@cohortapp/agent-sdk 2.3.2 → 2.4.1

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 (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +105 -17
  97. package/lib/setup/enroll-from-cohort.test.mjs +68 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -262,6 +262,36 @@
262
262
  "command": "true",
263
263
  "description": "lib/org/knowledge.mjs — recall()/remember() over the org's shared knowledge plane (backed by the Cohort server). Once the agent is enrolled (org-cohort feature: config/org.yaml enabled with base + token), the daemon's prompt-builder can consult recall() so replies are informed by what the fleet already knows, and finished work can remember() durable facts. Server-side ACL governs visibility. Inert + fail-open until enrollment; no standalone init."
264
264
  }
265
+ },
266
+ "skill-marketplace": {
267
+ "version": "1",
268
+ "since": "2.4.0",
269
+ "title": "Skill marketplace registration (rung 1 of the execution ladder)",
270
+ "init": {
271
+ "auto": true,
272
+ "command": "node scripts/setup/init-skill-marketplace.mjs",
273
+ "description": "scripts/setup/init-skill-marketplace.mjs — Claude Code only loads a skill when its plugin is declared by a .claude-plugin/marketplace.json AND listed in ~/.claude/settings.json enabledPlugins. Neither was ever written, so every skill markdown file under plugins/*/skills/ was an orphan: present on disk, dead at runtime, silently. This registers + enables each skill plugin through the SAME lib/capability/probe.probeSkillPlugin({repair}) the inventory section uses, so rung 1 of the execution ladder stops being a lie. Idempotent; an already-registered plugin is untouched. A plugin that cannot be repaired is reported loudly and its skills stay reachable:false, which the plan compiler then refuses to cite."
274
+ }
275
+ },
276
+ "capability-manifest": {
277
+ "version": "1",
278
+ "since": "2.4.0",
279
+ "title": "Capability inventory (probed, durable capability map across four planes)",
280
+ "init": {
281
+ "auto": true,
282
+ "command": "node scripts/setup/init-capability-manifest.mjs",
283
+ "description": "lib/capability/{inventory,probe}.mjs — exhaustively discover Claude Code built-ins, plugin skills (registering the marketplaces so the skill files are actually invocable), MCP servers (.mcp.json cross-read with config/mcp-servers.yaml), and the Cohort org tools + granted integration tools, PROBE each for reachability, and write config/capability-manifest.json (tracked). The law it establishes: an entry with reachable:false may not be cited by any obligation — the plan compiler enforces it. Idempotent; a no-op re-run yields the same checksum. Degradations are recorded in the manifest AND logged, never swallowed."
284
+ }
285
+ },
286
+ "mandate-spine": {
287
+ "version": "1",
288
+ "since": "2.4.0",
289
+ "title": "Mandate spine + plan compiler (derived, justified per-agent schedule)",
290
+ "init": {
291
+ "auto": false,
292
+ "command": "node scripts/setup/generate-plan.mjs --derive",
293
+ "description": "lib/mandate/{derive,cache}.mjs + lib/plan/{schema,compile,emit}.mjs — derive a PROPOSED objective tree from the agent's charter pillars, the org's strategy streams, its archetype KPI categories and config/priorities.yaml; bind each metric to a REACHABLE sensor capability; then deterministically compile obligations (SCHEDULE/OUTCOME/REACT) and emit config/plan.yaml + cadences.yaml + .cadence-registry.json (extended shape: allowedTools/guardModule/budgetCents/obligationKey) + .cadence-plists.tsv, which generate-plists.sh turns into real launchd jobs. MANUAL because it proposes objectives to hq: nothing is adopted by the agent, and PROPOSED objectives compile no obligations — until a human adopts, the schedule is exactly today's standard + archetype cadence set."
294
+ }
265
295
  }
266
296
  }
267
297
  }
