@cohortapp/agent-sdk 2.4.1 → 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.
Files changed (79) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/sections/mandate.mjs +43 -1
  57. package/lib/subagents/schema.mjs +14 -2
  58. package/lib/subagents/schema.test.mjs +22 -0
  59. package/package.json +1 -1
  60. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  61. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  62. package/scripts/ci/check.mjs +3 -0
  63. package/scripts/ci/conformance-org-api.mjs +16 -0
  64. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  65. package/scripts/daemon/agent-daemon.mjs +582 -28
  66. package/scripts/daemon/cadence-handlers.mjs +273 -17
  67. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  68. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  69. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  70. package/scripts/daemon/maestro-daemon.mjs +53 -0
  71. package/scripts/daemon/prompt-builder.mjs +47 -0
  72. package/scripts/daemon/responder.mjs +70 -3
  73. package/scripts/poller/imap-client.mjs +20 -1
  74. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  75. package/scripts/poller/utils.mjs +51 -0
  76. package/scripts/setup/generate-capability.mjs +120 -11
  77. package/scripts/setup/generate-capability.test.mjs +134 -0
  78. package/scripts/setup/generate-plan.mjs +6 -1
  79. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -114,6 +114,8 @@ export async function hydrate(o = {}) {
114
114
  return hydrateApproval(c, facts);
115
115
  case "handoff":
116
116
  return hydrateHandoff(c, facts);
117
+ case "calendar":
118
+ return hydrateCalendar(c, facts);
117
119
  case "email":
118
120
  return await hydrateEmail(c, facts, io, cache);
119
121
  default:
@@ -324,6 +326,44 @@ function hydrateEscalation(c, facts) {
324
326
  };
325
327
  }
326
328
 
329
+ /**
330
+ * A calendar event. The body came back on the ACL'd `calendar.list` read in the
331
+ * facts pass — that read IS the probe, so there is no second request here and
332
+ * nothing is rendered that hq did not already hand this seat.
333
+ */
334
+ function hydrateCalendar(c, facts) {
335
+ const id = s(c.ids.eventId);
336
+ const e = facts.myEvents instanceof Map ? facts.myEvents.get(id) : null;
337
+ const title = s((e && e.title) || c.ids.title || id);
338
+ const lines = [`${describeCalendarKind(c.kind)} — "${title}"`];
339
+ const startsAt = s((e && e.startsAt) || c.ids.startsAt);
340
+ if (startsAt) lines.push(`Starts: ${startsAt}${e && e.endsAt ? ` · ends ${s(e.endsAt)}` : ""}`);
341
+ if (c.kind === "event.rsvp" && c.ids.rsvp) lines.push(`RSVP: ${s(c.ids.rsvp)}`);
342
+ if (e && e.location) lines.push(`Location: ${s(e.location)}`);
343
+ if (e && e.description) lines.push(clip(e.description, 1200));
344
+ return {
345
+ ok: true,
346
+ text: clip(lines.join("\n")),
347
+ subjectDetail: title,
348
+ threadId: id,
349
+ from: { id: s(c.actor), name: s(c.actor) },
350
+ channelId: s(c.ids.channelId || (e && e.channelId) || ""),
351
+ channelLabel: `calendar/${title}`,
352
+ };
353
+ }
354
+
355
+ /** Human phrasing for a `calendar.*` event kind. */
356
+ export function describeCalendarKind(kind) {
357
+ switch (kind) {
358
+ case "event.created": return "Calendar event created";
359
+ case "event.updated": return "Calendar event updated";
360
+ case "event.rescheduled": return "Calendar event rescheduled";
361
+ case "event.rsvp": return "Calendar RSVP";
362
+ case "event.deleted": return "Calendar event cancelled";
363
+ default: return "Calendar update";
364
+ }
365
+ }
366
+
327
367
  /** An approval — already read back by the facts pass (that IS the probe). */
