@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
@@ -40,6 +40,7 @@
40
40
 
41
41
  import { createHash } from "node:crypto";
42
42
  import { canonical } from "../mandate/cache.mjs";
43
+ import { assertMandateContract } from "../mandate/contract.mjs";
43
44
 
44
45
  /** Bump when emission rules change — it is part of `inputsHash`. */
45
46
  export const COMPILER_VERSION = 1;
@@ -211,7 +212,13 @@ export function busyWindows(events = []) {
211
212
  * Compile the plan.
212
213
  *
213
214
  * @param {object} input
214
- * @param {object} input.mandate the cached mandate BODY ({objectives, budgetCentsPerPeriod, reactsTo, collaborators})
215
+ * @param {import("../mandate/contract.mjs").MandateBody} input.mandate
216
+ * the cached mandate BODY — see `lib/mandate/contract.mjs`, which is the
217
+ * canonical shape and is mirrored in hq at `methods/mandate/_contract.ts`
218
+ * @param {(level:string,msg:string)=>void} [input.log]
219
+ * where contract violations and unmetered obligations are announced. Pass
220
+ * it: a silent degradation here is the exact bug this joint closed.
221
+ * @param {number} [input.seatBudgetCents] overrides `mandate.seatBudgetCents`
215
222
  * @param {number} [input.mandateVersion]
216
223
  * @param {object} input.manifest the capability manifest
217
224
  * @param {object} [input.profile] resolved archetype profile (for the hash + altitude)
@@ -220,7 +227,7 @@ export function busyWindows(events = []) {
220
227
  * @param {object[]} [input.archetypeCadences] resolved function+altitude cadences (scope !== "standard")
221
228
  * @param {object[]} [input.calendarEvents] [{weekday,hour}] busy windows
222
229
  * @param {number} [input.concurrency=2]
223
- * @returns {{obligations:object[], inputsHash:string, obligationsHash:string, compilerVersion:number, mandateVersion:number, drift:object[], counts:object}}
230
+ * @returns {{obligations:object[], inputsHash:string, obligationsHash:string, compilerVersion:number, mandateVersion:number, drift:object[], counts:object, warnings:string[], contract:object}}
224
231
  */
225
232
  export function compilePlan(input = {}) {
226
233
  const mandate = input.mandate && typeof input.mandate === "object" ? input.mandate : {};
@@ -231,8 +238,52 @@ export function compilePlan(input = {}) {
231
238
  const drift = [];
232
239
  const obligations = [];
233
240
 
234
- const defaultBudget = Number.isFinite(mandate.budgetCentsPerPeriod) ? mandate.budgetCentsPerPeriod : 500;
235
- const seatEnvelope = Number.isFinite(input.seatBudgetCents) ? input.seatBudgetCents : defaultBudget * 20;
241
+ // ── THE MONEY, AND WHERE IT COMES FROM ──────────────────────────────────
242
+ // Every figure below is either a number hq published or `null`. There is no
243
+ // third option and there is no default. The previous `: 500` / `* 20` pair is
244
+ // the bug this joint closed: hq never emitted `budgetCentsPerPeriod` at all,
245
+ // `Number.isFinite(undefined)` was false, and the entire fleet compiled every
246
+ // obligation at a 500c allowance against a 10000c envelope that no column
247
+ // anywhere had ever produced. It never errored, so it ran for as long as it
248
+ // ran. A fabricated number is not a safe default — it is a silent one.
249
+ const warnings = [];
250
+ const log = typeof input.log === "function"
251
+ ? input.log
252
+ // FAIL-OPEN, NEVER FAIL-SILENT. A caller that passes no logger still gets the
253
+ // degradation on stderr; it does not get to make it disappear.
254
+ : (level, msg) => { (level === "error" ? console.error : console.warn)(msg); };
255
+ const note = (level, msg) => { warnings.push(msg); log(level, msg); };
256
+
257
+ const contract = assertMandateContract(mandate, (level, msg) => { warnings.push(msg); log(level, msg); }, "compilePlan");
258
+
259
+ // A body missing a required key is a PRE-CONTRACT body. It is announced as an
260
+ // ERROR and surfaced on `plan.contract` — but note what is NOT done here: the
261
+ // numbers it DOES carry are still used. An explicit figure is a fact even in a
262
+ // malformed envelope, and replacing it with null would be the same sin in the
263
+ // other direction. The only thing forbidden is INVENTING one.
264
+ if (!contract.ok) {
265
+ note("error",
266
+ "[compilePlan] the mandate body failed its contract — treat its budgets as " +
267
+ "unverified. Keys it does not carry compile UNMETERED (null); no default is " +
268
+ "substituted for them. See lib/mandate/contract.mjs.");
269
+ }
270
+
271
+ /** Default allowance for an obligation with no objective behind it. Null = unmetered. */
272
+ const defaultBudget = Number.isFinite(mandate.budgetCentsPerPeriod)
273
+ ? mandate.budgetCentsPerPeriod
274
+ : null;
275
+ /** The seat's whole envelope. Null = no ceiling is knowable, so none is enforced. */
276
+ const seatEnvelope = Number.isFinite(input.seatBudgetCents)
277
+ ? input.seatBudgetCents
278
+ : Number.isFinite(mandate.seatBudgetCents)
279
+ ? mandate.seatBudgetCents
280
+ : null;
281
+ if (seatEnvelope == null) {
282
+ note("warn",
283
+ "[compilePlan] no seat envelope (mandate.seatBudgetCents is null and no override " +
284
+ "was passed) — the budget ceiling is NOT enforced and no obligation can be " +
285
+ "suspended for breaching it.");
286
+ }
236
287
 
237
288
  /** Keep only reachable capabilities; record the rest as drift. */
238
289
  const resolveUses = (wanted, obKey) => {
@@ -324,10 +375,25 @@ export function compilePlan(input = {}) {
324
375
  continue; // an OUTCOME without a reachable method sensor is not emitted
325
376
  }
326
377
 
327
- const budget = Number.isFinite(obj.budgetCentsPerPeriod) ? obj.budgetCentsPerPeriod : defaultBudget;
328
- const withinEnvelope = spent + budget <= seatEnvelope;
329
- if (!withinEnvelope) drift.push({ kind: "budget_breach", key: `outcome.${okey}`, detail: { seatEnvelope, spent, requested: budget } });
330
- else spent += budget;
378
+ // This node's apportioned share of the seat envelope (hq apportions by
379
+ // Objective.weight, itself seeded from Charter.rewardWeights). Null when hq
380
+ // had nothing to apportion — which is a fact about funding, not a licence to
381
+ // pick a number.
382
+ const budget = Number.isFinite(obj.budgetCentsPerPeriod)
383
+ ? obj.budgetCentsPerPeriod
384
+ : defaultBudget;
385
+ if (budget == null) {
386
+ note("warn",
387
+ `[compilePlan] objective "${okey}" compiles UNMETERED: neither ` +
388
+ "objectives[].budgetCentsPerPeriod nor the body default carried a figure.");
389
+ }
390
+ // An unmetered obligation consumes an UNKNOWN amount, not zero — but a known
391
+ // ceiling cannot be spent down by an unknown, so it is admitted and named
392
+ // rather than silently charged 0 or silently charged a default.
393
+ const cost = Number.isFinite(budget) ? budget : 0;
394
+ const withinEnvelope = seatEnvelope == null ? true : spent + cost <= seatEnvelope;
395
+ if (!withinEnvelope) drift.push({ kind: "budget_breach", key: `outcome.${okey}`, detail: { seatEnvelope, spent, requested: cost } });
396
+ else spent += cost;
331
397
  const status = withinEnvelope ? "active" : "suspended";
332
398
 
333
399
  const measureKey = `schedule.measure.${okey}`;
@@ -464,6 +530,17 @@ export function compilePlan(input = {}) {
464
530
  mandateVersion: Number.isFinite(input.mandateVersion) ? input.mandateVersion : 0,
465
531
  drift,
466
532
  counts,
533
+ // Contract violations + every unmetered obligation. SEPARATE from `drift`
534
+ // because drift is a closed enum the org accepts (protocol DRIFT_KINDS) and
535
+ // "hq published no budget" is not one of its members — inventing a kind to
536
+ // make it fit would be rejected server-side and swallowed here.
537
+ warnings,
538
+ contract: {
539
+ ok: contract.ok,
540
+ version: contract.contractVersion,
541
+ missing: contract.missing,
542
+ unmetered: contract.unmetered,
543
+ },
467
544
  };
468
545
  }
469
546
 
@@ -193,6 +193,88 @@ test("budget envelope: objectives past the envelope are SUSPENDED with a breach
193
193
  assert.equal(p.obligations.filter((o) => o.kind === "OUTCOME").length, 3, "all three still exist — suspended, not dropped");
194
194
  });
195
195
 
196
+ // ---------------------------------------------------------------------------
197
+ // THE MANDATE BODY JOINT — no invented money, ever
198
+ // ---------------------------------------------------------------------------
199
+
200
+ test("THE BUG: a mandate with no budget compiles UNMETERED, never 500", () => {
201
+ // This is the exact failure. hq sent no `budgetCentsPerPeriod`, the compiler
202
+ // read `undefined`, `Number.isFinite(undefined)` was false, and every single
203
+ // obligation in the fleet silently took 500c against a 10000c envelope that
204
+ // no column anywhere produced. Nothing errored, so nothing was ever noticed.
205
+ const logs = [];
206
+ const p = compilePlan({
207
+ mandate: { objectives: [objective()] },
208
+ manifest: manifest(),
209
+ standardCadences: STANDARD,
210
+ log: (level, msg) => logs.push([level, msg]),
211
+ });
212
+
213
+ for (const ob of p.obligations) {
214
+ assert.equal(ob.budget_cents_per_period, null, `${ob.key} must be unmetered, not a made-up number`);
215
+ assert.notEqual(ob.budget_cents_per_period, 500, `${ob.key} must never be 500`);
216
+ }
217
+ // Unmetered means NO ceiling is invented either — nothing is suspended for
218
+ // breaching a 10000c envelope that never existed.
219
+ assert.equal(p.obligations.filter((o) => o.status === "suspended").length, 0);
220
+ assert.ok(!p.drift.some((d) => d.kind === "budget_breach"));
221
+ // FAIL-OPEN IS FINE; SILENT IS NOT.
222
+ assert.ok(logs.length > 0, "the degradation is announced");
223
+ assert.ok(logs.some(([l]) => l === "error"), "a pre-contract body is an error-level event");
224
+ assert.equal(p.contract.ok, false);
225
+ assert.equal(p.contract.unmetered, true);
226
+ assert.ok(p.warnings.length > 0);
227
+ });
228
+
229
+ test("a funded hq body puts the MANDATE's number on each obligation", () => {
230
+ const objs = [
231
+ objective({ key: "a", id: "a", weight: 3, budgetCentsPerPeriod: 18_750 }),
232
+ objective({ key: "b", id: "b", weight: 1, budgetCentsPerPeriod: 6_250 }),
233
+ ];
234
+ const p = compilePlan({
235
+ mandate: {
236
+ contractVersion: 1, memberId: "mem_self", objectives: objs, proposed: [],
237
+ budgetCentsPerPeriod: null, seatBudgetCents: 25_000, budgetPeriod: "monthly",
238
+ budgetSource: "employee.payBasis.meteredBudget", collaborators: [], reactsTo: [],
239
+ degradations: [], generatedAt: "2026-08-11T00:00:00.000Z",
240
+ },
241
+ manifest: manifest(), standardCadences: [], log: () => {},
242
+ });
243
+ assert.equal(p.contract.ok, true);
244
+ assert.equal(p.contract.unmetered, false);
245
+ const byKey = Object.fromEntries(p.obligations.filter((o) => o.kind === "OUTCOME").map((o) => [o.key, o.budget_cents_per_period]));
246
+ assert.equal(byKey["outcome.a"], 18_750);
247
+ assert.equal(byKey["outcome.b"], 6_250);
248
+ // Obligations with no objective behind them stay unmetered — hq holds no
249
+ // per-obligation column, and `budgetCentsPerPeriod:null` says exactly that.
250
+ for (const ob of p.obligations.filter((o) => o.kind === "REACT")) {
251
+ assert.equal(ob.budget_cents_per_period, null);
252
+ }
253
+ });
254
+
255
+ test("budgets are NOT part of obligationsHash — funding a seat must not churn every plist", () => {
256
+ const base = { objectives: [objective({ key: "a", id: "a" })] };
257
+ const unfunded = compilePlan({ mandate: base, manifest: manifest(), standardCadences: STANDARD, log: () => {} });
258
+ const funded = compilePlan({
259
+ mandate: { ...base, objectives: [objective({ key: "a", id: "a", budgetCentsPerPeriod: 9_000 })], seatBudgetCents: 9_000 },
260
+ manifest: manifest(), standardCadences: STANDARD, log: () => {},
261
+ });
262
+ assert.equal(funded.obligationsHash, unfunded.obligationsHash, "funding changes money, not identity");
263
+ });
264
+
265
+ test("an explicit figure in a MALFORMED body is still used — only invention is forbidden", () => {
266
+ // The contract failing does not license throwing away a number the caller
267
+ // actually stated. It licenses refusing to make one up.
268
+ const logs = [];
269
+ const p = compilePlan({
270
+ mandate: { objectives: [objective({ key: "a", id: "a" })], budgetCentsPerPeriod: 700 },
271
+ manifest: manifest(), standardCadences: [], log: (l, m) => logs.push([l, m]),
272
+ });
273
+ assert.equal(p.contract.ok, false, "still a pre-contract body");
274
+ assert.ok(logs.some(([l]) => l === "error"), "and still loud about it");
275
+ assert.equal(p.obligations.find((o) => o.kind === "OUTCOME").budget_cents_per_period, 700);
276
+ });
277
+
196
278
  test("validatePlan is FAIL-CLOSED on an unreachable citation and on an llm-sourced OUTCOME", () => {
197
279
  const reachable = new Set(["board_ready"]);
198
280
  const bad = {
@@ -84,7 +84,12 @@ test(".cadence-registry.json carries the EXTENDED shape session-permissions can
84
84
  assert.equal(m.mode, "guarded");
85
85
  assert.equal(m.guardModule, "lib/goals/loop.mjs");
86
86
  assert.equal(m.obligationKey, "schedule.measure.pipeline-coverage");
87
- assert.ok(Number.isFinite(m.budgetCents));
87
+ // PRESENT, and null when the mandate funded nothing — `planWith` builds from a
88
+ // mandate with no budget, so null is the honest reading. The key must exist
89
+ // either way: `getCadenceDef` branches on Number.isFinite, and an ABSENT key
90
+ // and a null one both mean "no cap" while only one of them can be logged.
91
+ assert.ok(Object.prototype.hasOwnProperty.call(m, "budgetCents"));
92
+ assert.equal(m.budgetCents, null);
88
93
  assert.ok(Array.isArray(m.allowedTools) && m.allowedTools.includes("crm_list_deals"));
89
94
  // Back-compat: {mode, prompt} still present for every entry.
90
95
  for (const [id, def] of Object.entries(reg)) {
@@ -48,6 +48,7 @@ import { readAgentJson } from "../sot.mjs";
48
48
  import { runScriptAbs } from "../run-generator.mjs";
49
49
  import { readManifest } from "../../capability/inventory.mjs";
50
50
  import { deriveMandate, toMandateBody } from "../../mandate/derive.mjs";
51
+ import { MANDATE_CONTRACT_VERSION } from "../../mandate/contract.mjs";
51
52
  import { readCache, writeCache, stalenessTier, staleSeconds, allObjectives, adoptedObjectives, CACHE_REL } from "../../mandate/cache.mjs";
52
53
  import { compilePlan } from "../../plan/compile.mjs";
53
54
  import { emitPlan, readLock, PLAN_REL, LOCK_REL } from "../../plan/emit.mjs";
@@ -171,7 +172,10 @@ export async function resolveMandate(o = {}, deps = {}) {
171
172
  const { refreshMandate } = await import("../../mandate/refresh.mjs");
172
173
  const res = await refreshMandate(agentRoot, {
173
174
  now: deps.now ? () => deps.now() : undefined,
174
- log: (level, m) => log(m),
175
+ // Keep the LEVEL. `(level, m) => log(m)` threw it away, so a contract
176
+ // violation ("this snapshot has no budgets") rendered identically to a
177
+ // routine dim note and read as noise in a wall of setup output.
178
+ log: (level, m) => log(level === "error" ? `ERROR ${m}` : m),
175
179
  memberId: o.memberId || null,
176
180
  callImpl: typeof call === "function" ? call : undefined,
177
181
  offline: typeof call !== "function",
@@ -222,6 +226,13 @@ export async function compileAndEmit(o = {}, deps = {}) {
222
226
  const { agentRoot, maestroRoot } = o;
223
227
  const inputs = o.inputs || (await loadCompilerInputs({ agentRoot, maestroRoot }, deps));
224
228
  const record = o.record || readCache(agentRoot, deps) || { body: {}, version: 0 };
229
+ // The compiler announces contract violations and unmetered obligations through
230
+ // this. Omit it and a plan built on a budget-less mandate looks identical to
231
+ // one built on a funded mandate — which is precisely how the 500c fiction
232
+ // survived for as long as it did.
233
+ const compileLog = deps.logImpl
234
+ ? (level, msg) => deps.logImpl(`[${level}] ${msg}`)
235
+ : (level, msg) => io.note(msg, { dim: level !== "error" });
225
236
 
226
237
  const plan = compilePlan({
227
238
  mandate: record.body || {},
@@ -233,6 +244,7 @@ export async function compileAndEmit(o = {}, deps = {}) {
233
244
  archetypeCadences: inputs.archetypeCadences,
234
245
  calendarEvents: o.calendarEvents || [],
235
246
  concurrency: o.concurrency,
247
+ log: compileLog,
236
248
  });
237
249
 
238
250
  const emit = await emitPlan({
@@ -321,6 +333,14 @@ export default {
321
333
  }
322
334
 
323
335
  io.note(`[mandate] compiled ${plan.counts.total} obligations (${plan.counts.SCHEDULE} SCHEDULE · ${plan.counts.OUTCOME} OUTCOME · ${plan.counts.REACT} REACT) from ${plan.counts.adoptedObjectives} adopted / ${plan.counts.proposedObjectives} proposed objectives`);
336
+ // The funding line, printed on EVERY apply. Before the contract there was no
337
+ // such line because there was nothing true to say: the compiler had already
338
+ // replaced the unknown with 500 before anyone could report it.
339
+ io.note(
340
+ plan.contract.unmetered
341
+ ? "[mandate] budget: UNMETERED — hq published no funding, so every obligation carries budget_cents_per_period:null and no ceiling is enforced"
342
+ : `[mandate] budget: metered from hq (contract v${plan.contract.version}) — ${plan.obligations.filter((ob) => Number.isFinite(ob.budget_cents_per_period)).length}/${plan.obligations.length} obligations carry a funded allowance`
343
+ );
324
344
  for (const d of plan.drift) log(`[mandate] drift ${d.kind}${d.key ? ` (${d.key})` : ""}: ${JSON.stringify(d.detail)}`);
325
345
 
326
346
  // launchd plists — generate-plists.sh reads config/.cadence-plists.tsv
@@ -361,10 +381,32 @@ export default {
361
381
  orgContext: inputs.orgContext,
362
382
  standardCadences: inputs.standardCadences,
363
383
  archetypeCadences: inputs.archetypeCadences,
384
+ // Silent here means the recompile can read a pre-contract mandate, compile
385
+ // every obligation unmetered, still match the lock hash (budgets are not
386
+ // part of `identityOf`) and report a clean bill of health. Route it.
387
+ log: (level, msg) => { if (level === "error") checks.push({ name: msg, ok: false, remedy: "the cached mandate body predates contract v1 — run `maestro setup --only mandate` to refetch from hq" }); },
364
388
  });
365
389
  const hashOk = plan.obligationsHash === lock.obligationsHash;
366
390
  checks.push({ name: "obligationsHash matches compile.lock", ok: hashOk, remedy: hashOk ? undefined : "inputs changed since the last compile — re-run `maestro setup --only mandate`" });
367
391
 
392
+ // THE FUNDING CHECK. `ok:true` either way — an unfunded seat is a legitimate
393
+ // state and must not fail setup — but it can never again be invisible, which
394
+ // is the only property that mattered here.
395
+ checks.push({
396
+ name: plan.contract.unmetered
397
+ ? "mandate budget: UNMETERED (hq published no funding for this seat)"
398
+ : `mandate budget: metered from hq (contract v${plan.contract.version})`,
399
+ ok: true,
400
+ remedy: plan.contract.unmetered
401
+ ? "obligations carry budget_cents_per_period:null. hq's reasons are in the mandate body's degradations[]; fund the seat via its Employee payBasis.meteredBudget in Cohort."
402
+ : undefined,
403
+ });
404
+ checks.push({
405
+ name: `mandate body satisfies contract v${MANDATE_CONTRACT_VERSION}`,
406
+ ok: plan.contract.ok,
407
+ remedy: plan.contract.ok ? undefined : `missing required key(s): ${plan.contract.missing.join(", ")} — refetch the mandate from hq`,
408
+ });
409
+
368
410
  const v = validatePlan(plan, { manifest: manifest || { entries: [] } });
369
411
  checks.push({ name: "plan validates (no obligation cites an unreachable capability)", ok: v.ok, remedy: v.ok ? undefined : v.errors.slice(0, 3).join("; ") });
370
412
 
@@ -284,10 +284,22 @@ export function stringifyFrontmatter(fm) {
284
284
  return lines.length ? `${lines.join("\n")}\n` : "";
285
285
  }
286
286
 
287
- /** Re-assemble a full agent.md from a frontmatter object + body. */
287
+ /**
288
+ * Re-assemble a full agent.md from a frontmatter object + body.
289
+ *
290
+ * The body is emitted VERBATIM. It used to have a single leading `\n` stripped,
291
+ * which looked like a convenience but broke the parse → stringify round trip:
292
+ * {@link splitFrontmatter} slices the body from just after the closing fence's
293
+ * own newline, so a file written in the shipped shape (`---\n\nYou are…`) parses
294
+ * to a body of `"\nYou are…"` — and re-emitting it dropped the blank line.
295
+ * Every {@link stampProvenance} call took that path, so materialisation changed
296
+ * bytes it had no business changing and {@link hashFile} (whole-file bytes) then
297
+ * reported files nobody had edited as locally edited. Byte-identity is the whole
298
+ * contract here; a "tidy" newline is not worth breaking it for.
299
+ */
288
300
  export function stringifyAgentMd(fm, body) {
289
301
  const b = typeof body === "string" ? body : "";
290
- return `---\n${stringifyFrontmatter(fm)}---\n${b.startsWith("\n") ? b.slice(1) : b}`;
302
+ return `---\n${stringifyFrontmatter(fm)}---\n${b}`;
291
303
  }
292
304
 
293
305
  /**
@@ -250,6 +250,28 @@ test("round trip: parse(stringify(x)) === x, and a second pass is byte-identical
250
250
  assert.equal(stringifyAgentMd(parsed.frontmatter, parsed.body), once);
251
251
  });
252
252
 
253
+ test("round trip: a file in the SHIPPED shape (blank line after the fence) survives byte-exact", () => {
254
+ // Regression. The round-trip test above starts its body at "# alpha", which is
255
+ // the one shape splitFrontmatter never returns — it slices from just after the
256
+ // closing fence's newline, so a real file's body ALWAYS starts with "\n".
257
+ // stringifyAgentMd used to strip exactly that newline, so every shipped
258
+ // agent.md lost its blank line on re-emit. stampProvenance takes this path on
259
+ // every materialisation and hashFile compares whole-file bytes, so the drift
260
+ // showed up as "locally edited" on files nobody had touched.
261
+ const src = [
262
+ "---", "name: alpha", "description: d", "model: claude-sonnet-4-6",
263
+ 'tools: ["Read"]', "---", "", "You are the alpha sub-agent.", "",
264
+ ].join("\n");
265
+ const parsed = parseAgentMd(src);
266
+ assert.equal(parsed.ok, true, parsed.errors.join("; "));
267
+ assert.equal(parsed.body.startsWith("\n"), true, "body keeps its leading \\n");
268
+ assert.equal(stringifyAgentMd(parsed.frontmatter, parsed.body), src);
269
+ assert.equal(
270
+ hashFile(stringifyAgentMd(parsed.frontmatter, parsed.body)), hashFile(src),
271
+ "re-emit must not move the file hash",
272
+ );
273
+ });
274
+
253
275
  test("stampProvenance: is IDEMPOTENT — re-stamping produces identical bytes", () => {
254
276
  // Non-idempotent stamping would change the file hash on every materialisation
255
277
  // and make local-edit detection permanently wrong.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * check-subagent-frontmatter.mjs — CI guard that fails if any
3
+ * `agents/<slug>/agent.md` would be refused by the sub-agent registry.
4
+ *
5
+ * WHY THIS EXISTS. `lib/subagents/schema.validateAgentMd()` has been the rule set
6
+ * since the registry landed, but nothing ran it over the working tree. The result
7
+ * was a fleet where 57 of 70 sub-agents failed validation — every one written by
8
+ * `scripts/setup/generate-capability.mjs`, which emitted `name`/`description`/
9
+ * `model` and no `tools`. Nobody noticed because the only thing that would have
10
+ * complained was a unit test asserting the shipped fleet validates, and that test
11
+ * had simply been red.
12
+ *
13
+ * Fixing the generator stops NEW bad agents from being generated. It does not
14
+ * stop one being hand-written, forked, or pasted in — so the rule is enforced
15
+ * here, over the whole directory, where a bad definition fails the build instead
16
+ * of failing silently at dispatch. Two gates, one rule set: this guard and the
17
+ * hq-side `validate.ts` both call the same validator.
18
+ *
19
+ * An EMPTY `agents/<slug>/` directory is a failure too. It used to pass `doctor`
20
+ * (which only checked that a directory existed), which is precisely how a
21
+ * sub-agent could be referenced everywhere and defined nowhere.
22
+ *
23
+ * Pure, dependency-light: Node builtins + lib/subagents. ESM.
24
+ *
25
+ * Usage: `node scripts/ci/check-subagent-frontmatter.mjs`
26
+ * exit 0 → every agent.md validates; exit 1 → at least one does not.
27
+ *
28
+ * @module scripts/ci/check-subagent-frontmatter
29
+ */
30
+
31
+ "use strict";
32
+
33
+ import { readFileSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ import { checkAgentFile } from "../../lib/subagents/schema.mjs";
38
+ import { listAgentDirs } from "../../lib/subagents/manifest.mjs";
39
+
40
+ /** Repo root: two levels up from scripts/ci/. @type {string} */
41
+ const REPO_ROOT = path.resolve(
42
+ fileURLToPath(new URL(".", import.meta.url)),
43
+ "..",
44
+ "..",
45
+ );
46
+
47
+ /**
48
+ * Validate every sub-agent definition under `<root>/agents`.
49
+ *
50
+ * @param {string} [root=REPO_ROOT]
51
+ * @returns {{ offenders: Array<{ slug: string, errors: string[] }>, warned: Array<{ slug: string, warnings: string[] }>, checked: number }}
52
+ * `offenders` fail the build; `warned` is advisory only (e.g. a declared token
53
+ * this renderer does not know), reported but never fatal.
54
+ */
55
+ export function findInvalidAgents(root = REPO_ROOT) {
56
+ const { slugs, empty } = listAgentDirs(root);
57
+ const offenders = [];
58
+ const warned = [];
59
+
60
+ for (const slug of empty) {
61
+ offenders.push({
62
+ slug,
63
+ errors: ["directory exists but contains no agent.md"],
64
+ });
65
+ }
66
+
67
+ for (const slug of slugs) {
68
+ const file = path.join(root, "agents", slug, "agent.md");
69
+ let text;
70
+ try {
71
+ text = readFileSync(file, "utf8");
72
+ } catch (err) {
73
+ offenders.push({
74
+ slug,
75
+ errors: [`unreadable: ${err && err.message ? err.message : err}`],
76
+ });
77
+ continue;
78
+ }
79
+ const res = checkAgentFile(text, slug);
80
+ if (!res.ok) offenders.push({ slug, errors: res.errors });
81
+ else if (res.warnings && res.warnings.length)
82
+ warned.push({ slug, warnings: res.warnings });
83
+ }
84
+
85
+ return { offenders, warned, checked: slugs.length + empty.length };
86
+ }
87
+
88
+ /**
89
+ * Run the check and print a human report.
90
+ *
91
+ * @param {string} [root=REPO_ROOT]
92
+ * @returns {Promise<number>} exit code (0 = all valid, 1 = at least one invalid).
93
+ */
94
+ export async function run(root = REPO_ROOT) {
95
+ const { offenders, warned, checked } = findInvalidAgents(root);
96
+
97
+ for (const { slug, warnings } of warned) {
98
+ for (const w of warnings) {
99
+ console.warn(`check-subagent-frontmatter: WARN ${slug}: ${w}`);
100
+ }
101
+ }
102
+
103
+ if (offenders.length === 0) {
104
+ console.log(
105
+ `check-subagent-frontmatter: OK (${checked} sub-agent definition(s) validate)`,
106
+ );
107
+ return 0;
108
+ }
109
+
110
+ console.error(
111
+ "check-subagent-frontmatter: FAIL — invalid sub-agent definition(s):",
112
+ );
113
+ for (const { slug, errors } of offenders) {
114
+ for (const e of errors) console.error(` agents/${slug}/agent.md: ${e}`);
115
+ }
116
+ console.error(
117
+ `check-subagent-frontmatter: ${offenders.length}/${checked} definition(s) would be refused by the registry.`,
118
+ );
119
+ console.error(
120
+ " Generated agents come from scripts/setup/generate-capability.mjs; repair existing ones with",
121
+ );
122
+ console.error(
123
+ " `node scripts/setup/repair-subagent-frontmatter.mjs --write`.",
124
+ );
125
+ return 1;
126
+ }
127
+
128
+ // Run when invoked directly (not when imported).
129
+ if (import.meta.url === `file://${process.argv[1]}`) {
130
+ run()
131
+ .then((code) => process.exit(code))
132
+ .catch((err) => {
133
+ console.error(
134
+ "check-subagent-frontmatter: ERROR",
135
+ err && err.message ? err.message : err,
136
+ );
137
+ process.exit(2);
138
+ });
139
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tests for check-subagent-frontmatter.mjs — the sub-agent definition guard.
3
+ *
4
+ * Runs against throwaway trees under os.tmpdir() rather than the repo's own
5
+ * agents/, so the tests still describe the guard's behaviour when the repo is
6
+ * clean (a guard that can only be tested by breaking the repo is a guard nobody
7
+ * tests).
8
+ *
9
+ * Run: node --test scripts/ci/check-subagent-frontmatter.test.mjs
10
+ * @module scripts/ci/check-subagent-frontmatter.test
11
+ */
12
+
13
+ import { describe, it } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { findInvalidAgents } from "./check-subagent-frontmatter.mjs";
20
+
21
+ /** Build a temp repo root containing `agents/<slug>/agent.md` for each entry. */
22
+ function makeTree(agents) {
23
+ const root = mkdtempSync(join(tmpdir(), "subagent-guard-"));
24
+ for (const [slug, text] of Object.entries(agents)) {
25
+ mkdirSync(join(root, "agents", slug), { recursive: true });
26
+ if (text !== null)
27
+ writeFileSync(join(root, "agents", slug, "agent.md"), text);
28
+ }
29
+ return root;
30
+ }
31
+
32
+ const VALID = [
33
+ "---",
34
+ "name: alpha",
35
+ "description: Does the alpha work.",
36
+ "model: claude-sonnet-4-6",
37
+ 'tools: ["Read", "Grep"]',
38
+ "---",
39
+ "",
40
+ "You are the alpha sub-agent.",
41
+ "",
42
+ ].join("\n");
43
+
44
+ describe("findInvalidAgents", () => {
45
+ it("passes a well-formed definition", () => {
46
+ const { offenders, checked } = findInvalidAgents(
47
+ makeTree({ alpha: VALID }),
48
+ );
49
+ assert.deepEqual(offenders, []);
50
+ assert.equal(checked, 1);
51
+ });
52
+
53
+ it("catches the exact defect that shipped: frontmatter with no tools key", () => {
54
+ // This is the whole reason the guard exists — 57 of 70 agents in this repo
55
+ // were generated in precisely this shape and every one failed at dispatch.
56
+ const noTools = VALID.split("\n")
57
+ .filter((l) => !l.startsWith("tools:"))
58
+ .join("\n");
59
+ const { offenders } = findInvalidAgents(makeTree({ alpha: noTools }));
60
+ assert.equal(offenders.length, 1);
61
+ assert.equal(offenders[0].slug, "alpha");
62
+ assert.ok(
63
+ offenders[0].errors.some((e) => e.includes("tools")),
64
+ `expected a tools error, got: ${offenders[0].errors.join("; ")}`,
65
+ );
66
+ });
67
+
68
+ it("fails an EMPTY agents/<slug>/ directory rather than passing it", () => {
69
+ // doctor used to accept this, which is how an agent could be referenced
70
+ // everywhere and defined nowhere.
71
+ const { offenders } = findInvalidAgents(makeTree({ ghost: null }));
72
+ assert.equal(offenders.length, 1);
73
+ assert.equal(offenders[0].slug, "ghost");
74
+ assert.match(offenders[0].errors[0], /no agent\.md/);
75
+ });
76
+
77
+ it("fails when frontmatter.name disagrees with the directory name", () => {
78
+ const renamed = VALID.replace("name: alpha", "name: beta");
79
+ const { offenders } = findInvalidAgents(makeTree({ alpha: renamed }));
80
+ assert.equal(offenders.length, 1);
81
+ assert.ok(offenders[0].errors.some((e) => e.includes("must equal")));
82
+ });
83
+
84
+ it("fails a body that uses a token its tokens list does not declare", () => {
85
+ const undeclared = VALID.replace(
86
+ "You are the alpha sub-agent.",
87
+ "You are {{agent.fullName}}'s alpha sub-agent.",
88
+ ).replace('tools: ["Read", "Grep"]', 'tools: ["Read"]\ntokens: []');
89
+ const { offenders } = findInvalidAgents(makeTree({ alpha: undeclared }));
90
+ assert.equal(offenders.length, 1);
91
+ assert.ok(offenders[0].errors.some((e) => e.includes("undeclared token")));
92
+ });
93
+
94
+ it("reports an unknown declared token as a WARNING, not a build failure", () => {
95
+ // Advisory by design: hq does not vendor KNOWN_TOKENS, so a body authored
96
+ // against a newer vocabulary must not hard-fail an older agent's build.
97
+ const odd = VALID.replace(
98
+ 'tools: ["Read", "Grep"]',
99
+ 'tools: ["Read"]\ntokens: ["agent.notAThing"]',
100
+ );
101
+ const { offenders, warned } = findInvalidAgents(makeTree({ alpha: odd }));
102
+ assert.deepEqual(offenders, []);
103
+ assert.equal(warned.length, 1);
104
+ assert.ok(warned[0].warnings.some((w) => w.includes("agent.notAThing")));
105
+ });
106
+
107
+ it("reports every offender, not just the first", () => {
108
+ const noTools = VALID.split("\n")
109
+ .filter((l) => !l.startsWith("tools:"))
110
+ .join("\n");
111
+ const { offenders, checked } = findInvalidAgents(
112
+ makeTree({ alpha: VALID, beta: noTools, gamma: noTools }),
113
+ );
114
+ assert.equal(checked, 3);
115
+ assert.deepEqual(offenders.map((o) => o.slug).sort(), ["beta", "gamma"]);
116
+ });
117
+
118
+ it("treats a missing agents/ directory as nothing to check, not an error", () => {
119
+ const root = mkdtempSync(join(tmpdir(), "subagent-guard-empty-"));
120
+ const { offenders, checked } = findInvalidAgents(root);
121
+ assert.deepEqual(offenders, []);
122
+ assert.equal(checked, 0);
123
+ });
124
+ });