@cohortapp/agent-sdk 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
package/lib/plan/compile.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 = {
|
package/lib/plan/emit.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
|
@@ -186,7 +186,17 @@ export function mapProfileToAgentConfig(profile) {
|
|
|
186
186
|
// the "Background" section of the prompt persona block (lib/identity/persona.mjs)
|
|
187
187
|
// so the agent writes with its own history behind it rather than as a generic
|
|
188
188
|
// seat-holder. The org is the SoT for this; we never synthesise one.
|
|
189
|
-
|
|
189
|
+
// Real Cohort records put this prose in one of two places, and the section
|
|
190
|
+
// list alone missed both for a live seat: the section is named SUMMARY (not
|
|
191
|
+
// "persona"/"background"), and the DTO also carries a top-level `bio` with the
|
|
192
|
+
// same text. Missing it is not cosmetic — an empty persona leaves
|
|
193
|
+
// `{{agent.persona}}` unresolved in CLAUDE.md and drops the Background line
|
|
194
|
+
// from the prompt block, which is the exact "generic assistant" failure this
|
|
195
|
+
// whole change exists to remove. Section first (it is the richer, explicitly
|
|
196
|
+
// authored field), then the top-level bio.
|
|
197
|
+
const persona =
|
|
198
|
+
sectionText(sections, ["persona", "background", "bio", "about", "profile", "summary"]) ||
|
|
199
|
+
str(p.bio);
|
|
190
200
|
if (persona) patch.persona = persona;
|
|
191
201
|
|
|
192
202
|
// charter — the Cohort-authored operating charter (CharterDTO:
|
|
@@ -345,7 +355,17 @@ function findSection(sections, names) {
|
|
|
345
355
|
const id = str(s.id).toLowerCase();
|
|
346
356
|
const title = str(s.title).toLowerCase();
|
|
347
357
|
const heading = str(s.heading).toLowerCase();
|
|
348
|
-
|
|
358
|
+
// `kind` is what a real Cohort ProfileSection identifies itself by
|
|
359
|
+
// (SUMMARY, VISUAL_PROMPT, COUNTERPARTS_NARRATIVE). Matching only
|
|
360
|
+
// id/title/heading silently missed EVERY section on a live seat, so a
|
|
361
|
+
// fully-populated org profile still yielded an empty persona.
|
|
362
|
+
const kind = str(s.kind).toLowerCase();
|
|
363
|
+
return (
|
|
364
|
+
wanted.includes(id) ||
|
|
365
|
+
wanted.includes(title) ||
|
|
366
|
+
wanted.includes(heading) ||
|
|
367
|
+
wanted.includes(kind)
|
|
368
|
+
);
|
|
349
369
|
}) || null
|
|
350
370
|
);
|
|
351
371
|
}
|
|
@@ -273,3 +273,28 @@ test("pullAndPlan: a local override is NOT clobbered without --force", async ()
|
|
|
273
273
|
const forced = await pullAndPlan({ agentRoot: root, env, force: true, fetchSelfProfileImpl: async () => ({ ok: true, result: PROFILE }) });
|
|
274
274
|
assert.equal(forced.patch.title, "VP of Engineering", "--force overwrites");
|
|
275
275
|
});
|
|
276
|
+
|
|
277
|
+
test("persona falls back to the SUMMARY section and then the top-level bio", () => {
|
|
278
|
+
// A live Cohort seat had its prose in a section named SUMMARY and in the
|
|
279
|
+
// DTO's top-level `bio` — neither of which the original name list matched.
|
|
280
|
+
// The result was an empty persona, an unresolved {{agent.persona}} in
|
|
281
|
+
// CLAUDE.md, and no Background line in the prompt: exactly the generic-seat
|
|
282
|
+
// failure this mapping exists to prevent.
|
|
283
|
+
const fromSummary = mapProfileToAgentConfig({
|
|
284
|
+
profileSections: [{ kind: "SUMMARY", body: "I build the agent substrate." }],
|
|
285
|
+
});
|
|
286
|
+
assert.equal(fromSummary.persona, "I build the agent substrate.");
|
|
287
|
+
|
|
288
|
+
const fromBio = mapProfileToAgentConfig({ bio: "I run platform engineering." });
|
|
289
|
+
assert.equal(fromBio.persona, "I run platform engineering.");
|
|
290
|
+
|
|
291
|
+
// An explicitly authored section still wins over the terser bio.
|
|
292
|
+
const both = mapProfileToAgentConfig({
|
|
293
|
+
bio: "short bio",
|
|
294
|
+
profileSections: [{ kind: "background", body: "the long authored version" }],
|
|
295
|
+
});
|
|
296
|
+
assert.equal(both.persona, "the long authored version");
|
|
297
|
+
|
|
298
|
+
// Nothing to say stays empty — we never synthesise a persona.
|
|
299
|
+
assert.equal(mapProfileToAgentConfig({}).persona, undefined);
|
|
300
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
package/lib/subagents/schema.mjs
CHANGED
|
@@ -284,10 +284,22 @@ export function stringifyFrontmatter(fm) {
|
|
|
284
284
|
return lines.length ? `${lines.join("\n")}\n` : "";
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/**
|
|
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
|
|
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.
|
|
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
|
+
}
|