328
368
  function hydrateApproval(c, facts) {
329
369
  const id = s(c.ids.approvalId);
@@ -322,3 +322,86 @@ test("an empty ledger window is a no-op that still advances nothing", async () =
322
322
  assert.deepEqual(out.events, []);
323
323
  assert.equal(out.nextCursor, 5);
324
324
  });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Calendar (JOINT 3) — the surface hq's own agent.wait lanes do not carry
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * The calendar payload is redacted like every other family: `event.created`
332
+ * carries `attendeeCount` and the CALENDAR OWNER, never the attendee list. So
333
+ * "am I in this meeting?" is answered by `calendar.list` — hq's seat-scoped
334
+ * query (owner OR attendeeRows, under the PERSONAL_RESTRICTED lens floor) — and
335
+ * NEVER by widening the chain payload.
336
+ */
337
+ test("calendar: an event I attend arrives; a colleague's event never does", async () => {
338
+ const io = fakeIo({
339
+ "read:events": okRead({
340
+ events: [
341
+ // Mine — I am an attendee (owner is someone else).
342
+ { seq: 900, family: "calendar", kind: "event.created", entity_id: "EV-mine", actor: BOSS,
343
+ at: "2026-08-11T10:00:00.000Z",
344
+ payload: { title: "Transport review", startsAt: "2026-08-12T10:00:00.000Z", endsAt: "2026-08-12T11:00:00.000Z",
345
+ eventKind: "MEETING", attendeeCount: 4, calendarMemberId: BOSS } },
346
+ // NOT mine — a meeting between two colleagues. `calendar.list` will not
347
+ // return it, so it must never be delivered or hydrated.
348
+ { seq: 901, family: "calendar", kind: "event.created", entity_id: "EV-theirs", actor: THEM,
349
+ at: "2026-08-11T10:05:00.000Z",
350
+ payload: { title: "Their 1:1", startsAt: "2026-08-12T14:00:00.000Z",
351
+ eventKind: "MEETING", attendeeCount: 2, calendarMemberId: THEM } },
352
+ ],
353
+ nextCursor: 901,
354
+ }),
355
+ "calendar.list": okFrame({
356
+ events: [
357
+ { id: "EV-mine", title: "Transport review", startsAt: "2026-08-12T10:00:00.000Z",
358
+ endsAt: "2026-08-12T11:00:00.000Z", location: "Huddle 2", description: "Walk the five hops",
359
+ calendarMemberId: BOSS, channelId: "C-pub" },
360
+ ],
361
+ }),
362
+ });
363
+
364
+ const res = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 899, io });
365
+
366
+ assert.equal(res.events.length, 1, "exactly one calendar item — the one I am in");
367
+ const ev = res.events[0];
368
+ assert.equal(ev.kind, "calendar");
369
+ assert.equal(ev.cohort.surface, "calendar");
370
+ assert.equal(ev.cohort.reason, "attendee", "attendance was proven by an ACL'd read, not by a payload");
371
+ assert.match(ev.text, /Calendar event created — "Transport review"/);
372
+ assert.match(ev.text, /Walk the five hops/);
373
+ assert.equal(res.stats.bySurface.calendar, 1);
374
+ assert.equal(res.stats.dropped.calendar_not_mine, 1, "the colleague's meeting was dropped with a named reason");
375
+
376
+ // ACL by call log: hq was asked ONE seat-scoped question, and we never probed
377
+ // the event we were not entitled to.
378
+ const calendarCalls = io.calls.filter((c) => c.method === "calendar.list");
379
+ assert.equal(calendarCalls.length, 1, "one list read per pull, not one probe per event");
380
+ assert.ok(
381
+ calendarCalls[0].params.calendarMemberId === undefined,
382
+ "no calendarMemberId → hq uses the ACTING SEAT's viewpoint; naming one would be asking about someone else's calendar",
383
+ );
384
+ assert.equal(io.calls.filter((c) => c.method === "calendar.get").length, 0, "no per-event probe at all");
385
+ });
386
+
387
+ test("calendar: an unreadable calendar fails CLOSED and says so", async () => {
388
+ const io = fakeIo({
389
+ "read:events": okRead({
390
+ events: [
391
+ { seq: 910, family: "calendar", kind: "event.rescheduled", entity_id: "EV-mine", actor: BOSS,
392
+ at: "2026-08-11T10:00:00.000Z", payload: { title: "Transport review", to: "2026-08-13T10:00:00.000Z" } },
393
+ ],
394
+ nextCursor: 910,
395
+ }),
396
+ // calendar.list not routed → the fake returns NOT_FOUND, i.e. a failed read.
397
+ });
398
+
399
+ const res = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 909, io });
400
+ assert.equal(res.events.length, 0, "a private surface guesses CLOSED, never open");
401
+ assert.equal(res.stats.dropped.calendar_unknown, 1);
402
+ assert.ok(res.stats.degraded.includes("calendar"), "and the degradation is reported, not swallowed");
403
+ assert.ok(
404
+ io.logs.some((l) => l.level === "warn" && /calendar\.list unreadable/.test(l.message)),
405
+ `fail-open is fine, silent is not; got ${JSON.stringify(io.logs)}`,
406
+ );
407
+ });
@@ -144,6 +144,14 @@ export function toMessageEvent(o = {}) {
144
144
  family: c.family,
145
145
  event_kind: c.kind,
146
146
  entity_id: entityId || null,
147
+ // The subject this surface hangs off, when it is NOT a channel: the task
148
+ // an approval blocks or an escalation flags, the decision a comment sits
149
+ // on, the file a comment is against. `channel_id` above deliberately only
150
+ // ever carries a real Cohort channel — a reply routes with it — so
151
+ // without this the downstream ladder has the event and no handle on the
152
+ // thing the event is about. `effects.escalate` needs exactly this to
153
+ // satisfy hq's "an escalation must reference a task or a channel".
154
+ scope_id: s(c.ids.taskId || c.ids.decisionId || c.ids.fileId || "") || null,
147
155
  seq: c.seq ?? null,
148
156
  me: s(me),
149
157
  },