package/lib/backlog.mjs CHANGED
@@ -341,11 +341,147 @@ export function parseQueueItems(content, sourceFile = "") {
341
341
  // the claim subsystem keys on a defined id and tolerates undefined.
342
342
  const id = idMatch?.[1]?.trim();
343
343
  if (id) item.id = id;
344
+
345
+ // --- goal-steward provenance (D3) ------------------------------------
346
+ // `lib/goals/loop.renderQueueItems` writes `advances` / `expected_delta` /
347
+ // `obligation_key` / `disposition` onto every self-directed item. Without
348
+ // extracting them here the daemon's sweep could not tell goal-linked work
349
+ // from seeded work, and the gap-weighted ranking below would be dead code.
350
+ // All four are OPTIONAL: a legacy or hand-written item simply lacks them
351
+ // and scores exactly as it does today.
352
+ const advancesMatch = cleanBlock.match(/^\s*-?\s*"?advances"?:\s*\[([^\]]*)\]/m);
353
+ if (advancesMatch) {
354
+ const list = advancesMatch[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
355
+ if (list.length) item.advances = list;
356
+ }
357
+ const deltaMatch = cleanBlock.match(/^\s*-?\s*"?expected_delta"?:\s*["']?(-?[0-9]*\.?[0-9]+)["']?\s*$/m);
358
+ if (deltaMatch) {
359
+ const d = Number(deltaMatch[1]);
360
+ if (Number.isFinite(d)) item.expected_delta = d;
361
+ }
362
+ const obligationMatch = cleanBlock.match(/^\s*-?\s*"?obligation_key"?:\s*["']?([^"'\n]+?)["']?\s*$/m);
363
+ const ok = obligationMatch?.[1]?.trim();
364
+ if (ok && ok !== "null") item.obligation_key = ok;
365
+ const dispMatch = cleanBlock.match(/^\s*-?\s*"?disposition"?:\s*["']?(SELF|COLLABORATE|HANDOFF|NEEDS_REVIEW)["']?/m);
366
+ if (dispMatch) item.disposition = dispMatch[1];
367
+
344
368
  out.push(item);
345
369
  }
346
370
  return out;
347
371
  }
348
372
 
373
+ // ---------------------------------------------------------------------------
374
+ // Gap-weighted backlog ranking (SPEC §6.3 step 6)
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /** Flat priority ordering — the behaviour the daemon's sweep has always had. */
378
+ export const PRIORITY_ORDER = Object.freeze({ critical: 0, high: 1, normal: 2, low: 3 });
379
+
380
+ /**
381
+ * Default ranking weights. `Charter.rewardWeights` overrides these when the
382
+ * charter carries them — which is the first thing in either repo that actually
383
+ * CONSUMES that field.
384
+ *
385
+ * priority how much the declared priority still counts
386
+ * gap how much "this objective is badly missed AND this item claims a
387
+ * big delta" counts
388
+ * age how much an item is penalised for sitting (prevents starvation
389
+ * of un-linked work by a permanently-lagging KPI)
390
+ */
391
+ export const DEFAULT_BACKLOG_WEIGHTS = Object.freeze({ priority: 1, gap: 1, age: 0.25 });
392
+
393
+ /**
394
+ * Resolve weights from a charter, falling back per-key. Non-finite / negative
395
+ * values are ignored rather than silently poisoning the ranking.
396
+ * @param {object} [charter] - { rewardWeights?: {priority?, gap?, age?} }
397
+ * @returns {{priority:number, gap:number, age:number}}
398
+ */
399
+ export function resolveBacklogWeights(charter) {
400
+ const rw = (charter && (charter.rewardWeights || charter.reward_weights)) || {};
401
+ const pick = (k) => {
402
+ const v = Number(rw[k]);
403
+ return Number.isFinite(v) && v >= 0 ? v : DEFAULT_BACKLOG_WEIGHTS[k];
404
+ };
405
+ return { priority: pick("priority"), gap: pick("gap"), age: pick("age") };
406
+ }
407
+
408
+ /**
409
+ * Score one queue item. HIGHER is more urgent (the sweep sorts descending).
410
+ *
411
+ * The flat 4-level priority ordering is preserved exactly as the base term, so
412
+ * an item with no `advances[]` ranks identically to how it ranks today — the
413
+ * gap term can only ever *promote* goal-linked work, never demote seeded work
414
+ * below its priority peers by more than the age penalty already would.
415
+ *
416
+ * @param {object} item - parseQueueItems() shape
417
+ * @param {object} [ctx]
418
+ * @param {Object<string, {normalizedGap?:number}>} [ctx.gapsByObjective] keyed by objective key
419
+ * @param {object} [ctx.weights] resolveBacklogWeights() output
420
+ * @param {number} [ctx.now] epoch ms (injected — never Date.now here)
421
+ * @returns {{score:number, base:number, gapTerm:number, agePenalty:number, objectiveKey:string|null}}
422
+ */
423
+ export function scoreQueueItem(item = {}, ctx = {}) {
424
+ const w = ctx.weights || DEFAULT_BACKLOG_WEIGHTS;
425
+ const gaps = ctx.gapsByObjective || {};
426
+
427
+ // Base: invert the priority ladder so higher == more urgent (critical → 3).
428
+ const rank = PRIORITY_ORDER[item.priority] ?? PRIORITY_ORDER.normal;
429
+ const base = (3 - rank) * w.priority;
430
+
431
+ // Gap term: normalizedGap × expectedDelta, over every objective the item
432
+ // claims to advance. An item that advances a badly-missed objective with a
433
+ // big claimed delta floats. Missing either factor contributes nothing —
434
+ // an unmeasured objective must not manufacture urgency.
435
+ let gapTerm = 0;
436
+ let objectiveKey = null;
437
+ for (const key of Array.isArray(item.advances) ? item.advances : []) {
438
+ const g = gaps[key];
439
+ // BOTH factors are clamped to [0,1] so the gap term is bounded by w_gap.
440
+ // Unclamped, a 100%-missed target with a big claimed delta would swamp the
441
+ // priority ladder entirely — a KPI gap must ESCALATE work, not let the
442
+ // agent redefine what "critical" means.
443
+ const ng = g && Number.isFinite(g.normalizedGap) ? Math.min(1, Math.max(0, g.normalizedGap)) : 0;
444
+ const delta = Number.isFinite(item.expected_delta) ? Math.abs(item.expected_delta) : 0;
445
+ const contribution = ng * (delta > 0 ? Math.min(1, delta) : 0);
446
+ if (contribution > gapTerm) { gapTerm = contribution; objectiveKey = key; }
447
+ if (objectiveKey === null) objectiveKey = key;
448
+ }
449
+ gapTerm *= w.gap;
450
+
451
+ // Age penalty: days since `created`, damped. Bounded so a stale item cannot
452
+ // dominate; it exists to stop a permanently-lagging KPI starving everything.
453
+ let agePenalty = 0;
454
+ if (item.created && Number.isFinite(ctx.now)) {
455
+ const t = Date.parse(item.created);
456
+ if (Number.isFinite(t)) {
457
+ const days = Math.max(0, (ctx.now - t) / 86400000);
458
+ agePenalty = Math.min(2, days / 14) * w.age;
459
+ }
460
+ }
461
+
462
+ return { score: base + gapTerm - agePenalty, base, gapTerm, agePenalty, objectiveKey };
463
+ }
464
+
465
+ /**
466
+ * Rank a backlog. Stable: ties fall back to the flat priority ordering and then
467
+ * to the original array order, so a run with no measurements produces byte-for-
468
+ * byte today's dispatch order.
469
+ *
470
+ * @param {object[]} items @param {object} [ctx] see scoreQueueItem
471
+ * @returns {object[]} a new array, most urgent first, each item carrying `_score`
472
+ */
473
+ export function rankBacklog(items, ctx = {}) {
474
+ const scored = (items || []).map((item, i) => ({ item, i, s: scoreQueueItem(item, ctx) }));
475
+ scored.sort((a, b) => {
476
+ if (b.s.score !== a.s.score) return b.s.score - a.s.score;
477
+ const pa = PRIORITY_ORDER[a.item.priority] ?? PRIORITY_ORDER.normal;
478
+ const pb = PRIORITY_ORDER[b.item.priority] ?? PRIORITY_ORDER.normal;
479
+ if (pa !== pb) return pa - pb;
480
+ return a.i - b.i;
481
+ });
482
+ return scored.map(({ item, s }) => ({ ...item, _score: s.score, _scoreParts: s }));
483
+ }
484
+
349
485
  /** Count immediately-actionable (d30) tasks in a WBS. @param {object} wbs @returns {number} */
350
486
  export function countReadyTasks(wbs) {
351
487
  if (!wbs || !Array.isArray(wbs.streams)) return 0;
package/lib/cadences.mjs CHANGED
@@ -76,6 +76,20 @@ export const STANDARD_CADENCES = [
76
76
  // the guard tails the org event chain for branding version.saved and escalates
77
77
  // only on a real save. 15min keeps the reaction prompt without polling hard.
78
78
  { id: "brand-steward", scope: "standard", mode: "guarded", interval: 900, prompt: "schedules/triggers/brand-steward.md" },
79
+ // goal-steward (D3, SPEC §6.3): the self-directed loop. Measures every adopted
80
+ // objective whose window is open, computes the gap, and turns a real gap into
81
+ // real backlog — classified SELF/COLLABORATE/HANDOFF/NEEDS_REVIEW and routed
82
+ // through the existing collaboration primitives. GUARDED: the guard runs the
83
+ // whole measure→decide→admit pass INLINE (it is pure local computation plus
84
+ // sensor reads) and escalates to a planning sub-session only when a gap
85
+ // actually needs candidates it cannot generate deterministically. An agent
86
+ // with no adopted mandate costs one cache read a day and never spawns.
87
+ //
88
+ // Defaults to OBSERVE-ONLY (config/plan.yaml: enforcement) — it records what
89
+ // it would create and creates nothing until a human flips enforcement to
90
+ // "active". A fleet of agents that starts by writing to a board on day one is
91
+ // how you lose the fleet.
92
+ { id: "goal-steward", scope: "standard", mode: "guarded", calendar: { hour: 7, minute: 15 }, prompt: "schedules/triggers/goal-steward.md" },
79
93
  ];
80
94
 
81
95
  /** Altitude-specific cadences (reuse existing triggers where one exists). */
@@ -168,8 +182,42 @@ function normalize(entry, scope) {
168
182
  }
169
183
 
170
184
  /**
171
- * Resolve the full cadence set for a { function, altitude }.
172
- * @param {{function:string, altitude:string}} sel
185
+ * Project a compiled SCHEDULE obligation onto a cadence entry. The obligation
186
+ * already carries a solved slot and a computed tool scope, so nothing here
187
+ * re-derives a schedule — `normalize()`'s `scheduleFor()` stagger must NOT be
188
+ * allowed to overwrite a slot the plan compiler solved against the principal's
189
+ * calendar.
190
+ * @param {object} ob @returns {object|null}
191
+ */
192
+ function fromObligation(ob) {
193
+ if (!ob || ob.kind !== "SCHEDULE" || !ob.cadence_id) return null;
194
+ if (ob.status && ob.status !== "active") return null;
195
+ const entry = { id: ob.cadence_id, mode: ob.mode || "escalate", prompt: ob.prompt || `schedules/triggers/${ob.cadence_id}.md` };
196
+ if (ob.schedule && Number.isFinite(ob.schedule.interval)) entry.interval = ob.schedule.interval;
197
+ else if (ob.schedule && ob.schedule.calendar) entry.calendar = { ...ob.schedule.calendar };
198
+ if (ob.purpose) entry.purpose = ob.purpose;
199
+ if (ob.cadence) entry.cadence = ob.cadence;
200
+ return entry;
201
+ }
202
+
203
+ /**
204
+ * Resolve the full cadence set for a { function, altitude }, optionally grafted
205
+ * with the agent's own compiled PLAN.
206
+ *
207
+ * Three legs, applied in order, first writer of an id wins:
208
+ *
209
+ * 1. STANDARD_CADENCES — the floor. Every agent, always.
210
+ * 2. function + altitude — the archetype's recurring workflows.
211
+ * 3. `obligations` — the agent's compiled plan (SPEC §5.6).
212
+ *
213
+ * Legs 1 and 2 are retained DELIBERATELY as the fallback: proposed-but-unadopted
214
+ * objectives compile no obligations, so an agent whose human has not yet adopted
215
+ * anything keeps running exactly today's archetype cadence set rather than
216
+ * falling to an empty schedule. The obligations leg only ever ADDS mandate-derived
217
+ * cadences on top — it can neither delete nor re-slot a standard one, which is
218
+ * what keeps existing launchd plists from churning.
219
+ *
220
+ * @param {{function:string, altitude:string, obligations?:object[]}} sel
173
221
  * @returns {Promise<object[]>} ordered, de-duplicated cadence entries
174
222
  */
175
223
  export async function resolveCadences(sel = {}) {
@@ -196,6 +244,19 @@ export async function resolveCadences(sel = {}) {
196
244
 
197
245
  for (const c of ALTITUDE_CADENCES[altitude] || []) add(c, "altitude");
198
246
 
247
+ // Plan leg: mandate-derived cadences (measure/review obligations). Carries the
248
+ // obligation key through so cadence-handlers can meter the tick's budget and
249
+ // lib/plan/explain.mjs can walk the tick back to the objective that justifies it.
250
+ for (const ob of Array.isArray(sel.obligations) ? sel.obligations : []) {
251
+ const entry = fromObligation(ob);
252
+ if (!entry || seen.has(entry.id)) continue;
253
+ seen.add(entry.id);
254
+ const norm = normalize(entry, ob.scope === "mandate" ? "mandate" : (ob.scope || "mandate"));
255
+ if (ob.key) norm.obligationKey = ob.key;
256
+ if (ob.objective_id) norm.objectiveId = ob.objective_id;
257
+ out.push(norm);
258
+ }
259
+
199
260
  return out;
200
261
  }
201
262
 
@@ -123,3 +123,108 @@ test("every STANDARD_CADENCES entry that declares a prompt has the file on disk"
123
123
  assert.ok(existsSync(join(root, c.prompt)), `cadence ${c.id} prompt missing: ${c.prompt}`);
124
124
  }
125
125
  });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // The PLAN leg (SPEC §4/§5.6): mandate-derived obligations graft onto the
129
+ // standard + archetype floor. This is what makes the schedule per-agent and
130
+ // justified rather than a static default list.
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /** A compiled SCHEDULE obligation, shaped as lib/plan/compile.mjs emits it. */
134
+ function measureObligation(over = {}) {
135
+ return {
136
+ key: "schedule.measure.pipeline-coverage",
137
+ kind: "SCHEDULE",
138
+ cadence_id: "measure-pipeline-coverage",
139
+ scope: "mandate",
140
+ mode: "guarded",
141
+ prompt: "schedules/triggers/measure-pipeline-coverage.md",
142
+ schedule: { calendar: { weekday: 2, hour: 14, minute: 30 } },
143
+ cadence: "weekly",
144
+ objective_id: "obj_7Kx",
145
+ status: "active",
146
+ ...over,
147
+ };
148
+ }
149
+
150
+ test("plan leg: an adopted objective's measure obligation becomes a real cadence", async () => {
151
+ const cads = await resolveCadences({
152
+ function: "commercial-leader",
153
+ altitude: "vp",
154
+ obligations: [measureObligation()],
155
+ });
156
+ const c = cads.find((x) => x.id === "measure-pipeline-coverage");
157
+ assert.ok(c, "mandate-derived cadence resolves");
158
+ assert.equal(c.scope, "mandate");
159
+ assert.equal(c.mode, "guarded");
160
+ // The compiler solved this slot against the principal's calendar — the
161
+ // resolver's own scheduleFor() stagger must NOT overwrite it.
162
+ assert.deepEqual(c.calendar, { weekday: 2, hour: 14, minute: 30 });
163
+ // Provenance rides along so a tick can be walked back to its objective.
164
+ assert.equal(c.obligationKey, "schedule.measure.pipeline-coverage");
165
+ assert.equal(c.objectiveId, "obj_7Kx");
166
+ });
167
+
168
+ test("plan leg: the standard + archetype floor is RETAINED, never replaced", async () => {
169
+ // An agent whose human has adopted nothing must keep running today's cadences.
170
+ const withPlan = await resolveCadences({ function: "commercial-leader", altitude: "vp", obligations: [measureObligation()] });
171
+ const withoutPlan = await resolveCadences({ function: "commercial-leader", altitude: "vp" });
172
+ const baseIds = withoutPlan.map((c) => c.id);
173
+ const planIds = withPlan.map((c) => c.id);
174
+ for (const id of baseIds) assert.ok(planIds.includes(id), `${id} survives the plan leg`);
175
+ assert.equal(planIds.length, baseIds.length + 1, "the plan leg only ADDS");
176
+ });
177
+
178
+ test("plan leg: an empty/absent obligation list is exactly today's behaviour", async () => {
179
+ const none = await resolveCadences({ function: "technical-leader", altitude: "c-suite" });
180
+ const empty = await resolveCadences({ function: "technical-leader", altitude: "c-suite", obligations: [] });
181
+ assert.deepEqual(empty, none);
182
+ });
183
+
184
+ test("plan leg: a plan obligation cannot re-slot or override a standard cadence", async () => {
185
+ // First writer wins. A mandate obligation colliding with `inbox-processor`
186
+ // must not move it — that is what keeps existing launchd plists from churning.
187
+ const base = await resolveCadences({ function: "technical-leader", altitude: "c-suite" });
188
+ const original = base.find((c) => c.id === "inbox-processor");
189
+ const cads = await resolveCadences({
190
+ function: "technical-leader",
191
+ altitude: "c-suite",
192
+ obligations: [measureObligation({ cadence_id: "inbox-processor", schedule: { interval: 99999 }, key: "schedule.hijack" })],
193
+ });
194
+ const after = cads.find((c) => c.id === "inbox-processor");
195
+ assert.deepEqual(after, original, "the standard cadence is untouched");
196
+ assert.equal(cads.filter((c) => c.id === "inbox-processor").length, 1, "no duplicate entry");
197
+ });
198
+
199
+ test("plan leg: non-active / non-SCHEDULE obligations emit no cadence", async () => {
200
+ const cads = await resolveCadences({
201
+ function: "technical-leader",
202
+ altitude: "c-suite",
203
+ obligations: [
204
+ measureObligation({ cadence_id: "suspended-job", status: "suspended", key: "schedule.suspended" }),
205
+ { key: "outcome.pipeline-coverage", kind: "OUTCOME", cadence_id: "outcome-job", status: "active" },
206
+ { key: "react.email.received", kind: "REACT", status: "active", trigger: { topic: "email" } },
207
+ ],
208
+ });
209
+ const ids = cads.map((c) => c.id);
210
+ assert.ok(!ids.includes("suspended-job"), "a suspended obligation is not scheduled");
211
+ assert.ok(!ids.includes("outcome-job"), "an OUTCOME is measured, not launchd-scheduled");
212
+ assert.ok(!ids.includes("react.email.received"), "a REACT is event-driven, not scheduled");
213
+ });
214
+
215
+ test("plan leg: an interval obligation carries its interval through", async () => {
216
+ const cads = await resolveCadences({
217
+ function: "technical-leader",
218
+ altitude: "c-suite",
219
+ obligations: [measureObligation({ cadence_id: "fast-sensor", schedule: { interval: 300 }, key: "schedule.fast" })],
220
+ });
221
+ const c = cads.find((x) => x.id === "fast-sensor");
222
+ assert.equal(c.interval, 300);
223
+ assert.equal(c.calendar, undefined);
224
+ });
225
+
226
+ test("plan leg: founder altitude resolves its own cadences (was unreachable from the wizard)", async () => {
227
+ const ids = (await resolveCadences({ function: "finance-leader", altitude: "founder" })).map((c) => c.id);
228
+ assert.ok(ids.includes("weekly-runway-review"), "founder gets the runway review");
229
+ assert.ok(ids.includes("monthly-investor-update"), "founder gets the investor update");
230
+ });