@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.
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +105 -17
- package/lib/setup/enroll-from-cohort.test.mjs +68 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/plan/schema.mjs — the OBLIGATION schema + a fail-closed validator.
|
|
3
|
+
*
|
|
4
|
+
* The plan is the agent's half of the ownership split: hq owns the mandate, the
|
|
5
|
+
* agent owns the obligations, the schedule and the backlog. An obligation is the
|
|
6
|
+
* unit of that plan — one of three kinds:
|
|
7
|
+
*
|
|
8
|
+
* REACT — "when <topic/kind> arrives and <predicate> holds, do this"
|
|
9
|
+
* SCHEDULE — "at <interval|calendar>, run this" (compiles to a launchd plist)
|
|
10
|
+
* OUTCOME — "this number, measured by this sensor, must reach this target"
|
|
11
|
+
*
|
|
12
|
+
* Validation is dependency-free and FAIL-CLOSED (mirrors lib/archetype.mjs's
|
|
13
|
+
* validateProfile): a plan that does not validate is not written, because a
|
|
14
|
+
* half-valid plan silently emits half a schedule, and a missing launchd job is
|
|
15
|
+
* exactly the class of silent failure this system keeps growing.
|
|
16
|
+
*
|
|
17
|
+
* Two structural laws are enforced here rather than by convention:
|
|
18
|
+
* 1. every `uses[]` id must be REACHABLE in the capability manifest
|
|
19
|
+
* 2. every OUTCOME obligation must carry a sensor, and a `source:"llm"` sensor
|
|
20
|
+
* is never allowed to close a loop (it is advisory only)
|
|
21
|
+
*
|
|
22
|
+
* PURE. No fs, no clock, no network.
|
|
23
|
+
*
|
|
24
|
+
* @module lib/plan/schema
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
/** The three obligation kinds. */
|
|
30
|
+
export const OBLIGATION_KINDS = Object.freeze(["REACT", "SCHEDULE", "OUTCOME"]);
|
|
31
|
+
/** Where an obligation came from (its provenance origin). */
|
|
32
|
+
export const ORIGINS = Object.freeze(["standard", "archetype", "mandate", "react-standard"]);
|
|
33
|
+
/** Execution modes the cadence consumer understands (unchanged vocabulary). */
|
|
34
|
+
export const MODES = Object.freeze(["inline", "guarded", "escalate"]);
|
|
35
|
+
/** Blast-radius classes that force an approval before any rung executes. */
|
|
36
|
+
export const ACTION_CLASSES = Object.freeze(["internal", "external", "irreversible", "financial"]);
|
|
37
|
+
/** Sensor provenance — the honesty gate. */
|
|
38
|
+
export const SENSOR_SOURCES = Object.freeze(["method", "human", "llm"]);
|
|
39
|
+
/** Statuses an obligation can hold locally. */
|
|
40
|
+
export const STATUSES = Object.freeze(["active", "suspended", "proposed"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate ONE obligation.
|
|
44
|
+
* @param {object} ob
|
|
45
|
+
* @param {{reachable?:Set<string>}} [o]
|
|
46
|
+
* @returns {string[]} error strings (empty = valid)
|
|
47
|
+
*/
|
|
48
|
+
export function validateObligation(ob, o = {}) {
|
|
49
|
+
const errs = [];
|
|
50
|
+
const at = (m) => `obligation ${(ob && ob.key) || "<no key>"}: ${m}`;
|
|
51
|
+
if (!ob || typeof ob !== "object") return ["obligation is not an object"];
|
|
52
|
+
|
|
53
|
+
if (!ob.key || typeof ob.key !== "string") errs.push(at("missing key"));
|
|
54
|
+
else if (!/^[a-z0-9][a-z0-9._-]*$/.test(ob.key)) errs.push(at(`key "${ob.key}" is not a stable slug ([a-z0-9._-])`));
|
|
55
|
+
|
|
56
|
+
if (!OBLIGATION_KINDS.includes(ob.kind)) errs.push(at(`kind must be one of ${OBLIGATION_KINDS.join("|")} (got ${JSON.stringify(ob.kind)})`));
|
|
57
|
+
|
|
58
|
+
if (!ob.source || typeof ob.source !== "object") errs.push(at("missing source provenance"));
|
|
59
|
+
else if (!ORIGINS.includes(ob.source.origin)) errs.push(at(`source.origin must be one of ${ORIGINS.join("|")}`));
|
|
60
|
+
|
|
61
|
+
if (!STATUSES.includes(ob.status)) errs.push(at(`status must be one of ${STATUSES.join("|")}`));
|
|
62
|
+
|
|
63
|
+
if (!Array.isArray(ob.uses)) errs.push(at("uses[] must be an array"));
|
|
64
|
+
if (!Array.isArray(ob.allowed_tools)) errs.push(at("allowed_tools[] must be an array"));
|
|
65
|
+
if (!Array.isArray(ob.action_classes)) errs.push(at("action_classes[] must be an array"));
|
|
66
|
+
else for (const c of ob.action_classes) if (!ACTION_CLASSES.includes(c)) errs.push(at(`unknown action class "${c}"`));
|
|
67
|
+
|
|
68
|
+
if (typeof ob.offline_safe !== "boolean") errs.push(at("offline_safe must be a boolean"));
|
|
69
|
+
if (ob.budget_cents_per_period != null && !Number.isFinite(ob.budget_cents_per_period)) errs.push(at("budget_cents_per_period must be a number"));
|
|
70
|
+
|
|
71
|
+
// Kind-specific structure.
|
|
72
|
+
if (ob.kind === "SCHEDULE") {
|
|
73
|
+
const hasInterval = ob.schedule && Number.isFinite(ob.schedule.interval);
|
|
74
|
+
const hasCalendar = ob.schedule && ob.schedule.calendar && typeof ob.schedule.calendar === "object";
|
|
75
|
+
if (!hasInterval && !hasCalendar) errs.push(at("SCHEDULE needs schedule.interval or schedule.calendar"));
|
|
76
|
+
if (!MODES.includes(ob.mode)) errs.push(at(`SCHEDULE needs a mode (${MODES.join("|")})`));
|
|
77
|
+
// The hole this closes: an archetype cadence used to get {mode, prompt} and a
|
|
78
|
+
// blanket-permission session. An empty allowed_tools list is legal ONLY for
|
|
79
|
+
// an inline (deterministic, no-session) cadence.
|
|
80
|
+
if (ob.mode !== "inline" && (!Array.isArray(ob.allowed_tools) || ob.allowed_tools.length === 0)) {
|
|
81
|
+
errs.push(at("a session-spawning SCHEDULE must declare a non-empty allowed_tools list"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (ob.kind === "OUTCOME") {
|
|
85
|
+
if (!ob.objective_id) errs.push(at("OUTCOME must be provenance-linked to an objective_id"));
|
|
86
|
+
if (!ob.sensor || typeof ob.sensor !== "object") errs.push(at("OUTCOME must carry a sensor"));
|
|
87
|
+
else {
|
|
88
|
+
if (!SENSOR_SOURCES.includes(ob.sensor.source)) errs.push(at(`sensor.source must be one of ${SENSOR_SOURCES.join("|")}`));
|
|
89
|
+
if (ob.sensor.source === "llm") errs.push(at('sensor.source "llm" cannot back an OUTCOME obligation (advisory only)'));
|
|
90
|
+
if (ob.sensor.source === "method" && !ob.sensor.capability) errs.push(at('sensor.source "method" requires a capability'));
|
|
91
|
+
}
|
|
92
|
+
if (ob.target != null && !Number.isFinite(ob.target)) errs.push(at("target must be a number when set"));
|
|
93
|
+
}
|
|
94
|
+
if (ob.kind === "REACT") {
|
|
95
|
+
if (!ob.trigger || typeof ob.trigger !== "object" || !ob.trigger.topic) errs.push(at("REACT needs trigger.topic"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The capability law.
|
|
99
|
+
if (o.reachable instanceof Set && Array.isArray(ob.uses)) {
|
|
100
|
+
for (const id of ob.uses) {
|
|
101
|
+
if (!o.reachable.has(id)) errs.push(at(`cites capability "${id}" which is not reachable:true in the capability manifest`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return errs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate a whole plan. Fail-closed: `ok:false` means DO NOT WRITE.
|
|
109
|
+
* @param {{obligations:object[]}} plan
|
|
110
|
+
* @param {{manifest?:object, reachable?:Set<string>}} [o]
|
|
111
|
+
* @returns {{ok:boolean, errors:string[], counts:object}}
|
|
112
|
+
*/
|
|
113
|
+
export function validatePlan(plan, o = {}) {
|
|
114
|
+
const errors = [];
|
|
115
|
+
const obligations = plan && Array.isArray(plan.obligations) ? plan.obligations : null;
|
|
116
|
+
if (!obligations) return { ok: false, errors: ["plan.obligations must be an array"], counts: {} };
|
|
117
|
+
|
|
118
|
+
const reachable = o.reachable instanceof Set
|
|
119
|
+
? o.reachable
|
|
120
|
+
: o.manifest
|
|
121
|
+
? new Set(((o.manifest.entries) || []).filter((e) => e && e.reachable).map((e) => e.id))
|
|
122
|
+
: null;
|
|
123
|
+
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const ob of obligations) {
|
|
126
|
+
if (ob && ob.key) {
|
|
127
|
+
if (seen.has(ob.key)) errors.push(`duplicate obligation key "${ob.key}"`);
|
|
128
|
+
seen.add(ob.key);
|
|
129
|
+
}
|
|
130
|
+
errors.push(...validateObligation(ob, { reachable }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const counts = {};
|
|
134
|
+
for (const k of OBLIGATION_KINDS) counts[k] = obligations.filter((ob) => ob && ob.kind === k).length;
|
|
135
|
+
counts.total = obligations.length;
|
|
136
|
+
|
|
137
|
+
return { ok: errors.length === 0, errors, counts };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default { OBLIGATION_KINDS, ORIGINS, MODES, ACTION_CLASSES, SENSOR_SOURCES, STATUSES, validateObligation, validatePlan };
|
|
@@ -169,11 +169,57 @@ export function _resetLiveCache() { _liveCache = { at: 0, value: null }; }
|
|
|
169
169
|
* Build the real-world deps snapshot the governor reasons over. Tests bypass
|
|
170
170
|
* this entirely by passing their own `deps`.
|
|
171
171
|
*/
|
|
172
|
+
// How long an availableMemoryBytes() probe is reused. vm_stat costs a few ms and
|
|
173
|
+
// the governor is consulted on every admission decision; memory pressure does not
|
|
174
|
+
// meaningfully move inside a 2s window.
|
|
175
|
+
const AVAIL_CACHE_MS = 2000;
|
|
176
|
+
let availCache = { at: 0, bytes: 0 };
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Bytes of memory the OS can actually hand to a new session.
|
|
180
|
+
*
|
|
181
|
+
* On Darwin `os.freemem()` reports ONLY vm_stat's "Pages free" — it excludes
|
|
182
|
+
* inactive, speculative and purgeable pages, every one of which macOS reclaims on
|
|
183
|
+
* demand. Any warm Mac therefore reports near-zero free memory forever (measured:
|
|
184
|
+
* 263MB "free" on a 24GB box that `memory_pressure` simultaneously called 76%
|
|
185
|
+
* free), so a percentage floor could never be satisfied and the governor deferred
|
|
186
|
+
* every spawn indefinitely. Counting the reclaimable pages is what macOS itself
|
|
187
|
+
* means by available memory.
|
|
188
|
+
*
|
|
189
|
+
* Falls back to `os.freemem()` on non-Darwin platforms and on any probe failure —
|
|
190
|
+
* a governor that cannot measure must not silently admit everything.
|
|
191
|
+
*
|
|
192
|
+
* @param {number} [now] injectable clock (tests)
|
|
193
|
+
* @returns {number} bytes
|
|
194
|
+
*/
|
|
195
|
+
export function availableMemoryBytes(now = Date.now()) {
|
|
196
|
+
if (process.platform !== "darwin") return os.freemem();
|
|
197
|
+
if (availCache.at && now - availCache.at < AVAIL_CACHE_MS) return availCache.bytes;
|
|
198
|
+
|
|
199
|
+
let bytes = os.freemem();
|
|
200
|
+
try {
|
|
201
|
+
const out = execFileSync("vm_stat", { encoding: "utf8", timeout: 2000 });
|
|
202
|
+
const pageSize = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096;
|
|
203
|
+
const pages = (label) => {
|
|
204
|
+
const m = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(out);
|
|
205
|
+
return m ? Number(m[1]) : 0;
|
|
206
|
+
};
|
|
207
|
+
const reclaimable =
|
|
208
|
+
pages("Pages free") + pages("Pages inactive") + pages("Pages speculative") + pages("Pages purgeable");
|
|
209
|
+
if (reclaimable > 0) bytes = reclaimable * pageSize;
|
|
210
|
+
} catch {
|
|
211
|
+
// vm_stat missing or unparseable — keep the os.freemem() reading.
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
availCache = { at: now, bytes };
|
|
215
|
+
return bytes;
|
|
216
|
+
}
|
|
217
|
+
|
|
172
218
|
export function defaultDeps(extra = {}) {
|
|
173
219
|
const live = liveClaudeStats();
|
|
174
220
|
const throttle = readThrottle(extra);
|
|
175
221
|
return {
|
|
176
|
-
freemem:
|
|
222
|
+
freemem: availableMemoryBytes(),
|
|
177
223
|
totalmem: os.totalmem(),
|
|
178
224
|
loadavg: os.loadavg(),
|
|
179
225
|
cpus: os.cpus().length || 1,
|
|
@@ -11,7 +11,7 @@ import { test } from "node:test";
|
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
12
|
import { promises as fsp } from "node:fs";
|
|
13
13
|
import { writeFileSync } from "node:fs";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
14
|
+
import { tmpdir, freemem as osFreemem, totalmem as totalmemBytes } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import {
|
|
@@ -21,8 +21,28 @@ import {
|
|
|
21
21
|
PER_SESSION_MB,
|
|
22
22
|
RAM_FRACTION,
|
|
23
23
|
DECISIONS,
|
|
24
|
+
availableMemoryBytes,
|
|
25
|
+
FREEMEM_FLOOR_FRACTION,
|
|
24
26
|
} from "./resource-governor.mjs";
|
|
25
27
|
|
|
28
|
+
test("availableMemoryBytes counts reclaimable pages, not just free ones", () => {
|
|
29
|
+
const bytes = availableMemoryBytes();
|
|
30
|
+
assert.ok(Number.isFinite(bytes) && bytes > 0, "always a usable number");
|
|
31
|
+
|
|
32
|
+
if (process.platform !== "darwin") {
|
|
33
|
+
assert.equal(bytes, osFreemem(), "non-Darwin defers to os.freemem()");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The regression: os.freemem() on macOS reports only "Pages free", which sits
|
|
38
|
+
// near zero on any warm box and pinned the governor at DEFER. Availability must
|
|
39
|
+
// include the inactive/speculative/purgeable pages macOS reclaims on demand,
|
|
40
|
+
// so it is always >= the raw reading and should clear the floor on an idle host.
|
|
41
|
+
assert.ok(bytes >= osFreemem(), "availability is never below raw free memory");
|
|
42
|
+
const floor = totalmemBytes() * FREEMEM_FLOOR_FRACTION;
|
|
43
|
+
assert.ok(bytes > floor, `an idle Mac must clear its own floor (${bytes} vs ${floor})`);
|
|
44
|
+
});
|
|
45
|
+
|
|
26
46
|
const GB = 1024 * 1024 * 1024;
|
|
27
47
|
const MB = 1024 * 1024;
|
|
28
48
|
|
|
@@ -40,15 +40,20 @@ import { isPlaceholder } from "./completeness.mjs";
|
|
|
40
40
|
// Archetype axes the identity section also offers. Keep these in sync with
|
|
41
41
|
// lib/setup/sections/identity.mjs FUNCTIONS/ALTITUDES — a derived value MUST be
|
|
42
42
|
// one the wizard's select() can render and the archetype resolver accepts.
|
|
43
|
+
// These are the seven archetypes that exist on disk. `people-leader`/`compliance-leader`
|
|
44
|
+
// were derivable here but resolve to no archetype file — a pull could therefore
|
|
45
|
+
// write an un-resolvable function into config/agent.json and make the identity
|
|
46
|
+
// section's apply() fatal. They map onto `operations-leader`/`compliance-officer`.
|
|
43
47
|
const FUNCTIONS = Object.freeze([
|
|
44
48
|
"executive-operator",
|
|
45
49
|
"technical-leader",
|
|
46
50
|
"commercial-leader",
|
|
47
|
-
"
|
|
51
|
+
"product-leader",
|
|
52
|
+
"operations-leader",
|
|
48
53
|
"finance-leader",
|
|
49
|
-
"compliance-
|
|
54
|
+
"compliance-officer",
|
|
50
55
|
]);
|
|
51
|
-
const ALTITUDES = Object.freeze(["c-suite", "svp", "vp", "senior-manager"]);
|
|
56
|
+
const ALTITUDES = Object.freeze(["founder", "c-suite", "svp", "vp", "senior-manager"]);
|
|
52
57
|
|
|
53
58
|
const DEFAULT_FUNCTION = "executive-operator";
|
|
54
59
|
const DEFAULT_ALTITUDE = "c-suite";
|
|
@@ -69,9 +74,50 @@ export function cohortPullEnv(env = process.env) {
|
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
function str(v) {
|
|
72
|
-
|
|
77
|
+
if (v == null) return "";
|
|
78
|
+
// NEVER stringify an object/array leaf. The live MemberProfileDTO nests
|
|
79
|
+
// role/team as objects; `String({})` yields the TRUTHY literal
|
|
80
|
+
// "[object Object]", which used to satisfy every `if (value)` guard below and
|
|
81
|
+
// then poison the keyword derivations — a silent fall-back to defaults rather
|
|
82
|
+
// than an honest empty. Nested shapes are read via the accessors below.
|
|
83
|
+
if (typeof v === "object") return "";
|
|
84
|
+
return String(v).trim();
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The live Cohort MemberProfileDTO nests the fields the mapper needs:
|
|
89
|
+
* role: { title, level, status } team: { slug, name, shortLabel }
|
|
90
|
+
* Older/flattened payloads (and the wizard's own fixtures) carry them as plain
|
|
91
|
+
* strings. These accessors read BOTH shapes so a pull maps identically either
|
|
92
|
+
* way. Each returns "" when the profile genuinely carries nothing.
|
|
93
|
+
*/
|
|
94
|
+
function profileTitle(p) {
|
|
95
|
+
return str(p.title) || str(p.role?.title);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function profileRoleText(p) {
|
|
99
|
+
return str(p.role) || str(p.role?.title) || str(p.role?.level);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function profileTeamText(p) {
|
|
103
|
+
return str(p.team) || str(p.team?.name) || str(p.team?.shortLabel) || str(p.team?.slug);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The org's explicit seniority band, when it ships one (`role.level`). */
|
|
107
|
+
function profileLevel(p) {
|
|
108
|
+
return str(p.level) || str(p.role?.level);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Cohort's `role.level` enum → the archetype `altitude` axis. An EXPLICIT band
|
|
112
|
+
// from the org beats guessing at the title string, so this is consulted first.
|
|
113
|
+
const LEVEL_TO_ALTITUDE = Object.freeze({
|
|
114
|
+
FOUNDER: "founder",
|
|
115
|
+
C_SUITE: "c-suite",
|
|
116
|
+
SVP: "svp",
|
|
117
|
+
VP: "vp",
|
|
118
|
+
SENIOR_MANAGER: "senior-manager",
|
|
119
|
+
});
|
|
120
|
+
|
|
75
121
|
/**
|
|
76
122
|
* Map a Cohort MemberProfileDTO to the config/agent.json field subset it
|
|
77
123
|
* authoritatively provides. Only sets a field when the profile carries a real
|
|
@@ -88,6 +134,7 @@ function str(v) {
|
|
|
88
134
|
* operatingPrinciples ← profileSections (operating-principles section)
|
|
89
135
|
* communication.defaultTone ← profileSections (communication/tone section)
|
|
90
136
|
* responsibilities ← profileSections (responsibilities section)
|
|
137
|
+
* persona ← profileSections (persona/background/bio section)
|
|
91
138
|
*
|
|
92
139
|
* @param {object|null} profile MemberProfileDTO
|
|
93
140
|
* @returns {object} the config/agent.json patch (deep-mergeable)
|
|
@@ -104,7 +151,7 @@ export function mapProfileToAgentConfig(profile) {
|
|
|
104
151
|
const fullName = display || [first, last].filter(Boolean).join(" ").trim();
|
|
105
152
|
if (fullName) patch.fullName = fullName;
|
|
106
153
|
|
|
107
|
-
const title =
|
|
154
|
+
const title = profileTitle(p);
|
|
108
155
|
if (title) patch.title = title;
|
|
109
156
|
|
|
110
157
|
const email = str(p.agentEmail) || str(p.email);
|
|
@@ -115,7 +162,7 @@ export function mapProfileToAgentConfig(profile) {
|
|
|
115
162
|
// leaving the wizard to prompt). When signal IS present, derivation always
|
|
116
163
|
// resolves to a VALID axis (safe default when unmappable) so the wizard renders
|
|
117
164
|
// + confirms it rather than failing the pull.
|
|
118
|
-
const hasAxisSignal = !!(
|
|
165
|
+
const hasAxisSignal = !!(profileRoleText(p) || profileTeamText(p) || profileTitle(p) || profileLevel(p));
|
|
119
166
|
if (hasAxisSignal) {
|
|
120
167
|
patch.function = deriveFunction(p);
|
|
121
168
|
patch.altitude = deriveAltitude(p);
|
|
@@ -135,6 +182,22 @@ export function mapProfileToAgentConfig(profile) {
|
|
|
135
182
|
if (responsibilities.length) patch.responsibilities = responsibilities;
|
|
136
183
|
const tone = sectionText(sections, ["communication", "tone", "communication-style"]);
|
|
137
184
|
if (tone) patch.communication = { defaultTone: tone };
|
|
185
|
+
// persona/background — free prose describing who this member is. Rendered into
|
|
186
|
+
// the "Background" section of the prompt persona block (lib/identity/persona.mjs)
|
|
187
|
+
// so the agent writes with its own history behind it rather than as a generic
|
|
188
|
+
// seat-holder. The org is the SoT for this; we never synthesise one.
|
|
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);
|
|
200
|
+
if (persona) patch.persona = persona;
|
|
138
201
|
|
|
139
202
|
// charter — the Cohort-authored operating charter (CharterDTO:
|
|
140
203
|
// approvedByLine/gatesText/sections). When present this is the SOURCE OF TRUTH
|
|
@@ -218,14 +281,17 @@ function mapSupervisor(sup) {
|
|
|
218
281
|
}
|
|
219
282
|
if (typeof sup === "object") {
|
|
220
283
|
const fullName = str(sup.displayName) || str(sup.fullName) || [str(sup.firstName), str(sup.lastName)].filter(Boolean).join(" ").trim();
|
|
221
|
-
|
|
284
|
+
// A supervisor DTO nests its title under `role`, exactly as the member does.
|
|
285
|
+
const title = profileTitle(sup);
|
|
286
|
+
const email = str(sup.email) || str(sup.agentEmail);
|
|
287
|
+
if (!fullName && !email && !title) return null;
|
|
222
288
|
const [pf, ...pr] = fullName.split(/\s+/);
|
|
223
289
|
return {
|
|
224
290
|
firstName: str(sup.firstName) || pf || "",
|
|
225
291
|
lastName: str(sup.lastName) || pr.join(" "),
|
|
226
292
|
fullName,
|
|
227
|
-
title
|
|
228
|
-
email
|
|
293
|
+
title,
|
|
294
|
+
email,
|
|
229
295
|
};
|
|
230
296
|
}
|
|
231
297
|
return null;
|
|
@@ -239,14 +305,16 @@ function mapSupervisor(sup) {
|
|
|
239
305
|
* @returns {string} one of FUNCTIONS
|
|
240
306
|
*/
|
|
241
307
|
export function deriveFunction(p) {
|
|
242
|
-
const hay = `${
|
|
308
|
+
const hay = `${profileRoleText(p)} ${profileTeamText(p)} ${profileTitle(p)}`.toLowerCase();
|
|
243
309
|
const rules = [
|
|
244
310
|
[/\b(eng|engineer|platform|infra|technical|cto|devops|software|sre)\b/, "technical-leader"],
|
|
311
|
+
[/\b(product|design|ux|research|pm|cpo)\b/, "product-leader"],
|
|
245
312
|
[/\b(sales|revenue|gtm|growth|commercial|marketing|bd|biz dev|partnerships)\b/, "commercial-leader"],
|
|
246
|
-
[/\b(people|hr|talent|recruit|org design|human resources)\b/, "
|
|
313
|
+
[/\b(people|hr|talent|recruit|org design|human resources)\b/, "operations-leader"],
|
|
247
314
|
[/\b(finance|fp&a|accounting|treasury|capital|corp dev|cfo)\b/, "finance-leader"],
|
|
248
|
-
[/\b(compliance|legal|risk|regulatory|counsel|audit|dfsa)\b/, "compliance-
|
|
249
|
-
[/\b(ops|operations|
|
|
315
|
+
[/\b(compliance|legal|risk|regulatory|counsel|audit|dfsa)\b/, "compliance-officer"],
|
|
316
|
+
[/\b(ops|operations|delivery|supply|logistics)\b/, "operations-leader"],
|
|
317
|
+
[/\b(chief of staff|coo|executive|operator)\b/, "executive-operator"],
|
|
250
318
|
];
|
|
251
319
|
for (const [re, fn] of rules) if (re.test(hay)) return fn;
|
|
252
320
|
return DEFAULT_FUNCTION;
|
|
@@ -259,9 +327,19 @@ export function deriveFunction(p) {
|
|
|
259
327
|
* @returns {string} one of ALTITUDES
|
|
260
328
|
*/
|
|
261
329
|
export function deriveAltitude(p) {
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
|
|
330
|
+
// An explicit `role.level` from the org is authoritative — prefer it over
|
|
331
|
+
// guessing at prose. (An SVP reporting to a CPTO reads as "chief" to the
|
|
332
|
+
// keyword pass below only because their manager's title is nearby; the band
|
|
333
|
+
// removes that whole class of error.)
|
|
334
|
+
const level = profileLevel(p).toUpperCase().replace(/[\s-]+/g, "_");
|
|
335
|
+
if (LEVEL_TO_ALTITUDE[level]) return LEVEL_TO_ALTITUDE[level];
|
|
336
|
+
|
|
337
|
+
const hay = `${profileRoleText(p)} ${profileTitle(p)}`.toLowerCase();
|
|
338
|
+
// Order matters: most-senior cue wins (check founder/c-suite/SVP before
|
|
339
|
+
// VP/manager). `founder` is its own altitude — it resolves the runway-review
|
|
340
|
+
// and investor-update cadences a generic c-suite seat does not get.
|
|
341
|
+
if (/\b(founder|co-?founder)\b/.test(hay)) return "founder";
|
|
342
|
+
if (/\b(c-?suite|chief|ceo|cto|cfo|coo|cmo|cpo)\b/.test(hay)) return "c-suite";
|
|
265
343
|
if (/\bsvp\b|senior vice president/.test(hay)) return "svp";
|
|
266
344
|
if (/\bvp\b|vice president|head of|director/.test(hay)) return "vp";
|
|
267
345
|
if (/\b(senior manager|sr\.? manager|manager|lead)\b/.test(hay)) return "senior-manager";
|
|
@@ -277,7 +355,17 @@ function findSection(sections, names) {
|
|
|
277
355
|
const id = str(s.id).toLowerCase();
|
|
278
356
|
const title = str(s.title).toLowerCase();
|
|
279
357
|
const heading = str(s.heading).toLowerCase();
|
|
280
|
-
|
|
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
|
+
);
|
|
281
369
|
}) || null
|
|
282
370
|
);
|
|
283
371
|
}
|
|
@@ -52,6 +52,46 @@ const PROFILE = {
|
|
|
52
52
|
],
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
// The shape the LIVE org-data API actually returns: role/team are objects, and
|
|
56
|
+
// the supervisor nests its title the same way. The flat PROFILE above is the
|
|
57
|
+
// legacy/wizard shape — both must map identically. Regression fixture for the
|
|
58
|
+
// enrolment that shipped an agent with title:"", altitude:"c-suite" and a
|
|
59
|
+
// principal with no title, because every field below was read one level too high.
|
|
60
|
+
const NESTED_PROFILE = {
|
|
61
|
+
slug: "A016",
|
|
62
|
+
kind: "AI_AGENT",
|
|
63
|
+
status: "ACTIVE",
|
|
64
|
+
displayName: "Agent One",
|
|
65
|
+
firstName: "Agent",
|
|
66
|
+
lastName: "One",
|
|
67
|
+
team: { slug: "ai-systems-agent-platform", name: "AI Systems & Agent Platform", shortLabel: "AI Systems" },
|
|
68
|
+
role: { title: "SVP AI Systems & Agent Platform", level: "SVP", status: "ACTIVE" },
|
|
69
|
+
agentEmail: "agent-one@example.test",
|
|
70
|
+
supervisor: {
|
|
71
|
+
slug: "A002",
|
|
72
|
+
displayName: "Samir Al Sulaiti",
|
|
73
|
+
role: { title: "Chief Product & Technology Officer", level: "C_SUITE", status: "ACTIVE" },
|
|
74
|
+
team: { slug: "product-technology-platform", name: "Product, Technology & Platform" },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
test("mapProfileToAgentConfig reads the nested (live) DTO shape", () => {
|
|
79
|
+
const patch = mapProfileToAgentConfig(NESTED_PROFILE);
|
|
80
|
+
assert.equal(patch.title, "SVP AI Systems & Agent Platform", "title comes from role.title");
|
|
81
|
+
assert.equal(patch.function, "technical-leader", "derived from the platform/AI-systems team");
|
|
82
|
+
assert.equal(patch.altitude, "svp", "role.level beats keyword-guessing the title");
|
|
83
|
+
assert.equal(patch.principal.fullName, "Samir Al Sulaiti");
|
|
84
|
+
assert.equal(patch.principal.title, "Chief Product & Technology Officer", "supervisor title comes from role.title");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("an object leaf never stringifies into a derivation", () => {
|
|
88
|
+
// `String({})` is the truthy "[object Object]"; letting it through made every
|
|
89
|
+
// derivation silently fall back to its default instead of reporting no signal.
|
|
90
|
+
assert.equal(mapProfileToAgentConfig({ role: {}, team: {} }).function, undefined, "no axis signal → no axis emitted");
|
|
91
|
+
assert.equal(deriveFunction({ role: { title: "Staff Engineer" } }), "technical-leader");
|
|
92
|
+
assert.equal(deriveAltitude({ role: { level: "SENIOR_MANAGER" } }), "senior-manager");
|
|
93
|
+
});
|
|
94
|
+
|
|
55
95
|
test("mapProfileToAgentConfig maps the authoritative fields", () => {
|
|
56
96
|
const patch = mapProfileToAgentConfig(PROFILE);
|
|
57
97
|
assert.equal(patch.firstName, "Robin");
|
|
@@ -130,7 +170,9 @@ test("charter/orgProfile absent → no charter/orgProfile keys (empty patch stay
|
|
|
130
170
|
test("deriveFunction + deriveAltitude resolve to valid axes (safe default)", () => {
|
|
131
171
|
assert.ok(FUNCTIONS.includes(deriveFunction({ role: "sales lead", team: "Revenue" })));
|
|
132
172
|
assert.equal(deriveFunction({ role: "sales lead", team: "Revenue" }), "commercial-leader");
|
|
133
|
-
|
|
173
|
+
// `compliance-leader` resolves to no archetype on disk; the rule map returns
|
|
174
|
+
// the archetype that does exist. The expectation, not the code, was stale.
|
|
175
|
+
assert.equal(deriveFunction({ role: "general counsel", team: "Legal" }), "compliance-officer");
|
|
134
176
|
assert.equal(deriveFunction({ role: "weird-unknown-role" }), "executive-operator");
|
|
135
177
|
assert.ok(ALTITUDES.includes(deriveAltitude({ title: "Chief Technology Officer" })));
|
|
136
178
|
assert.equal(deriveAltitude({ title: "Chief Technology Officer" }), "c-suite");
|
|
@@ -231,3 +273,28 @@ test("pullAndPlan: a local override is NOT clobbered without --force", async ()
|
|
|
231
273
|
const forced = await pullAndPlan({ agentRoot: root, env, force: true, fetchSelfProfileImpl: async () => ({ ok: true, result: PROFILE }) });
|
|
232
274
|
assert.equal(forced.patch.title, "VP of Engineering", "--force overwrites");
|
|
233
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
|
+
});
|
|
@@ -20,15 +20,26 @@ import { runGenerator } from "../run-generator.mjs";
|
|
|
20
20
|
import { cohortPullEnv, pullAndPlan } from "../enroll-from-cohort.mjs";
|
|
21
21
|
import { fetchSelfProfile } from "../../org/client.mjs";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// The seven archetypes that ACTUALLY EXIST on disk (archetypes/*.yaml). This
|
|
24
|
+
// list used to offer `people-leader` and `compliance-leader`, neither of which
|
|
25
|
+
// has an archetype file — picking either resolved to nothing and made apply()
|
|
26
|
+
// fatal, and it omitted `operations-leader` and `product-leader`, which do
|
|
27
|
+
// exist. The whole init pipeline downstream (capability pack → cadences →
|
|
28
|
+
// plan compiler) keys off this value, so it must name a real archetype.
|
|
29
|
+
// Keep in sync with lib/setup/enroll-from-cohort.mjs FUNCTIONS.
|
|
30
|
+
export const FUNCTIONS = [
|
|
24
31
|
{ value: "executive-operator", label: "Executive operator (chief of staff / COO-style)" },
|
|
25
32
|
{ value: "technical-leader", label: "Technical leader (eng / platform / CTO-style)" },
|
|
26
33
|
{ value: "commercial-leader", label: "Commercial leader (sales / GTM / revenue)" },
|
|
27
|
-
{ value: "
|
|
34
|
+
{ value: "product-leader", label: "Product leader (product / design / research)" },
|
|
35
|
+
{ value: "operations-leader", label: "Operations leader (ops / people / delivery)" },
|
|
28
36
|
{ value: "finance-leader", label: "Finance leader (finance / corp dev / capital)" },
|
|
29
|
-
{ value: "compliance-
|
|
37
|
+
{ value: "compliance-officer", label: "Compliance officer (legal / risk / regulatory)" },
|
|
30
38
|
];
|
|
31
|
-
|
|
39
|
+
// `founder` carries its own ALTITUDE_CADENCES entry (runway review + investor
|
|
40
|
+
// update) in lib/cadences.mjs but was unreachable from the wizard.
|
|
41
|
+
export const ALTITUDES = [
|
|
42
|
+
{ value: "founder", label: "Founder" },
|
|
32
43
|
{ value: "c-suite", label: "C-suite" },
|
|
33
44
|
{ value: "svp", label: "SVP" },
|
|
34
45
|
{ value: "vp", label: "VP" },
|