@@ -127,6 +127,26 @@ export const SURFACES = Object.freeze({
127
127
  scope: "channel",
128
128
  default: true,
129
129
  }),
130
+ /**
131
+ * A calendar event on MY calendar, or one I am an attendee of: created,
132
+ * updated, rescheduled, RSVP'd, deleted.
133
+ *
134
+ * ADDRESSING. The `calendar.*` payload is redacted like every other family —
135
+ * `event.created` carries `attendeeCount`, `invitesQueued` and the CALENDAR
136
+ * OWNER (`calendarMemberId`), never the attendee list. So ownership is the
137
+ * only thing the frame can prove, and attendance has to come from
138
+ * `calendar.list`, which is ACL'd to the acting seat: it returns events where
139
+ * `calendarMemberId = viewpoint` OR `attendeeRows.some(memberId = viewpoint)`,
140
+ * under hq's PERSONAL_RESTRICTED lens floor (a colleague's restricted event is
141
+ * ABSENT, never redacted). We never widen the payload to carry attendees.
142
+ */
143
+ calendar: Object.freeze({
144
+ topic: "calendar",
145
+ kind: "calendar",
146
+ subject: "Cohort calendar",
147
+ scope: "channel",
148
+ default: true,
149
+ }),
130
150
  /** A delegation offered to me, or one I offered being accepted/declined. */
