@cohortapp/agent-sdk 2.5.0 → 2.6.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 +185 -88
- package/bin/maestro.test.mjs +175 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/inbound/hydrate.mjs +107 -15
- package/lib/org/inbound/hydrate.test.mjs +127 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
|
@@ -338,6 +338,93 @@ export function writeProposal(decision, o = {}) {
|
|
|
338
338
|
return { ok, ref: { proposed: true }, error: ok ? null : "proposal append failed" };
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Compact the engagement run result for the outcome journal. Ids, surface and
|
|
343
|
+
* the machine reason — never the body, never a room name.
|
|
344
|
+
*/
|
|
345
|
+
function engagementRef(eng) {
|
|
346
|
+
if (!eng || typeof eng !== "object") return null;
|
|
347
|
+
const d = eng.decision || {};
|
|
348
|
+
return {
|
|
349
|
+
engaged: eng.engaged === true,
|
|
350
|
+
outcome: eng.outcome || null,
|
|
351
|
+
surface: eng.surface || d.surface || "none",
|
|
352
|
+
reasonCode: d.reasonCode || null,
|
|
353
|
+
targets: Array.isArray(d.targets) ? d.targets.map((t) => t.memberId) : [],
|
|
354
|
+
rungs: Array.isArray(d.targets) ? d.targets.map((t) => t.rung) : [],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* An engagement problem the operator must see. A DELIBERATE non-engagement
|
|
360
|
+
* (`self_contained`, `already_engaged`, a rate limit) is not an error — it is
|
|
361
|
+
* the judgement working — so it returns null and lives in the ledger instead.
|
|
362
|
+
*/
|
|
363
|
+
function engagementError(eng) {
|
|
364
|
+
if (!eng || eng.engaged) return null;
|
|
365
|
+
const hard = new Set(["send_failed", "send_blocked", "engage_threw", "unroutable"]);
|
|
366
|
+
if (!hard.has(String(eng.outcome))) return null;
|
|
367
|
+
return `engagement ${eng.outcome}${eng.error ? `: ${eng.error}` : ""}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Turn a ladder decision into the `work` shape `lib/org/engagement.mjs` judges.
|
|
372
|
+
*
|
|
373
|
+
* ── WHY AN ESCALATION HAS TO ENGAGE SOMEBODY ──
|
|
374
|
+
* `escalate` used to end at `escalation.ask` / `escalation.create`: a row in a
|
|
375
|
+
* table. The live journal shows nine escalations, every one `spoke:false`, and
|
|
376
|
+
* three of them raised nothing at all ("Nothing was raised."). The ladder could
|
|
377
|
+
* decide a human was needed and then not tell a human. A record of a blockage
|
|
378
|
+
* that nobody is told about is a blockage nobody clears.
|
|
379
|
+
*
|
|
380
|
+
* So the record is still written — it is the auditable artefact and the thing a
|
|
381
|
+
* human can resolve in one click — and then the same decision is put in front of
|
|
382
|
+
* a person, through the judgement in engagement.mjs rather than by broadcasting.
|
|
383
|
+
* The judgement is what stops this becoming a firehose: it picks the addressee
|
|
384
|
+
* from the directory, picks the cheapest surface that reaches them, dedupes, and
|
|
385
|
+
* rate-limits. When it decides nobody needs to be involved, that is recorded too.
|
|
386
|
+
*
|
|
387
|
+
* `selfServeExhausted: true` is asserted here and it is honest: reaching the
|
|
388
|
+
* escalate rung IS the ladder having exhausted every disposition it can execute
|
|
389
|
+
* alone. This is the one caller entitled to assert it.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} decision
|
|
392
|
+
* @param {object} candidate
|
|
393
|
+
* @param {object} [o] { me, escalationTarget }
|
|
394
|
+
* @returns {object} work
|
|
395
|
+
*/
|
|
396
|
+
function workFromDecision(decision, candidate, o = {}) {
|
|
397
|
+
const ids = (candidate && candidate.ids) || {};
|
|
398
|
+
const options = (decision.options || []).map((l) => String(l || "").trim()).filter(Boolean);
|
|
399
|
+
return {
|
|
400
|
+
id: String(decision.key || `${decision.surface || "inbound"}:${decision.obligationKey || "uncovered"}`),
|
|
401
|
+
title: `${decision.surface || "inbound"}: ${decision.reason || "needs a decision"}`,
|
|
402
|
+
// REDACTION HOLDS. The escalation lane never carries message bodies, room
|
|
403
|
+
// names or addresses — ids, surfaces and machine reasons only. The engagement
|
|
404
|
+
// body is assembled from the decision, exactly as the escalation text is.
|
|
405
|
+
summary: `The execution ladder stopped at \`${decision.reason}\` on ${decision.surface || "an inbound event"} (rung ${decision.rung ?? "?"}).`,
|
|
406
|
+
scope: decision.surface || "",
|
|
407
|
+
state: "blocked",
|
|
408
|
+
blocker: {
|
|
409
|
+
kind: options.length >= 2 ? "decision" : "approval",
|
|
410
|
+
scope: decision.surface || "",
|
|
411
|
+
detail: (decision.why || []).join("; ") || String(decision.reason || ""),
|
|
412
|
+
ownerId: decision.escalateTo || o.escalationTarget || null,
|
|
413
|
+
},
|
|
414
|
+
// Reaching this rung IS the exhaustion of what the agent can do alone.
|
|
415
|
+
selfServeExhausted: true,
|
|
416
|
+
origin: {
|
|
417
|
+
surface: decision.surface || "",
|
|
418
|
+
channelId: ids.channelId || null,
|
|
419
|
+
threadRootId: ids.threadRootId || ids.messageId || null,
|
|
420
|
+
requesterId: ids.fromMemberId || ids.actorId || null,
|
|
421
|
+
},
|
|
422
|
+
question: options.length >= 2 ? "Which way should this go?" : "",
|
|
423
|
+
options,
|
|
424
|
+
tried: (decision.why || []).slice(0, 4),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
341
428
|
/**
|
|
342
429
|
* Append a PROPOSED REACT obligation for an event the compiled plan did not
|
|
343
430
|
* cover (§6.1: "emit `PlanDrift{uncovered_event}` **and** a proposed REACT
|
|
@@ -403,8 +490,48 @@ export function defaultEffects(o = {}) {
|
|
|
403
490
|
schedule: async ({ decision, candidate, item }) =>
|
|
404
491
|
scheduleToQueue(decision, { ...common, candidate, item: item || o.item || null }),
|
|
405
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Hand the work to someone else — and TELL them.
|
|
495
|
+
*
|
|
496
|
+
* `sendHandoff` writes an A2A envelope. That is the right machine-to-machine
|
|
497
|
+
* artefact and it is also, on its own, invisible: the journal shows the
|
|
498
|
+
* delegate lane has never once produced `spoke:true`, because an envelope is
|
|
499
|
+
* not a message. A human delegatee learns nothing from it, and an agent
|
|
500
|
+
* delegatee learns it only when it next drains its queue.
|
|
501
|
+
*
|
|
502
|
+
* So the envelope still goes, and the delegatee is also told directly. The
|
|
503
|
+
* engagement judgement is used rather than an unconditional DM, so the
|
|
504
|
+
* dedupe and the rate caps apply here exactly as they do to escalation — a
|
|
505
|
+
* retried handoff must not re-ping.
|
|
506
|
+
*/
|
|
406
507
|
delegate: async ({ decision, candidate }) => {
|
|
407
508
|
const { sendHandoff } = await import("../org/handoff.mjs");
|
|
509
|
+
const notifyLeg = (async () => {
|
|
510
|
+
if (!decision.delegateTo) return { ok: true, engaged: false, outcome: "no_delegatee" };
|
|
511
|
+
try {
|
|
512
|
+
const eng = o.engageImpl || (await import("../org/engagement.mjs")).engage;
|
|
513
|
+
const work = workFromDecision(decision, candidate, { me: o.me, escalationTarget: o.escalationTarget });
|
|
514
|
+
return await eng(
|
|
515
|
+
{
|
|
516
|
+
...work,
|
|
517
|
+
title: `Handed to you: ${work.title}`,
|
|
518
|
+
blocker: { ...work.blocker, kind: "decision", ownerId: String(decision.delegateTo) },
|
|
519
|
+
question: "This is now yours — shout if it should come back to me.",
|
|
520
|
+
options: [],
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
cfg: o.cfg,
|
|
524
|
+
agentRoot,
|
|
525
|
+
me: o.me || "",
|
|
526
|
+
fetchImpl: o.fetchImpl,
|
|
527
|
+
nowMs: o.nowMs,
|
|
528
|
+
ctx: o.engagementCtx,
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return { ok: false, engaged: false, outcome: "engage_threw", error: (err && err.message) || String(err) };
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
408
535
|
const r = await sendHandoff({
|
|
409
536
|
agentRoot,
|
|
410
537
|
from: o.me || "",
|
|
@@ -419,7 +546,12 @@ export function defaultEffects(o = {}) {
|
|
|
419
546
|
},
|
|
420
547
|
...(o.transport ? { transport: o.transport } : {}),
|
|
421
548
|
});
|
|
422
|
-
|
|
549
|
+
const eng = await notifyLeg;
|
|
550
|
+
return {
|
|
551
|
+
ok: r && r.ok !== false,
|
|
552
|
+
ref: { handoff: r, engagement: engagementRef(eng), spoke: eng.engaged === true },
|
|
553
|
+
error: r && r.error ? String(r.error) : engagementError(eng),
|
|
554
|
+
};
|
|
423
555
|
},
|
|
424
556
|
|
|
425
557
|
/**
|
|
@@ -461,6 +593,30 @@ export function defaultEffects(o = {}) {
|
|
|
461
593
|
return { ok: false, ref: null, error: "org not configured (no base) — cannot escalate" };
|
|
462
594
|
}
|
|
463
595
|
|
|
596
|
+
// THE PERSON LEG. Runs regardless of whether the record leg below can find
|
|
597
|
+
// a target, because "escalation.create needs a taskId or channelId and this
|
|
598
|
+
// event carries neither" must no longer mean nobody hears about it. The
|
|
599
|
+
// judgement decides who and where; it is entitled to decide nobody, and it
|
|
600
|
+
// records that decision either way.
|
|
601
|
+
const engageLeg = (async () => {
|
|
602
|
+
try {
|
|
603
|
+
const eng = o.engageImpl || (await import("../org/engagement.mjs")).engage;
|
|
604
|
+
const cfg = o.cfg || { org: { cohort: { enabled: true, base: conn.base, token: conn.token, agentId: o.me } } };
|
|
605
|
+
return await eng(workFromDecision(decision, candidate, { me: o.me, escalationTarget: o.escalationTarget }), {
|
|
606
|
+
cfg,
|
|
607
|
+
agentRoot,
|
|
608
|
+
me: o.me || "",
|
|
609
|
+
supervisorId: o.escalationTarget || undefined,
|
|
610
|
+
fetchImpl: o.fetchImpl,
|
|
611
|
+
nowMs: o.nowMs,
|
|
612
|
+
ctx: o.engagementCtx,
|
|
613
|
+
});
|
|
614
|
+
} catch (err) {
|
|
615
|
+
// FAIL-OPEN IS FINE; SILENT IS NOT.
|
|
616
|
+
return { ok: false, engaged: false, outcome: "engage_threw", error: (err && err.message) || String(err) };
|
|
617
|
+
}
|
|
618
|
+
})();
|
|
619
|
+
|
|
464
620
|
const surface = decision.surface || "inbound";
|
|
465
621
|
const ids = (candidate && candidate.ids) || {};
|
|
466
622
|
const options = (decision.options || [])
|
|
@@ -488,10 +644,13 @@ export function defaultEffects(o = {}) {
|
|
|
488
644
|
},
|
|
489
645
|
{ ...conn, agentRoot },
|
|
490
646
|
);
|
|
647
|
+
const eng = await engageLeg;
|
|
491
648
|
return {
|
|
492
|
-
ok: !!r && r.ok !== false,
|
|
493
|
-
|
|
494
|
-
|
|
649
|
+
ok: (!!r && r.ok !== false) || eng.engaged === true,
|
|
650
|
+
// `spoke` is read by drive.mjs. It is TRUE only when a message actually
|
|
651
|
+
// reached a person — never merely because a row was written.
|
|
652
|
+
ref: { method: "escalation.ask", frame: r, engagement: engagementRef(eng), spoke: eng.engaged === true },
|
|
653
|
+
error: r && r.error ? errText(r.error) : engagementError(eng),
|
|
495
654
|
};
|
|
496
655
|
}
|
|
497
656
|
|
|
@@ -501,13 +660,32 @@ export function defaultEffects(o = {}) {
|
|
|
501
660
|
const taskId = ids.taskId || ids.itemId || null;
|
|
502
661
|
const channelId = ids.channelId || null;
|
|
503
662
|
if (!taskId && !channelId) {
|
|
663
|
+
// No RECORD could be raised. That used to be the end of it — an honest
|
|
664
|
+
// error string and a human who never heard. The person leg still ran, so
|
|
665
|
+
// check it before declaring failure.
|
|
666
|
+
const eng = await engageLeg;
|
|
667
|
+
const cannotRaise =
|
|
668
|
+
`escalation.create needs a taskId or channelId and ${surface} event ` +
|
|
669
|
+
`${decision.key} carries neither; escalation.ask needs ≥2 options and ` +
|
|
670
|
+
`the decision offered ${options.length}. No escalation ROW was raised`;
|
|
671
|
+
if (eng.engaged) {
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
ref: {
|
|
675
|
+
method: "engagement-only",
|
|
676
|
+
engagement: engagementRef(eng),
|
|
677
|
+
spoke: true,
|
|
678
|
+
// Still a degradation: the audit ROW is missing even though the
|
|
679
|
+
// person was reached. Both facts travel together on the outcome.
|
|
680
|
+
degraded: [`escalation_record_unraisable:${decision.key}`],
|
|
681
|
+
},
|
|
682
|
+
error: null,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
504
685
|
return {
|
|
505
686
|
ok: false,
|
|
506
|
-
ref:
|
|
507
|
-
error:
|
|
508
|
-
`escalation.create needs a taskId or channelId and ${surface} event ` +
|
|
509
|
-
`${decision.key} carries neither; escalation.ask needs ≥2 options and ` +
|
|
510
|
-
`the decision offered ${options.length}. Nothing was raised.`,
|
|
687
|
+
ref: { engagement: engagementRef(eng), spoke: false },
|
|
688
|
+
error: `${cannotRaise}, and nobody was engaged either (${eng.outcome || "no outcome"}${eng.error ? `: ${eng.error}` : ""}). Nothing reached a human.`,
|
|
511
689
|
};
|
|
512
690
|
}
|
|
513
691
|
const r = await call(
|
|
@@ -522,10 +700,11 @@ export function defaultEffects(o = {}) {
|
|
|
522
700
|
},
|
|
523
701
|
{ ...conn, agentRoot },
|
|
524
702
|
);
|
|
703
|
+
const eng = await engageLeg;
|
|
525
704
|
return {
|
|
526
|
-
ok: !!r && r.ok !== false,
|
|
527
|
-
ref: { method: "escalation.create", frame: r },
|
|
528
|
-
error: r && r.error ? errText(r.error) :
|
|
705
|
+
ok: (!!r && r.ok !== false) || eng.engaged === true,
|
|
706
|
+
ref: { method: "escalation.create", frame: r, engagement: engagementRef(eng), spoke: eng.engaged === true },
|
|
707
|
+
error: r && r.error ? errText(r.error) : engagementError(eng),
|
|
529
708
|
};
|
|
530
709
|
},
|
|
531
710
|
|
|
@@ -237,12 +237,16 @@ test("escalate resolves the org binding from the agent root — `agentRoot` alon
|
|
|
237
237
|
});
|
|
238
238
|
{
|
|
239
239
|
assert.equal(r.ok, true, r.error || "escalate failed");
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
assert.
|
|
245
|
-
assert.
|
|
240
|
+
// The escalation now has TWO legs — the record, and the person. The
|
|
241
|
+
// person leg reads the directory + rooms first, so the request this test
|
|
242
|
+
// is about is found by name rather than by being the only one.
|
|
243
|
+
const ask = seen.find((s) => /escalation\.ask$/.test(s.url));
|
|
244
|
+
assert.ok(ask, "no escalation.ask request was made");
|
|
245
|
+
assert.equal(ask.body.options.length, 2);
|
|
246
|
+
assert.equal(typeof ask.body.context, "string", "hq's `context` is a STRING");
|
|
247
|
+
assert.equal(ask.body.trigger, "cannot_self_decide");
|
|
248
|
+
assert.ok(ask.body.question, "hq requires a question");
|
|
249
|
+
assert.ok(r.ref.engagement, "the escalation must report what it did about telling a human");
|
|
246
250
|
}
|
|
247
251
|
} finally {
|
|
248
252
|
globalThis.fetch = orig;
|
|
@@ -277,11 +281,11 @@ test("fewer than two options falls back to escalation.create WITH a target", asy
|
|
|
277
281
|
}
|
|
278
282
|
});
|
|
279
283
|
|
|
280
|
-
test("no options AND no target
|
|
284
|
+
test("no options AND no target: no escalation ROW is attempted, and the failure names the human who was not reached", async () => {
|
|
281
285
|
const root = enrolledRoot();
|
|
282
286
|
const orig = globalThis.fetch;
|
|
283
|
-
|
|
284
|
-
globalThis.fetch = async () => {
|
|
287
|
+
const urls = [];
|
|
288
|
+
globalThis.fetch = async (url) => { urls.push(String(url)); return new Response("{}", { status: 200 }); };
|
|
285
289
|
try {
|
|
286
290
|
const eff = defaultEffects({ agentRoot: root, me: "M-1" });
|
|
287
291
|
const r = await eff
|
|
@@ -289,14 +293,49 @@ test("no options AND no target REFUSES loudly rather than 400ing on the wire", a
|
|
|
289
293
|
{
|
|
290
294
|
assert.equal(r.ok, false);
|
|
291
295
|
assert.match(r.error, /needs a taskId or channelId/);
|
|
292
|
-
assert.match(r.error, /
|
|
293
|
-
|
|
296
|
+
assert.match(r.error, /No escalation ROW was raised/);
|
|
297
|
+
// THE POINT OF THE CHANGE. It used to end at "Nothing was raised" — a
|
|
298
|
+
// true statement about a table that told nobody anything. The failure now
|
|
299
|
+
// has to account for the person as well, and only claims total failure
|
|
300
|
+
// when neither leg reached anyone.
|
|
301
|
+
assert.match(r.error, /nobody was engaged either/);
|
|
302
|
+
assert.match(r.error, /Nothing reached a human/);
|
|
303
|
+
assert.equal(r.ref.spoke, false);
|
|
304
|
+
assert.ok(
|
|
305
|
+
urls.every((u) => !/escalation\./.test(u)),
|
|
306
|
+
"an escalation.* call that cannot succeed must still not be made",
|
|
307
|
+
);
|
|
294
308
|
}
|
|
295
309
|
} finally {
|
|
296
310
|
globalThis.fetch = orig;
|
|
297
311
|
}
|
|
298
312
|
});
|
|
299
313
|
|
|
314
|
+
test("when the record cannot be raised but a PERSON is reached, the escalation succeeds — and says the row is missing", async () => {
|
|
315
|
+
const root = enrolledRoot();
|
|
316
|
+
const orig = globalThis.fetch;
|
|
317
|
+
globalThis.fetch = async () => new Response("{}", { status: 200 });
|
|
318
|
+
try {
|
|
319
|
+
const eff = defaultEffects({
|
|
320
|
+
agentRoot: root,
|
|
321
|
+
me: "M-1",
|
|
322
|
+
// The judgement is injected here; it is proved on its own terms in
|
|
323
|
+
// lib/org/engagement.test.mjs.
|
|
324
|
+
engageImpl: async () => ({ ok: true, engaged: true, outcome: "engaged", surface: "dm", decision: { targets: [{ memberId: "M-2", rung: "supervisory" }], reasonCode: "blocked:approval" } }),
|
|
325
|
+
});
|
|
326
|
+
const r = await eff.escalate({
|
|
327
|
+
decision: { key: "k", surface: "approval", reason: "r", why: [], options: [] },
|
|
328
|
+
candidate: { ids: {} },
|
|
329
|
+
});
|
|
330
|
+
assert.equal(r.ok, true, "a human heard about it — that is not a failure");
|
|
331
|
+
assert.equal(r.ref.spoke, true);
|
|
332
|
+
assert.deepEqual(r.ref.engagement.targets, ["M-2"]);
|
|
333
|
+
assert.deepEqual(r.ref.degraded, ["escalation_record_unraisable:k"]);
|
|
334
|
+
} finally {
|
|
335
|
+
globalThis.fetch = orig;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
300
339
|
test("errText renders an RPC error frame as a line, never `[object Object]`", () => {
|
|
301
340
|
assert.equal(errText({ code: "BAD_REQUEST", message: "title is required" }), "BAD_REQUEST: title is required");
|
|
302
341
|
assert.equal(errText("plain"), "plain");
|
package/lib/goals/admission.mjs
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"use strict";
|
|
34
34
|
|
|
35
35
|
import { WORK_CREATING_SOURCES } from "../kpi.mjs";
|
|
36
|
+
import { adoptionDefect } from "../mandate/model.mjs";
|
|
36
37
|
|
|
37
38
|
/** Ordered gate names — evaluated in this order, first failure wins. */
|
|
38
39
|
export const GATES = Object.freeze([
|
|
@@ -122,11 +123,22 @@ export function admit(candidate = {}, ctx = {}) {
|
|
|
122
123
|
pass("freshness", "mandate cache is fresh");
|
|
123
124
|
|
|
124
125
|
// --- adoption -------------------------------------------------------------
|
|
126
|
+
// The gate's contract is "adopted by SOMEBODY WHO IS NOT ITS OWNER", but for a
|
|
127
|
+
// long time it only tested the state column — so a hand-edited cache with
|
|
128
|
+
// `state:"active"` and no `adoptedById` (or the owner's own id) admitted work.
|
|
129
|
+
// Same law as the compiler; one implementation, in lib/mandate/model.mjs.
|
|
125
130
|
const obj = ctx.objective || {};
|
|
126
131
|
if (obj.state !== "active") {
|
|
127
132
|
return fail("adoption", `objective "${obj.key || "?"}" is ${obj.state || "unknown"}, not active — a proposed objective admits no work`);
|
|
128
133
|
}
|
|
129
|
-
|
|
134
|
+
const defect = adoptionDefect(obj, ctx.memberId || obj.ownerMemberId || obj.memberId || null);
|
|
135
|
+
if (defect === "self_adopted") {
|
|
136
|
+
return fail("adoption", `objective "${obj.key || "?"}" was adopted by its own owning seat — a seat cannot define, measure and be graded on the same number`);
|
|
137
|
+
}
|
|
138
|
+
if (defect === "missing_adoption_provenance") {
|
|
139
|
+
return fail("adoption", `objective "${obj.key || "?"}" is active but carries no adoptedById — nobody is on record as having handed this seat the number`);
|
|
140
|
+
}
|
|
141
|
+
pass("adoption", `objective "${obj.key}" is active and adopted by ${obj.adoptedById}`);
|
|
130
142
|
|
|
131
143
|
// --- honesty --------------------------------------------------------------
|
|
132
144
|
const source = ctx.latestSample && ctx.latestSample.source;
|
|
@@ -10,7 +10,10 @@ import assert from "node:assert/strict";
|
|
|
10
10
|
|
|
11
11
|
import { admit, decideForGap, titleSimilarity, GATES, DUPLICATE_THRESHOLD } from "./admission.mjs";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// `adoptedById` is load-bearing, not decoration: an active objective with no
|
|
14
|
+
// adopter on record admits no work (the gate's contract has always been "adopted
|
|
15
|
+
// by SOMEBODY WHO IS NOT ITS OWNER", and it used to check only the state column).
|
|
16
|
+
const OBJ = { key: "pipeline", state: "active", metric: "pipeline_coverage_x", target: 3, cadence: "weekly", memberId: "M-OWNER", adoptedById: "M-SUPERVISOR" };
|
|
14
17
|
const GAP = { gap: 1, target: 3, value: 2, withinTolerance: false, trend: "flat", normalizedGap: 0.33, stale: false };
|
|
15
18
|
const SAMPLE = { source: "method", value: 2, window: "2026-W33" };
|
|
16
19
|
|
|
@@ -137,3 +140,25 @@ test("every rejection carries its gate and the full check trail — nothing is d
|
|
|
137
140
|
assert.equal(v.checks[v.checks.length - 1].ok, false);
|
|
138
141
|
assert.equal(v.checks[v.checks.length - 1].gate, v.gate);
|
|
139
142
|
});
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// the adoption gate reads the LAW, not the state column
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
test("GATE adoption — an active objective with NO adoptedById admits nothing", () => {
|
|
149
|
+
// The cheap tamper. Forging a supervisor id is work; DELETING the field was one
|
|
150
|
+
// keystroke, and `isSelfAdopted` returned false for a missing field, so the row
|
|
151
|
+
// sailed through both the compiler and this gate.
|
|
152
|
+
const { adoptedById, ...noProvenance } = OBJ;
|
|
153
|
+
const v = admit(candidate(), ctx({ objective: noProvenance }));
|
|
154
|
+
assert.equal(v.admitted, false);
|
|
155
|
+
assert.equal(v.gate, "adoption");
|
|
156
|
+
assert.match(v.reason, /no adoptedById/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("GATE adoption — a SELF-adopted objective admits nothing", () => {
|
|
160
|
+
const v = admit(candidate(), ctx({ objective: { ...OBJ, adoptedById: "M-OWNER" } }));
|
|
161
|
+
assert.equal(v.admitted, false);
|
|
162
|
+
assert.equal(v.gate, "adoption");
|
|
163
|
+
assert.match(v.reason, /own owning seat/);
|
|
164
|
+
});
|
package/lib/goals/loop.mjs
CHANGED
|
@@ -104,6 +104,19 @@ export function renderQueueItems(items) {
|
|
|
104
104
|
L.push(` disposition: ${it.disposition || "SELF"}`);
|
|
105
105
|
L.push(` rung: ${it.rung == null ? "null" : it.rung}`);
|
|
106
106
|
L.push(` why: "${String((it.why && it.why.reason) || "kpi-gap").replace(/"/g, "'")}"`);
|
|
107
|
+
// The provenance an operator needs to answer "why did the agent do that?"
|
|
108
|
+
// from the queue file alone, with no network and no LLM. `why` used to be
|
|
109
|
+
// flattened to the bare string "kpi-gap", which named the CLASS of reason
|
|
110
|
+
// and none of the facts: which charter clause the item advances, which
|
|
111
|
+
// obligation it discharges, how big the gap was, and — the governance one —
|
|
112
|
+
// who adopted the objective it serves. A machine-created task that cannot
|
|
113
|
+
// say who authorised the number behind it is exactly the thing the mandate
|
|
114
|
+
// split exists to prevent.
|
|
115
|
+
const w = it.why || {};
|
|
116
|
+
if (w.clause) L.push(` charter_clause: ${w.clause}`);
|
|
117
|
+
if (w.adoptedBy) L.push(` adopted_by: ${w.adoptedBy}`);
|
|
118
|
+
if (Number.isFinite(w.gap)) L.push(` gap: ${w.gap}`);
|
|
119
|
+
if (w.trend) L.push(` trend: ${w.trend}`);
|
|
107
120
|
L.push(` created: ${it.created}`);
|
|
108
121
|
}
|
|
109
122
|
return L.join("\n");
|
package/lib/kpi-sensors.test.mjs
CHANGED
|
@@ -52,6 +52,9 @@ function fakeExec(table, calls = []) {
|
|
|
52
52
|
const OBJ = (capability, params = {}) => ({
|
|
53
53
|
key: "k", kind: "GOAL", state: "active", metric: "m", direction: "up",
|
|
54
54
|
target: 90, tolerance: 2, cadence: "weekly",
|
|
55
|
+
// `adoptedById` is part of the law the admission gate enforces, not decoration:
|
|
56
|
+
// an active objective with nobody on record as having adopted it admits no work.
|
|
57
|
+
memberId: "M-OWNER", adoptedById: "M-SUPERVISOR",
|
|
55
58
|
sensor: { capability, params, source: "method" },
|
|
56
59
|
});
|
|
57
60
|
|
package/lib/mandate/cache.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "
|
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
import { createHash } from "node:crypto";
|
|
35
35
|
|
|
36
|
+
import { adoptedObjectives as modelAdoptedObjectives } from "./model.mjs";
|
|
37
|
+
|
|
36
38
|
/** Relative path of the cache (git-ignored runtime state). */
|
|
37
39
|
export const CACHE_REL = join("state", "mandate", "cache.json");
|
|
38
40
|
|
|
@@ -143,11 +145,17 @@ export function permissionsForTier(tier) {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Are the adopted objectives in this cache usable to compile obligations?
|
|
150
|
+
*
|
|
151
|
+
* DELEGATES to `lib/mandate/model.adoptedObjectives`, which carries the whole
|
|
152
|
+
* anti-Goodhart law (measurable kind, active, real adoption provenance, not
|
|
153
|
+
* self-adopted). This used to be a second, LAW-FREE implementation that filtered
|
|
154
|
+
* on `state === "active"` alone — so whichever module a caller happened to
|
|
155
|
+
* import decided whether the law applied. One law, one implementation.
|
|
156
|
+
*/
|
|
157
|
+
export function adoptedObjectives(record, opts = {}) {
|
|
158
|
+
return modelAdoptedObjectives(record, opts);
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/** Every objective in the cache, adopted or not (the proposal surface). */
|