131
151
  handoff: Object.freeze({
132
152
  topic: "handoff",
@@ -422,11 +422,25 @@ export const PARAM_CONTRACT = {
422
422
  note: "hq resolves a member by SLUG, not id.",
423
423
  },
424
424
  "escalation.create": {
425
- alias: { subject: "title", body: "detail", description: "detail" },
425
+ alias: { subject: "title", body: "detail", description: "detail", summary: "title" },
426
426
  enums: { severity: ESCALATION_SEVERITIES },
427
427
  required: ["title"],
428
428
  oneOf: [["taskId", "channelId"]],
429
- note: "hq requires a title AND a target (taskId or channelId).",
429
+ unsupported: ["waitingOnMemberId", "options", "context"],
430
+ note:
431
+ "hq requires a title AND a target (taskId or channelId). `summary` is aliased " +
432
+ "because lib/execution/effects.mjs sent exactly that and 400'd on every call; " +
433
+ "`waitingOnMemberId`/`options`/`context` are NOT fields of this method — an " +
434
+ "escalation carrying OPTIONS is an OpenQuestion and belongs at escalation.ask.",
435
+ },
436
+ "escalation.ask": {
437
+ // hq methods/escalation/ask.ts: `question` (<=2000) + >=2 DISTINCT options,
438
+ // `context` is a STRING (<=4000) — not an object — and `trigger` <=100.
439
+ // The two-option floor is enforced server-side and is the whole point: an
440
+ // escalation with one option is a notification, with none it is prose.
441
+ alias: { text: "question", prompt: "question", choices: "options" },
442
+ required: ["question", "options"],
443
+ note: "at least two distinct options, or hq 400s; `context` is a string, not an object.",
430
444
  },
431
445
 
432
446
  // ── leases ───────────────────────────────────────────────────────────────
@@ -1 +1 @@
1
- 85e71129422236b2de83d7f2da1ed04821b9f7c3f7a025a31939cf95d5a4a7db
1
+ 1946d98145f4c1e56d80e58c37d9b54d5ea8d88c7d30251a694484418327dddf
@@ -55,6 +55,27 @@ export const FAMILIES = Object.freeze([
55
55
  // the Directory (2026-07): the canonical registry of external people and
56
56
  // organisations every other desk resolves its counterparties through.
57
57
  "directory",
58
+ // the sub-agent registry (2026-08): layer 1 of the SDK / workspace /
59
+ // agent-local resolution model — org-scoped, versioned sub-agent definitions
60
+ // pinned per seat. Governs the DISTRIBUTION and provenance of agent prose;
61
+ // resolution itself runs locally on the agent, never here.
62
+ "subagent",
63
+ // the agent's own control lane (2026-08): agent.wait, the bounded long-poll a
64
+ // daemon parks on instead of polling every 45s. Read-only — nothing in this
65
+ // family appends to the chain; it only READS the chain by seq.
66
+ "agent",
67
+ // the MANDATE spine (2026-08, SPEC "Three-Dimensional Agent Autonomy"): the
68
+ // objective tree, its KPI samples, the published per-seat mandate snapshot,
69
+ // and the agent's own capability/plan/drift reports. hq owns the MANDATE
70
+ // (outcomes, owners, targets, budgets, kill-switch); the agent owns the PLAN
71
+ // (obligations, schedule, rung) and compiles it locally. This family is the
72
+ // wire between the two halves.
73
+ "mandate",
74
+ // member-scoped PREFERENCES (2026-08, SPEC §9 reverse parity). hq has always
75
+ // injected these into responder context automatically; the family exists so a
76
+ // seat can PERSIST one it learned rather than re-deriving it every session.
77
+ // The chain rows carry subject + domain + key only — never the value.
78
+ "preference",
58
79
  ]);
59
80
 
60
81
  /**
@@ -131,6 +152,17 @@ export const SCOPES = Object.freeze([
131
152
  "directory.write", // Directory writes: create/update parties, channels, captures, links, lists. Structural verbs (merge execution, awareness change, archive) refuse an agent actor in-domain and create a review item instead
132
153
  "design.read", // Design reads: the SAVED brand foundation snapshot (never a draft), voice, visible templates. Renders and voice rewrites are AUDITED side-effecting reads under this scope (files.exportRequest precedent)
133
154
  "design.write", // Design writes: generate imagery, propose a foundation change. Foundation mutation itself is admin-only and human-gated -- a proposal creates a reviewable diff, it does not write
155
+ // mandate spine (2026-08). The read/propose/write split is the anti-Goodhart
156
+ // law expressed as scopes, and it deliberately mirrors approval.request vs
157
+ // approval.decide: an agent may READ its mandate and PROPOSE against it, but
158
+ // ADOPTING an objective (making it live, work-creating and graded) is a
159
+ // human/manager act the seat's own credential cannot perform.
160
+ "mandate.read", // read own mandate snapshot + version + KPI series
161
+ "mandate.propose", // propose/update objectives — `state:'proposed'` ONLY, never an adopted row
162
+ "mandate.write", // adopt / retire an objective. NOT in DEFAULT_AGENT_SCOPES — human/manager only
163
+ "kpi.write", // report a KPI sample against an objective. Honesty is enforced by the
164
+ // server-set `source` field, never by scope: a seat may always report,
165
+ // but an `llm`-sourced number is advisory and cannot create work.
134
166
  "admin", // pairing approval, deactivate (kill switch), governance, policy, credential put/revoke
135
167
  ]);
136
168
 
@@ -148,6 +180,11 @@ export const DEFAULT_AGENT_SCOPES = Object.freeze([
148
180
  "files.read", "files.write", "calendar.read", "calendar.write",
149
181
  "crm.read", "crm.write", "books.read", "books.write",
150
182
  "directory.read", "directory.write", "design.read", "design.write",
183
+ // mandate spine (2026-08): a seat reads its own mandate, proposes against it,
184
+ // and reports its own samples. `mandate.write` (adopt/retire) is POINTEDLY
185
+ // ABSENT — that is the whole anti-Goodhart law. A seat cannot define, measure
186
+ // AND be graded on the same number.
187
+ "mandate.read", "mandate.propose", "kpi.write",
151
188
  ]);
152
189
 
153
190
  /** Knowledge group scopes: org-wide, per-unit, or an information-barrier cell (deny-override). */
@@ -240,6 +277,13 @@ export const METHODS = Object.freeze({
240
277
  "messaging.react": { family: "messaging", scope: "messaging.write", sideEffecting: true },
241
278
  "messaging.edit": { family: "messaging", scope: "messaging.write", sideEffecting: true },
242
279
  "messaging.history": { family: "messaging", scope: "messaging.read", sideEffecting: false },
280
+ // --- messaging.search (2026-08 REVERSE PARITY, SPEC §9): the family carried
281
+ // thirteen methods and no search, so an SDK-driven seat could not answer
282
+ // "what did we decide about X anywhere?" while the hq responder in the
283
+ // same workspace could (its `search_messages` tool). Postgres FTS across
284
+ // every channel the paired member can read; the ACL floor is computed
285
+ // server-side, so `channelId`/`channelSlug` narrow and can never widen. ---
286
+ "messaging.search": { family: "messaging", scope: "messaging.read", sideEffecting: false },
243
287
  // --- messaging.typing (2026-08): the "is composing…" indicator an agent raises
244
288
  // while it works. The ONE write-less method in the family — it appends NO
245
289
  // chain event and writes NO row, it publishes an EPHEMERAL frame that
@@ -357,6 +401,14 @@ export const METHODS = Object.freeze({
357
401
  "escalation.create": { family: "escalation", scope: "org.write", sideEffecting: true, idempotent: true },
358
402
  "escalation.resolve": { family: "escalation", scope: "org.write", sideEffecting: true },
359
403
  "escalation.list": { family: "escalation", scope: "org.read", sideEffecting: false },
404
+ // `OpenQuestion`'s FIRST WRITERS. The model has had readers and a UI for
405
+ // months with no producer, so a charter `escalationTrigger` firing could only
406
+ // ever emit prose. `ask` requires >= 2 concrete options (a one-option question
407
+ // is a notification, not a decision); `answer` is deliberately NOT idempotent
408
+ // — a second answer is a CONFLICT so the first decision stands — and refuses
409
+ // the asker, the same law as `Approval.requester ≠ approver`.
410
+ "escalation.ask": { family: "escalation", scope: "org.write", sideEffecting: true },
411
+ "escalation.answer": { family: "escalation", scope: "org.write", sideEffecting: true },
360
412
  // --- annotation ---
361
413
  "annotation.create": { family: "annotation", scope: "org.write", sideEffecting: true, idempotent: true },
362
414
  "annotation.comment": { family: "annotation", scope: "org.write", sideEffecting: true },
@@ -380,6 +432,26 @@ export const METHODS = Object.freeze({
380
432
  "memory.proposeAmendment": { family: "memory", scope: "knowledge.write", sideEffecting: true },
381
433
  "memory.mergeAmendment": { family: "memory", scope: "knowledge.write", sideEffecting: true },
382
434
  "memory.list": { family: "memory", scope: "knowledge.read", sideEffecting: false },
435
+ // --- memory.recall (2026-08 REVERSE PARITY, SPEC §9): layered semantic recall
436
+ // over the unified agent memory, with the FULL contract the hq responder's
437
+ // `recall_memory` tool has had since the agent-memory program shipped —
438
+ // scope (org|self|channel) / refType+refId / time range / depth /
439
+ // drill-down pointers. `knowledge.search` is a narrowed projection of this
440
+ // (shared facts only, no controls), so until now an SDK seat reasoned off
441
+ // a strictly smaller memory than the chat lane in the same workspace.
442
+ // ACL is server-side off the paired member — fail-closed, never a param. ---
443
+ "memory.recall": { family: "memory", scope: "knowledge.read", sideEffecting: false },
444
+ // --- preference (2026-08 REVERSE PARITY, SPEC §9): there was NO preference.*
445
+ // family at all. An SDK agent could LEARN a durable member-scoped fact and
446
+ // had nowhere to put it, while the hq responder's `remember_preference`
447
+ // wrote straight into PreferenceMemory — which hq then injects into every
448
+ // future conversation. Same workspace, two memories. `upsert` converges on
449
+ // one row per (subject, domain, key) and REQUIRES evidence; the chain
450
+ // append carries whose/which-key and never the value (a preference value
451
+ // is content). `list` ships with it because a write-only family is not
452
+ // parity: hq SEES injected preferences for free, a seat has to ask. ---
453
+ "preference.upsert": { family: "preference", scope: "org.write", sideEffecting: true, idempotent: true },
454
+ "preference.list": { family: "preference", scope: "org.read", sideEffecting: false },
383
455
  // --- compact ---
384
456
  "compact.upsert": { family: "compact", scope: "org.write", sideEffecting: true, idempotent: true },
385
457
  // --- notification ---
@@ -775,8 +847,124 @@ export const METHODS = Object.freeze({
775
847
  "files.comments": { family: "files", scope: "files.read", sideEffecting: false },
776
848
  "files.sheetFedRange": { family: "files", scope: "files.write", sideEffecting: true, idempotent: true },
777
849
  "files.ask": { family: "files", scope: "files.read", sideEffecting: false },
850
+ // --- subagent (2026-08): the org-scoped sub-agent registry. NO NEW SCOPES —
851
+ // a sub-agent definition is org-structural authored content exactly like
852
+ // a persona, an SOP or an annotation, which is what `org.write` covers,
853
+ // and both org.read/org.write are already in DEFAULT_AGENT_SCOPES.
854
+ // Curation (`yank`) is admin, like every other destructive verb.
855
+ //
856
+ // `resolve` is the EXPENSIVE read (bodies + provenance + rewire hints);
857
+ // the cheap poll is the `subagent.roster` GET in READS, which returns
858
+ // {slug, definitionId, version, contentHash} and no bodies. Naming a
859
+ // memberId other than the caller's own seat requires `admin` — enforced
860
+ // in-domain (methods/subagent/_shared.ts), the invokeTool doctrine. ---
861
+ "subagent.list": { family: "subagent", scope: "org.read", sideEffecting: false },
862
+ "subagent.get": { family: "subagent", scope: "org.read", sideEffecting: false },
863
+ "subagent.resolve": { family: "subagent", scope: "org.read", sideEffecting: false },
864
+ "subagent.create": { family: "subagent", scope: "org.write", sideEffecting: true, idempotent: true },
865
+ "subagent.publishVersion": { family: "subagent", scope: "org.write", sideEffecting: true, idempotent: true },
866
+ "subagent.fork": { family: "subagent", scope: "org.write", sideEffecting: true, idempotent: true },
867
+ "subagent.pin": { family: "subagent", scope: "org.write", sideEffecting: true, idempotent: true },
868
+ "subagent.unpin": { family: "subagent", scope: "org.write", sideEffecting: true },
869
+ "subagent.yank": { family: "subagent", scope: "admin", sideEffecting: true },
870
+ // --- agent control lane (2026-08): the bounded long-poll a daemon parks on so
871
+ // it learns about its own work in ~0s instead of on a 45s poll cadence.
872
+ // Modelled on the `approval.wait` precedent (READS, a held request), but
873
+ // registered here as a POST method because it takes a CURSOR + a page of
874
+ // structured results, which is the METHODS shape — and because a held GET
875
+ // is far more likely to be cached/coalesced by an intermediary.
876
+ //
877
+ // NO NEW SCOPE. `org.read` is the floor to call it at all (it is in
878
+ // DEFAULT_AGENT_SCOPES, so every paired agent can park), and each LANE it
879
+ // reports on is gated separately in-domain against the key's own scopes
880
+ // (messaging.read / board.read / email.read) — a key minted without
881
+ // email.read is never told that mail arrived. sideEffecting:false: it
882
+ // appends NOTHING to the chain, it only reads the chain by seq. ---
883
+ "agent.wait": { family: "agent", scope: "org.read", sideEffecting: false },
884
+ // --- mandate (2026-08): the MANDATE half of the mandate/plan split. hq is
885
+ // authoritative for WHAT the outcomes are; the agent compiles the plan.
886
+ //
887
+ // The scope split is the design: `mandate.read`/`mandate.propose`/
888
+ // `kpi.write` are in DEFAULT_AGENT_SCOPES, `mandate.write` is not.
889
+ // `adopt`/`retire` are ALSO governanceGated — the decision-rights matrix
890
+ // must exist before a seat can be handed a live, graded objective, the
891
+ // same ordering rule that gates board.assign and handoff.*.
892
+ //
893
+ // `declareCapability`/`publishPlan`/`reportDrift` reuse `registry.write`:
894
+ // they are a seat reporting facts about ITSELF, exactly like
895
+ // registry.register, and every paired agent already holds it. ---
896
+ "mandate.get": { family: "mandate", scope: "mandate.read", sideEffecting: false },
897
+ "mandate.version": { family: "mandate", scope: "mandate.read", sideEffecting: false },
898
+ "mandate.tree": { family: "mandate", scope: "org.read", sideEffecting: false },
899
+ "mandate.series": { family: "mandate", scope: "mandate.read", sideEffecting: false },
900
+ "mandate.propose": { family: "mandate", scope: "mandate.propose", sideEffecting: true, idempotent: true },
901
+ "mandate.adopt": { family: "mandate", scope: "mandate.write", sideEffecting: true, governanceGated: true },
902
+ "mandate.retire": { family: "mandate", scope: "mandate.write", sideEffecting: true, governanceGated: true },
903
+ "mandate.sample": { family: "mandate", scope: "kpi.write", sideEffecting: true, idempotent: true },
904
+ "mandate.declareCapability": { family: "mandate", scope: "registry.write", sideEffecting: true, idempotent: true },
905
+ "mandate.publishPlan": { family: "mandate", scope: "registry.write", sideEffecting: true, idempotent: true },
906
+ "mandate.reportDrift": { family: "mandate", scope: "registry.write", sideEffecting: true },
907
+ // --- board review lane (2026-08 consensus fix): `Task.reviewerId` has existed
908
+ // since T19 and had NO writer on the agent plane, so "send it for review"
909
+ // was unreachable from a daemon and every review round-trip degraded to a
910
+ // comment. These two methods are the writer. ---
911
+ "board.requestReview": { family: "board", scope: "board.write", sideEffecting: true },
912
+ "board.resolveReview": { family: "board", scope: "board.write", sideEffecting: true },
913
+ // --- escalation as a QUESTION (2026-08): `OpenQuestion` shipped with readers
914
+ // and no writer at all. `escalation.ask` is its first one. The difference
915
+ // from `escalation.create` matters: an Escalation is "a human is needed
916
+ // here"; an OpenQuestion is "here are 2-3 concrete options, pick one",
917
+ // which is what a Charter.escalationTriggers hit should actually produce. ---
918
+ // NOTE: `escalation.ask` / `escalation.answer` (raise an OpenQuestion with
919
+ // concrete options, and the human picking one) are deliberately NOT registered
920
+ // yet — `ask` needs a `_question` projector that is not written. A METHODS
921
+ // entry with no working handler is not a placeholder, it is a live promise the
922
+ // dispatcher cannot keep: the protocol advertises the method, the caller gets
923
+ // through scope resolution, and the call 500s. Register each in the same
924
+ // change that lands its handler.
925
+ // NOTE: `escalation.answer` (the human picking one of the offered options) is
926
+ // deliberately NOT registered yet — its handler is not written. A METHODS
927
+ // entry with no handler file is not a placeholder, it is a live promise the
928
+ // dispatcher cannot keep: the protocol advertises the method, callers get
929
+ // through scope resolution, and the call 500s. Register it in the same change
930
+ // that adds src/server/methods/escalation/answer.ts.
778
931
  });
779
932
 
933
+ /**
934
+ * The ONE action-class table (SPEC §8 "Approvals"). Replaces maestro's local
935
+ * `GATED_CLASSES` and hq's free-form `Approval.actionClass` strings with a
936
+ * shared vocabulary both sides validate against.
937
+ *
938
+ * `GATED_ACTION_CLASSES` is the blast-radius gate: an action in one of these
939
+ * classes forces an approval BEFORE any execution rung runs, at every rung.
940
+ * @type {readonly string[]}
941
+ */
942
+ export const ACTION_CLASSES = Object.freeze([
943
+ "internal", // stays inside the workspace; reversible; no spend
944
+ "external", // leaves the org (email to a third party, a public post)
945
+ "irreversible", // cannot be undone by a subsequent method call
946
+ "financial", // moves money or commits spend
947
+ ]);
948
+
949
+ /** Action classes that force a human approval before execution. */
950
+ export const GATED_ACTION_CLASSES = Object.freeze(["external", "irreversible", "financial"]);
951
+
952
+ /**
953
+ * PlanDrift kinds — the reconciler's vocabulary for "the plan and the world
954
+ * disagree". Shared so hq can index/report on them without string drift.
955
+ * @type {readonly string[]}
956
+ */
957
+ export const DRIFT_KINDS = Object.freeze([
958
+ "uncovered_event", // a directed event matched no REACT obligation
959
+ "unreachable_capability", // an obligation cites a capability that failed its probe
960
+ "stale_sensor", // no sample within 2x the objective's cadence
961
+ "schedule_missing", // a SCHEDULE obligation has no launchd trigger on disk
962
+ "kpi_gap", // measured value is outside tolerance of target
963
+ "budget_breach", // an obligation exhausted its envelope
964
+ "orphan_work", // open work with no objective behind it
965
+ "mandate_stale", // the cached mandate aged past the staleness ladder
966
+ ]);
967
+
780
968
  /**
781
969
  * Is a method refused until the org server is `governance_ready` (decision_rights
782
970
  * populated AND signing live)? A partially deployed control plane is worse than
@@ -806,6 +994,12 @@ export const READS = Object.freeze({
806
994
  // phase 4 reads
807
995
  "contacts.list": "knowledge.read", // org contacts registry (ACL-filtered)
808
996
  "meetings.list": "knowledge.read", // org meetings registry (ACL-filtered)
997
+ // sub-agent registry (2026-08): the CHEAP roster poll. Returns the bare
998
+ // {rosterVersion, entries:[{slug, definitionId, version, contentHash}]} — no
999
+ // bodies — so a seat can diff its `.maestro/subagents.lock.json` on every
1000
+ // beat without paying for prose. `subagent.resolve` (METHODS) is the
1001
+ // expensive twin that carries bodies.
1002
+ "subagent.roster": "org.read",
809
1003
  });
810
1004
 
811
1005
  /** Error codes (the error contract; HTTP status hints are advisory). */
@@ -822,8 +1016,26 @@ export const ERROR_CODES = Object.freeze({
822
1016
  INTERNAL: { http: 500 },
823
1017
  });
824
1018
 
825
- /** Directive kinds the presence.beat response may carry (kill-switch + nudges). */
826
- export const DIRECTIVES = Object.freeze(["halt", "rotate_token", "policy_stale", "resync"]);
1019
+ /**
1020
+ * Directive kinds the presence.beat response may carry (kill-switch + nudges).
1021
+ *
1022
+ * Levels 1-3 of the four-level kill-switch (SPEC §8) live here and are
1023
+ * COOPERATIVE: they only work while the daemon is healthy enough to beat and
1024
+ * honest enough to obey. Level 4 — `credential.revoke` + API-key disable — is
1025
+ * the only control that works against a wedged or compromised laptop. Say that
1026
+ * out loud rather than implying the beat is a hard stop.
1027
+ */
1028
+ export const DIRECTIVES = Object.freeze([
1029
+ "halt", // stop entirely. hq ALSO closes the SSE lane (`event: bye`) so a
1030
+ // daemon that stopped beating stops receiving work to do.
1031
+ "rotate_token",
1032
+ "policy_stale",
1033
+ "resync",
1034
+ // --- mandate spine (2026-08) ---
1035
+ "pause_schedules", // stop INITIATING (Loop B + Loop C); keep answering when addressed
1036
+ "plan_stale", // the seat's mandate moved — refetch `mandate.get` and recompile
1037
+ "revoke_scope", // a scope was withdrawn; drop it locally and re-probe capabilities
1038
+ ]);
827
1039
 
828
1040
  // ---------------------------------------------------------------------------
829
1041
  // Helpers (shared, pure)
@@ -131,7 +131,10 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
131
131
  // from the UI-parity "file" pins/comments family), and directory (external
132
132
  // people + organisations; the Design half of that program rides the existing
133
133
  // `branding` family — the section is renamed, the wire name is not).
134
- assert.equal(FAMILIES.length, 47, "family count");
134
+ // The 2026-08 protocol-convergence pass adds 4: subagent (the org-scoped
135
+ // sub-agent registry), agent (the agent.wait control lane), mandate (the
136
+ // MANDATE spine) and preference (member-scoped preferences) → 51.
137
+ assert.equal(FAMILIES.length, 51, "family count");
135
138
  // 357 = the mesh-protocol + agent UI-parity + calling + branding + email
136
139
  // methods, plus the Live Integrations surface: integration.toolsetVersion +
137
140
  // integration.listAgentTools (SP1) and integration.invokeTool (SP5, execute a
@@ -160,8 +163,14 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
160
163
  // "is composing…" indicator an SDK-driven agent raises while it works —
161
164
  // sideEffecting:false, no chain event, expires client-side) → 431.
162
165
  // Bumped deliberately alongside
166
+ // The 2026-08 protocol-convergence pass closes the 29-method gap against hq's
167
+ // vendored table (the client rejected every one of them BEFORE the network,
168
+ // so mandate.* et al were unreachable from a daemon): messaging.search,
169
+ // escalation.ask/answer, memory.recall, preference.upsert/list (6 reverse-
170
+ // parity methods), the 9 subagent.* registry methods, agent.wait, the 11
171
+ // mandate.* spine methods, and board.requestReview/resolveReview → 460.
163
172
  // the descriptors; the checksum (read at runtime) is the primary drift guard.
164
- assert.equal(Object.keys(METHODS).length, 431, "method count");
173
+ assert.equal(Object.keys(METHODS).length, 460, "method count");
165
174
  });
166
175
 
167
176
  test("protocol SP3: messaging + calling families/methods/scopes", async () => {