@cohortapp/agent-sdk 2.3.1 → 2.4.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 (162) hide show
  1. package/bin/maestro.mjs +37 -50
  2. package/framework-features.json +30 -0
  3. package/lib/backlog.mjs +136 -0
  4. package/lib/cadences.mjs +63 -2
  5. package/lib/cadences.test.mjs +105 -0
  6. package/lib/capability/inventory.mjs +542 -0
  7. package/lib/capability/inventory.test.mjs +232 -0
  8. package/lib/capability/probe.mjs +255 -0
  9. package/lib/channels/contract.mjs +37 -1
  10. package/lib/channels/contract.test.mjs +25 -1
  11. package/lib/channels/inbox-item.mjs +20 -0
  12. package/lib/claude-bin.mjs +37 -3
  13. package/lib/claude-bin.test.mjs +42 -8
  14. package/lib/execution/disposition.mjs +501 -0
  15. package/lib/execution/disposition.test.mjs +482 -0
  16. package/lib/execution/drive.mjs +352 -0
  17. package/lib/execution/drive.test.mjs +270 -0
  18. package/lib/execution/effects.mjs +340 -0
  19. package/lib/execution/effects.test.mjs +193 -0
  20. package/lib/execution/index.mjs +152 -0
  21. package/lib/execution/intake.mjs +581 -0
  22. package/lib/execution/intake.test.mjs +343 -0
  23. package/lib/execution/journal.mjs +374 -0
  24. package/lib/execution/journal.test.mjs +261 -0
  25. package/lib/execution/match.mjs +331 -0
  26. package/lib/execution/match.test.mjs +235 -0
  27. package/lib/execution/pipeline.mjs +341 -0
  28. package/lib/execution/pipeline.test.mjs +389 -0
  29. package/lib/execution/route.mjs +332 -0
  30. package/lib/execution/route.test.mjs +186 -0
  31. package/lib/execution/surface-policy.mjs +446 -0
  32. package/lib/execution/surface-policy.test.mjs +162 -0
  33. package/lib/goals/admission.mjs +209 -0
  34. package/lib/goals/admission.test.mjs +139 -0
  35. package/lib/goals/classify.mjs +206 -0
  36. package/lib/goals/classify.test.mjs +109 -0
  37. package/lib/goals/collaborate.mjs +415 -0
  38. package/lib/goals/collaborate.test.mjs +324 -0
  39. package/lib/goals/gaps.mjs +111 -0
  40. package/lib/goals/gaps.test.mjs +284 -0
  41. package/lib/goals/loop.mjs +537 -0
  42. package/lib/goals/loop.test.mjs +719 -0
  43. package/lib/identity/persona.mjs +247 -0
  44. package/lib/identity/persona.test.mjs +117 -0
  45. package/lib/kpi.mjs +469 -0
  46. package/lib/kpi.test.mjs +244 -0
  47. package/lib/mandate/audit.mjs +168 -0
  48. package/lib/mandate/audit.test.mjs +195 -0
  49. package/lib/mandate/cache.mjs +162 -0
  50. package/lib/mandate/derive.mjs +317 -0
  51. package/lib/mandate/derive.test.mjs +224 -0
  52. package/lib/mandate/model.mjs +352 -0
  53. package/lib/mandate/model.test.mjs +145 -0
  54. package/lib/mandate/refresh.mjs +187 -0
  55. package/lib/mandate/refresh.test.mjs +293 -0
  56. package/lib/mcp/server.test.mjs +4 -4
  57. package/lib/org/approvals.mjs +14 -2
  58. package/lib/org/client.mjs +79 -25
  59. package/lib/org/client.test.mjs +54 -1
  60. package/lib/org/doctor.mjs +64 -0
  61. package/lib/org/doctor.test.mjs +31 -2
  62. package/lib/org/inbound/directedness.mjs +720 -0
  63. package/lib/org/inbound/directedness.test.mjs +543 -0
  64. package/lib/org/inbound/facts.mjs +501 -0
  65. package/lib/org/inbound/facts.test.mjs +375 -0
  66. package/lib/org/inbound/hydrate.mjs +535 -0
  67. package/lib/org/inbound/hydrate.test.mjs +326 -0
  68. package/lib/org/inbound/index.mjs +233 -0
  69. package/lib/org/inbound/index.test.mjs +324 -0
  70. package/lib/org/inbound/io.mjs +141 -0
  71. package/lib/org/inbound/project.mjs +201 -0
  72. package/lib/org/inbound/project.test.mjs +287 -0
  73. package/lib/org/inbound/surfaces.mjs +257 -0
  74. package/lib/org/knowledge.mjs +10 -1
  75. package/lib/org/knowledge.test.mjs +8 -1
  76. package/lib/org/leases.mjs +5 -0
  77. package/lib/org/mesh.mjs +45 -2
  78. package/lib/org/mesh.test.mjs +55 -0
  79. package/lib/org/messaging.mjs +180 -15
  80. package/lib/org/messaging.test.mjs +117 -0
  81. package/lib/org/param-contract.mjs +694 -0
  82. package/lib/org/param-contract.test.mjs +451 -0
  83. package/lib/org/protocol.checksum +1 -1
  84. package/lib/org/protocol.mjs +8 -0
  85. package/lib/org/protocol.test.mjs +5 -1
  86. package/lib/org/push.mjs +1025 -0
  87. package/lib/org/push.test.mjs +690 -0
  88. package/lib/org/tool-surface.mjs +138 -38
  89. package/lib/org/tool-surface.test.mjs +13 -8
  90. package/lib/org/typing.mjs +341 -0
  91. package/lib/org/typing.test.mjs +291 -0
  92. package/lib/plan/compile.mjs +510 -0
  93. package/lib/plan/compile.test.mjs +286 -0
  94. package/lib/plan/emit.mjs +256 -0
  95. package/lib/plan/emit.test.mjs +246 -0
  96. package/lib/plan/explain.mjs +226 -0
  97. package/lib/plan/explain.test.mjs +188 -0
  98. package/lib/plan/schema.mjs +140 -0
  99. package/lib/resource-governor.mjs +47 -1
  100. package/lib/resource-governor.test.mjs +21 -1
  101. package/lib/setup/enroll-from-cohort.mjs +84 -16
  102. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  103. package/lib/setup/sections/identity.mjs +15 -4
  104. package/lib/setup/sections/identity.test.mjs +94 -0
  105. package/lib/setup/sections/inventory.mjs +178 -0
  106. package/lib/setup/sections/inventory.test.mjs +198 -0
  107. package/lib/setup/sections/mandate.mjs +392 -0
  108. package/lib/setup/sections/mandate.test.mjs +373 -0
  109. package/lib/setup/sections/subagents.mjs +427 -0
  110. package/lib/setup/sections/subagents.test.mjs +429 -0
  111. package/lib/setup/sections/verify.mjs +121 -0
  112. package/lib/setup/sections/verify.test.mjs +175 -0
  113. package/lib/setup/sot.mjs +2 -0
  114. package/lib/subagents/cli.mjs +463 -0
  115. package/lib/subagents/cli.test.mjs +389 -0
  116. package/lib/subagents/client.mjs +373 -0
  117. package/lib/subagents/client.test.mjs +309 -0
  118. package/lib/subagents/gap.mjs +268 -0
  119. package/lib/subagents/gap.test.mjs +234 -0
  120. package/lib/subagents/lock.mjs +296 -0
  121. package/lib/subagents/lock.test.mjs +248 -0
  122. package/lib/subagents/manifest.mjs +224 -0
  123. package/lib/subagents/manifest.test.mjs +175 -0
  124. package/lib/subagents/refs.mjs +274 -0
  125. package/lib/subagents/refs.test.mjs +204 -0
  126. package/lib/subagents/resolve.mjs +455 -0
  127. package/lib/subagents/resolve.test.mjs +422 -0
  128. package/lib/subagents/schema.mjs +467 -0
  129. package/lib/subagents/schema.test.mjs +306 -0
  130. package/package.json +9 -4
  131. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  132. package/policies/ai-disclosure.yaml +42 -2
  133. package/scaffold/CLAUDE.md +16 -2
  134. package/schedules/triggers/goal-steward.md +79 -0
  135. package/scripts/ci/conformance-org-api.mjs +792 -0
  136. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  137. package/scripts/daemon/agent-daemon.mjs +70 -11
  138. package/scripts/daemon/cadence-handlers.mjs +187 -5
  139. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  140. package/scripts/daemon/inbox-deferral.mjs +45 -2
  141. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  142. package/scripts/daemon/inbox-wake.mjs +282 -0
  143. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  144. package/scripts/daemon/maestro-daemon.mjs +23 -0
  145. package/scripts/daemon/prompt-builder.mjs +41 -1
  146. package/scripts/daemon/responder.mjs +56 -0
  147. package/scripts/daemon/typing-registry.mjs +55 -2
  148. package/scripts/daemon/typing-registry.test.mjs +25 -0
  149. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  150. package/scripts/poller/inbox-scan-poller.mjs +26 -1
  151. package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
  152. package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
  153. package/scripts/poller/slack-poller.mjs +32 -0
  154. package/scripts/poller/slack-socket-mode.mjs +27 -1
  155. package/scripts/poller/slack-socket-mode.test.mjs +52 -0
  156. package/scripts/poller/utils.mjs +47 -0
  157. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  158. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  159. package/scripts/setup/generate-plan.mjs +108 -0
  160. package/scripts/setup/init-capability-manifest.mjs +70 -0
  161. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  162. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * compile.test.mjs — the plan compiler + obligation schema (SPEC §5.6).
3
+ * Run: node --test lib/plan/compile.test.mjs
4
+ *
5
+ * The compiler is PURE, so these tests are pure too: fixture mandate, fixture
6
+ * manifest, fixture cadence list. No fs, no network, no clock.
7
+ */
8
+ "use strict";
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import { compilePlan, solveSlot, busyWindows, allowedToolsFor, actionClassesFor, toolNameFor, STANDARD_REACTS, COMPILER_VERSION } from "./compile.mjs";
14
+ import { validatePlan, validateObligation } from "./schema.mjs";
15
+ import { deriveMandate, toMandateBody, pickSensor, slug } from "../mandate/derive.mjs";
16
+
17
+ /** A small, explicit capability manifest. */
18
+ function manifest(ids = ["board_ready", "task_create", "task_update", "board_claim", "board_complete", "crm_list_deals", "messaging_send", "knowledge_search", "email_inbox", "email_triage", "messaging_history"], unreachable = []) {
19
+ return {
20
+ schemaVersion: 1,
21
+ checksum: "fixture",
22
+ entries: [
23
+ ...ids.map((id) => ({ id, plane: "org", kind: "method_tool", reachable: true, blastRadius: id === "messaging_send" ? "external" : id.endsWith("_create") ? "irreversible" : "internal" })),
24
+ ...unreachable.map((id) => ({ id, plane: "org", kind: "method_tool", reachable: false, blastRadius: "internal" })),
25
+ { id: "builtin:Read", plane: "builtin", kind: "tool", reachable: true, blastRadius: "internal" },
26
+ ],
27
+ };
28
+ }
29
+
30
+ const STANDARD = [
31
+ { id: "inbox-processor", scope: "standard", mode: "guarded", interval: 300, prompt: "schedules/triggers/inbox-processor.md" },
32
+ { id: "daily-morning-brief", scope: "standard", mode: "escalate", calendar: { hour: 6, minute: 30 }, prompt: "schedules/triggers/daily-morning-brief.md" },
33
+ { id: "nightly-cost-reconcile", scope: "standard", mode: "inline", calendar: { hour: 3, minute: 30 } },
34
+ ];
35
+
36
+ const ARCHETYPE = [
37
+ { id: "weekly-operating-review", scope: "function", mode: "escalate", cadence: "weekly", calendar: { weekday: 2, hour: 10, minute: 0 }, prompt: "schedules/triggers/weekly-operating-review.md", purpose: "Run the leadership operating review" },
38
+ ];
39
+
40
+ function objective(over = {}) {
41
+ return {
42
+ key: "pipeline-coverage", kind: "OBJECTIVE", text: "Pipeline coverage", state: "active",
43
+ metric: "pipeline_coverage_x", unit: "x", direction: "up", target: 3, tolerance: 0.2,
44
+ weight: 1, cadence: "weekly", sensor: { capability: "crm_list_deals", params: { stage: "open" }, source: "method" },
45
+ collaborators: [], id: "obj_7Kx", charterSectionId: "cs_12", ...over,
46
+ };
47
+ }
48
+
49
+ test("standard cadences compile to SCHEDULE obligations with VERBATIM triggers", () => {
50
+ const p = compilePlan({ mandate: {}, manifest: manifest(), standardCadences: STANDARD });
51
+ const inbox = p.obligations.find((o) => o.key === "schedule.inbox-processor");
52
+ assert.equal(inbox.kind, "SCHEDULE");
53
+ assert.deepEqual(inbox.schedule, { interval: 300 });
54
+ assert.equal(inbox.source.origin, "standard");
55
+ assert.equal(inbox.scope, "standard");
56
+ const brief = p.obligations.find((o) => o.key === "schedule.daily-morning-brief");
57
+ assert.deepEqual(brief.schedule, { calendar: { hour: 6, minute: 30 } }, "trigger copied byte-for-byte so plists do not churn");
58
+ });
59
+
60
+ test("PROPOSED objectives compile NOTHING — the schedule only grows on human adoption", () => {
61
+ const proposed = { objectives: [objective({ state: "proposed" })] };
62
+ const p = compilePlan({ mandate: proposed, manifest: manifest(), standardCadences: STANDARD });
63
+ assert.equal(p.counts.OUTCOME, 0);
64
+ assert.equal(p.counts.proposedObjectives, 1);
65
+ assert.ok(!p.obligations.some((o) => o.key.startsWith("schedule.measure.")));
66
+ });
67
+
68
+ test("an ADOPTED objective compiles measure + outcome, provenance-linked to the objective and charter clause", () => {
69
+ const p = compilePlan({ mandate: { objectives: [objective()] }, manifest: manifest(), standardCadences: STANDARD });
70
+ const measure = p.obligations.find((o) => o.key === "schedule.measure.pipeline-coverage");
71
+ const outcome = p.obligations.find((o) => o.key === "outcome.pipeline-coverage");
72
+ assert.ok(measure && outcome);
73
+ assert.equal(measure.cadence_id, "measure-pipeline-coverage");
74
+ assert.equal(outcome.objective_id, "obj_7Kx");
75
+ assert.equal(outcome.source.charter_section, "cs_12");
76
+ assert.equal(outcome.sensor.capability, "crm_list_deals");
77
+ assert.equal(outcome.target, 3);
78
+ // This is what makes it "not a generic cron list":
79
+ assert.equal(measure.source.origin, "mandate");
80
+ assert.deepEqual(measure.uses, ["crm_list_deals"]);
81
+ });
82
+
83
+ test("a REVIEWER collaborator compiles a review cadence", () => {
84
+ const obj = objective({ collaborators: [{ member_id: "mem_A03", role: "REVIEWER" }] });
85
+ const p = compilePlan({ mandate: { objectives: [obj] }, manifest: manifest(), standardCadences: [] });
86
+ const review = p.obligations.find((o) => o.key === "schedule.review.pipeline-coverage");
87
+ assert.ok(review, "review obligation emitted");
88
+ assert.equal(review.cadence_id, "review-pipeline-coverage");
89
+ assert.equal(review.mode, "escalate");
90
+ });
91
+
92
+ test("an objective whose sensor is UNREACHABLE emits no OUTCOME and opens drift", () => {
93
+ const p = compilePlan({ mandate: { objectives: [objective()] }, manifest: manifest(["board_ready"], ["crm_list_deals"]), standardCadences: [] });
94
+ assert.equal(p.counts.OUTCOME, 0);
95
+ const d = p.drift.find((x) => x.kind === "unreachable_capability");
96
+ assert.ok(d, `expected unreachable_capability drift, got ${JSON.stringify(p.drift)}`);
97
+ assert.equal(d.detail.sensor, "crm_list_deals");
98
+ });
99
+
100
+ test("an objective with a HUMAN sensor emits no OUTCOME and opens stale_sensor drift", () => {
101
+ const obj = objective({ sensor: { capability: null, params: {}, source: "human" } });
102
+ const p = compilePlan({ mandate: { objectives: [obj] }, manifest: manifest(), standardCadences: [] });
103
+ assert.equal(p.counts.OUTCOME, 0);
104
+ assert.ok(p.drift.some((d) => d.kind === "stale_sensor"));
105
+ });
106
+
107
+ test("uses[] is filtered against the manifest and every drop is recorded as drift", () => {
108
+ const p = compilePlan({ mandate: {}, manifest: manifest(["email_inbox"]), standardCadences: STANDARD });
109
+ const inbox = p.obligations.find((o) => o.key === "schedule.inbox-processor");
110
+ assert.deepEqual(inbox.uses, ["email_inbox"], "unreachable capabilities are dropped, not cited");
111
+ assert.ok(p.drift.filter((d) => d.kind === "unreachable_capability" && d.key === "schedule.inbox-processor").length >= 2);
112
+ });
113
+
114
+ test("allowed_tools is COMPUTED from uses[] — a session-spawning cadence can never be unscoped", () => {
115
+ const p = compilePlan({ mandate: {}, manifest: manifest(), standardCadences: STANDARD, archetypeCadences: ARCHETYPE });
116
+ for (const ob of p.obligations.filter((o) => o.kind === "SCHEDULE" && o.mode !== "inline")) {
117
+ assert.ok(ob.allowed_tools.length > 0, `${ob.key} must carry a tool scope`);
118
+ assert.ok(ob.allowed_tools.includes("Read"), "baseline read primitives present");
119
+ }
120
+ const inline = p.obligations.find((o) => o.key === "schedule.nightly-cost-reconcile");
121
+ assert.deepEqual(inline.allowed_tools, [], "an inline cadence spawns no session and needs no scope");
122
+ });
123
+
124
+ test("action_classes are derived from the manifest's blast radii", () => {
125
+ assert.deepEqual(actionClassesFor(["messaging_send", "board_ready"], manifest()), ["external"]);
126
+ assert.deepEqual(actionClassesFor(["board_ready"], manifest()), []);
127
+ });
128
+
129
+ test("toolNameFor maps every plane onto a session tool name", () => {
130
+ assert.equal(toolNameFor("builtin:Read"), "Read");
131
+ assert.equal(toolNameFor("skill:agent-skills:morning-brief"), "Skill");
132
+ assert.equal(toolNameFor("workflow:weekly-operating-review"), "Task");
133
+ assert.equal(toolNameFor("mcp:notion"), "mcp__notion__*");
134
+ assert.equal(toolNameFor("board_ready"), "board_ready");
135
+ assert.deepEqual(allowedToolsFor(["board_ready", "builtin:Read"]), ["Glob", "Grep", "LS", "Read", "board_ready"]);
136
+ });
137
+
138
+ test("the four standard REACT obligations always compile", () => {
139
+ const p = compilePlan({ mandate: {}, manifest: manifest(["email_message", "email_thread", "email_send", "board_ready", "board_claim", "task_update", "approval_wait", "decision_list"]), standardCadences: [] });
140
+ for (const r of STANDARD_REACTS) assert.ok(p.obligations.some((o) => o.key === r.key), `${r.key} missing`);
141
+ assert.equal(p.counts.REACT, STANDARD_REACTS.length);
142
+ });
143
+
144
+ test("mandate reactsTo[] adds REACT obligations bound to an objective", () => {
145
+ const mandate = { objectives: [], reactsTo: [{ key: "react.mandate.deal-stalled", topic: "task", kind: "stalled", uses: ["crm_list_deals"], objectiveId: "obj_7Kx", offlineSafe: true }] };
146
+ const p = compilePlan({ mandate, manifest: manifest(), standardCadences: [] });
147
+ const r = p.obligations.find((o) => o.key === "react.mandate.deal-stalled");
148
+ assert.equal(r.kind, "REACT");
149
+ assert.equal(r.objective_id, "obj_7Kx");
150
+ assert.equal(r.offline_safe, true);
151
+ assert.equal(r.source.origin, "mandate");
152
+ });
153
+
154
+ test("DETERMINISM: identical inputs → identical obligationsHash; a changed mandate changes it", () => {
155
+ const a = compilePlan({ mandate: { objectives: [objective()] }, manifest: manifest(), standardCadences: STANDARD, archetypeCadences: ARCHETYPE });
156
+ const b = compilePlan({ mandate: { objectives: [objective()] }, manifest: manifest(), standardCadences: STANDARD, archetypeCadences: ARCHETYPE });
157
+ assert.equal(a.obligationsHash, b.obligationsHash);
158
+ assert.equal(a.inputsHash, b.inputsHash);
159
+ const c = compilePlan({ mandate: { objectives: [objective({ target: 4 })] }, manifest: manifest(), standardCadences: STANDARD, archetypeCadences: ARCHETYPE });
160
+ assert.notEqual(a.inputsHash, c.inputsHash, "a different mandate is a different plan input");
161
+ });
162
+
163
+ test("DETERMINISM: objective input ORDER does not change the emitted schedule", () => {
164
+ const o1 = objective();
165
+ const o2 = objective({ key: "decision-latency", id: "obj_2", metric: "decision_days", cadence: "monthly", weight: 1 });
166
+ const a = compilePlan({ mandate: { objectives: [o1, o2] }, manifest: manifest(), standardCadences: [] });
167
+ const b = compilePlan({ mandate: { objectives: [o2, o1] }, manifest: manifest(), standardCadences: [] });
168
+ assert.equal(a.obligationsHash, b.obligationsHash);
169
+ });
170
+
171
+ test("slot solver: deterministic, avoids the principal's busy hours, respects the concurrency cap", () => {
172
+ const busy = busyWindows([{ weekday: 1, hour: 9 }, { weekday: 1, hour: 10 }]);
173
+ const state = { busy, taken: new Map(), concurrency: 1 };
174
+ const a = solveSlot("schedule.measure.alpha", "weekly", state);
175
+ const b = solveSlot("schedule.measure.beta", "weekly", state);
176
+ assert.notDeepEqual(a.calendar, b.calendar, "the cap forces distinct slots");
177
+ for (const s of [a, b]) assert.ok(!busy.has(`${s.calendar.weekday}:${s.calendar.hour}`), "never lands in a busy window");
178
+ // Same key, fresh state → same slot, forever.
179
+ const again = solveSlot("schedule.measure.alpha", "weekly", { busy, taken: new Map(), concurrency: 1 });
180
+ assert.deepEqual(again.calendar, a.calendar);
181
+ });
182
+
183
+ test("budget envelope: objectives past the envelope are SUSPENDED with a breach record, never dropped", () => {
184
+ const objs = [
185
+ objective({ key: "a", id: "a", weight: 3 }),
186
+ objective({ key: "b", id: "b", weight: 2 }),
187
+ objective({ key: "c", id: "c", weight: 1 }),
188
+ ];
189
+ const p = compilePlan({ mandate: { objectives: objs, budgetCentsPerPeriod: 500 }, manifest: manifest(), standardCadences: [], seatBudgetCents: 1000 });
190
+ const suspended = p.obligations.filter((o) => o.status === "suspended");
191
+ assert.ok(suspended.length > 0, "the third objective exceeds the envelope");
192
+ assert.ok(p.drift.some((d) => d.kind === "budget_breach"));
193
+ assert.equal(p.obligations.filter((o) => o.kind === "OUTCOME").length, 3, "all three still exist — suspended, not dropped");
194
+ });
195
+
196
+ test("validatePlan is FAIL-CLOSED on an unreachable citation and on an llm-sourced OUTCOME", () => {
197
+ const reachable = new Set(["board_ready"]);
198
+ const bad = {
199
+ obligations: [{
200
+ key: "outcome.x", kind: "OUTCOME", source: { origin: "mandate", ref: "x" }, status: "active",
201
+ uses: ["ghost_tool"], allowed_tools: ["Read"], action_classes: [], offline_safe: true,
202
+ objective_id: "obj", sensor: { source: "llm", capability: null },
203
+ }],
204
+ };
205
+ const v = validatePlan(bad, { reachable });
206
+ assert.equal(v.ok, false);
207
+ assert.ok(v.errors.some((e) => /not reachable:true/.test(e)));
208
+ assert.ok(v.errors.some((e) => /"llm" cannot back an OUTCOME/.test(e)));
209
+ });
210
+
211
+ test("validateObligation rejects a session-spawning SCHEDULE with no tool scope", () => {
212
+ const errs = validateObligation({
213
+ key: "schedule.x", kind: "SCHEDULE", source: { origin: "archetype", ref: "x" }, status: "active",
214
+ mode: "escalate", schedule: { interval: 600 }, uses: [], allowed_tools: [], action_classes: [], offline_safe: false,
215
+ });
216
+ assert.ok(errs.some((e) => /non-empty allowed_tools/.test(e)));
217
+ });
218
+
219
+ test("a full compiled plan validates against the manifest", () => {
220
+ const m = manifest();
221
+ const p = compilePlan({ mandate: { objectives: [objective()] }, manifest: m, standardCadences: STANDARD, archetypeCadences: ARCHETYPE });
222
+ const v = validatePlan(p, { manifest: m });
223
+ assert.equal(v.ok, true, v.errors.join("; "));
224
+ assert.equal(p.compilerVersion, COMPILER_VERSION);
225
+ });
226
+
227
+ // ── derivation (the "per-agent and justified, not a static list" property) ───
228
+
229
+ test("deriveMandate: charter pillars become PILLARs, KPI categories become OBJECTIVEs parented to them", () => {
230
+ const d = deriveMandate({
231
+ charterSections: [
232
+ { kind: "PILLAR", order: 0, body: "1) Own the agent-platform substrate — runtime, memory, tools." },
233
+ { kind: "PILLAR", order: 1, body: "2) Make the fleet governable: every capability observable." },
234
+ ],
235
+ kpiCategories: [{ id: "execution", name: "Execution & Follow-Through", examples: ["Commitment-closure rate"] }],
236
+ manifest: manifest(),
237
+ });
238
+ assert.equal(d.counts.pillars, 2);
239
+ assert.equal(d.counts.objectives, 1);
240
+ const exec = d.objectives.find((o) => o.key === "execution");
241
+ assert.equal(exec.kind, "OBJECTIVE");
242
+ assert.ok(exec.parentKey, "every objective hangs off a pillar (provenance)");
243
+ assert.equal(exec.state, "proposed", "NOTHING is adopted by the agent");
244
+ assert.equal(exec.sensor.source, "method");
245
+ assert.equal(exec.sensor.capability, "board_ready", "bound to a REACHABLE capability");
246
+ });
247
+
248
+ test("deriveMandate: two different agents (different charters/KPIs) get different mandates", () => {
249
+ const a = deriveMandate({ charterSections: [{ kind: "PILLAR", order: 0, body: "Own revenue growth" }], kpiCategories: [{ id: "pipeline", name: "Pipeline coverage" }], manifest: manifest() });
250
+ const b = deriveMandate({ charterSections: [{ kind: "PILLAR", order: 0, body: "Own compliance posture" }], kpiCategories: [{ id: "audit", name: "Audit findings closed" }], manifest: manifest() });
251
+ assert.notDeepEqual(a.objectives.map((o) => o.key), b.objectives.map((o) => o.key));
252
+ });
253
+
254
+ test("deriveMandate: a metric with no REACHABLE sensor is proposed source:human AND flagged", () => {
255
+ const d = deriveMandate({ kpiCategories: [{ id: "pipeline", name: "Pipeline coverage", examples: ["Pipeline coverage x"] }], manifest: manifest(["board_ready"]) });
256
+ const o = d.objectives[0];
257
+ assert.equal(o.sensor.source, "human");
258
+ assert.ok(d.degradations.some((x) => x.kind === "no_reachable_sensor"));
259
+ });
260
+
261
+ test("deriveMandate is deterministic and slug keys are stable", () => {
262
+ const input = { charterSections: [{ kind: "PILLAR", order: 0, body: "3) Scale the platform with the roster, not after it." }], kpiCategories: [], manifest: manifest() };
263
+ assert.deepEqual(deriveMandate(input).objectives, deriveMandate(input).objectives);
264
+ assert.equal(slug("3) Scale the platform with the roster, not after it."), "scale-the-platform-with-the-roster-not-after-it");
265
+ });
266
+
267
+ test("pickSensor prefers the most specific reachable capability", () => {
268
+ assert.equal(pickSensor("weekly pipeline coverage", new Set(["crm_list_deals"])).capability, "crm_list_deals");
269
+ assert.equal(pickSensor("decision cycle time", new Set(["decision_list"])).capability, "decision_list");
270
+ assert.equal(pickSensor("something unmodelled", new Set(["board_ready"])).source, "human");
271
+ });
272
+
273
+ test("end to end: derive → compile produces a plan whose obligations trace back to the charter", () => {
274
+ const d = deriveMandate({
275
+ charterSections: [{ kind: "PILLAR", order: 0, id: "cs_1", body: "Own execution follow-through" }],
276
+ kpiCategories: [{ id: "execution", name: "Execution & Follow-Through", examples: ["Commitment-closure rate"] }],
277
+ manifest: manifest(),
278
+ });
279
+ // Simulate the human adoption step in hq.
280
+ const adopted = d.objectives.map((o) => (o.kind === "OBJECTIVE" ? { ...o, state: "active", target: 0.95, id: `obj_${o.key}` } : o));
281
+ const p = compilePlan({ mandate: toMandateBody({ objectives: adopted }), manifest: manifest(), standardCadences: STANDARD });
282
+ const outcome = p.obligations.find((o) => o.kind === "OUTCOME");
283
+ assert.ok(outcome, "adoption turns the proposal into a real obligation");
284
+ assert.equal(outcome.target, 0.95);
285
+ assert.equal(validatePlan(p, { manifest: manifest() }).ok, true);
286
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * lib/plan/emit.mjs — turn compiled obligations into the files the runtime
3
+ * already reads (SPEC §5.7).
4
+ *
5
+ * The whole point of this design is that it grows NO second scheduling
6
+ * mechanism. The compiler produces obligations; this module projects them onto
7
+ * the four artefacts that already exist and are already consumed:
8
+ *
9
+ * config/plan.yaml ← NEW, and TRACKED: the reviewable obligation
10
+ * set. A schedule change shows up in a PR diff.
11
+ * config/cadences.yaml ← unchanged format, new producer
12
+ * (read by cadence-handlers to route ticks)
13
+ * config/.cadence-plists.tsv ← unchanged format, new producer
14
+ * (read verbatim by generate-plists.sh, which
15
+ * needs NO edit to emit real launchd jobs)
16
+ * config/.cadence-registry.json ← EXTENDED shape
17
+ * {mode, prompt, allowedTools, guardModule,
18
+ * budgetCents, obligationKey}
19
+ * state/plan/compile.lock ← the hashes verify() checks
20
+ *
21
+ * FAIL-CLOSED: the plan is validated before a single byte is written. A plan
22
+ * that cites an unreachable capability, or a session-spawning cadence with no
23
+ * tool scope, is not written at all — a half-written schedule is how a launchd
24
+ * job silently goes missing.
25
+ *
26
+ * NON-DESTRUCTIVE by construction: standard cadences carry their triggers
27
+ * verbatim from `STANDARD_CADENCES` and are excluded from the TSV exactly as the
28
+ * old generator excluded them (generate-plists.sh emits the standard set
29
+ * itself), so re-emitting an unchanged plan produces byte-identical files and no
30
+ * plist churns.
31
+ *
32
+ * @module lib/plan/emit
33
+ */
34
+
35
+ "use strict";
36
+
37
+ import { writeFileSync, mkdirSync, renameSync, existsSync, readFileSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { validatePlan } from "./schema.mjs";
40
+
41
+ /** Relative paths this module owns. */
42
+ export const PLAN_REL = join("config", "plan.yaml");
43
+ export const CADENCES_REL = join("config", "cadences.yaml");
44
+ export const REGISTRY_REL = join("config", ".cadence-registry.json");
45
+ export const TSV_REL = join("config", ".cadence-plists.tsv");
46
+ export const LOCK_REL = join("state", "plan", "compile.lock");
47
+
48
+ /**
49
+ * Build the StartCalendarInterval dict-content XML `generate_trigger_plist`
50
+ * expects. Byte-for-byte the same shape generate-cadences.mjs emitted, so an
51
+ * unchanged cadence yields an unchanged base64 blob and an unchanged plist.
52
+ */
53
+ export function scheduleXml(cal) {
54
+ let x = "";
55
+ if (cal.weekday != null) x += ` <key>Weekday</key>\n <integer>${cal.weekday}</integer>\n`;
56
+ if (cal.day != null) x += ` <key>Day</key>\n <integer>${cal.day}</integer>\n`;
57
+ x += ` <key>Hour</key>\n <integer>${cal.hour ?? 9}</integer>\n <key>Minute</key>\n <integer>${cal.minute ?? 0}</integer>`;
58
+ return x;
59
+ }
60
+
61
+ /** Project SCHEDULE obligations onto the legacy cadences.yaml entry shape. */
62
+ export function toCadenceEntries(obligations) {
63
+ const out = [];
64
+ for (const ob of obligations || []) {
65
+ if (ob.kind !== "SCHEDULE" || ob.status !== "active") continue;
66
+ const entry = {
67
+ id: ob.cadence_id,
68
+ scope: ob.scope || "mandate",
69
+ mode: ob.mode || "escalate",
70
+ prompt: ob.prompt || `schedules/triggers/${ob.cadence_id}.md`,
71
+ };
72
+ if (ob.schedule && Number.isFinite(ob.schedule.interval)) entry.interval = ob.schedule.interval;
73
+ else if (ob.schedule && ob.schedule.calendar) entry.calendar = ob.schedule.calendar;
74
+ if (ob.purpose) entry.purpose = ob.purpose;
75
+ if (ob.cadence) entry.cadence = ob.cadence;
76
+ entry.obligationKey = ob.key;
77
+ out.push(entry);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * The EXTENDED cadence registry (SPEC §4): a plan-derived cadence gets a guard
84
+ * and a tool scope by construction, so `sessionPermissionArgs()` can scope the
85
+ * spawn and the consumer can meter its budget.
86
+ */
87
+ export function toRegistry(obligations) {
88
+ const reg = {};
89
+ for (const ob of obligations || []) {
90
+ if (ob.kind !== "SCHEDULE" || ob.status !== "active") continue;
91
+ reg[ob.cadence_id] = {
92
+ mode: ob.mode || "escalate",
93
+ prompt: ob.prompt || `schedules/triggers/${ob.cadence_id}.md`,
94
+ allowedTools: Array.isArray(ob.allowed_tools) ? ob.allowed_tools : [],
95
+ guardModule: ob.guard_module || null,
96
+ budgetCents: Number.isFinite(ob.budget_cents_per_period) ? ob.budget_cents_per_period : null,
97
+ obligationKey: ob.key,
98
+ };
99
+ }
100
+ return reg;
101
+ }
102
+
103
+ /**
104
+ * The launchd TSV. Standard-scope cadences are EXCLUDED (generate-plists.sh
105
+ * emits those itself) — identical to the old generator's rule.
106
+ * Pipe-delimited: `<id>|<interval>|<base64(calendar xml)>`.
107
+ */
108
+ export function toTsvLines(obligations) {
109
+ const lines = [];
110
+ for (const ob of obligations || []) {
111
+ if (ob.kind !== "SCHEDULE" || ob.status !== "active") continue;
112
+ if (ob.scope === "standard") continue;
113
+ if (ob.schedule && Number.isFinite(ob.schedule.interval)) lines.push(`${ob.cadence_id}|${ob.schedule.interval}|`);
114
+ else if (ob.schedule && ob.schedule.calendar) lines.push(`${ob.cadence_id}||${Buffer.from(scheduleXml(ob.schedule.calendar), "utf-8").toString("base64")}`);
115
+ }
116
+ return lines;
117
+ }
118
+
119
+ /** The obligation projection written to config/plan.yaml (SPEC §5.6 shape). */
120
+ export function toPlanDoc(plan, o = {}) {
121
+ return {
122
+ schemaVersion: 1,
123
+ compilerVersion: plan.compilerVersion,
124
+ mandateVersion: plan.mandateVersion,
125
+ enforcement: o.enforcement || "observe-only",
126
+ archetype: o.archetype || null,
127
+ generatedAt: o.at || null,
128
+ inputsHash: plan.inputsHash,
129
+ obligationsHash: plan.obligationsHash,
130
+ counts: plan.counts,
131
+ obligations: (plan.obligations || []).map((ob) => {
132
+ const y = {
133
+ key: ob.key,
134
+ kind: ob.kind,
135
+ source: ob.source,
136
+ status: ob.status,
137
+ uses: ob.uses || [],
138
+ allowed_tools: ob.allowed_tools || [],
139
+ action_classes: ob.action_classes || [],
140
+ budget_cents_per_period: ob.budget_cents_per_period,
141
+ offline_safe: ob.offline_safe,
142
+ };
143
+ if (ob.objective_id) y.objective_id = ob.objective_id;
144
+ if (ob.cadence_id) y.cadence_id = ob.cadence_id;
145
+ if (ob.schedule) y.schedule = ob.schedule;
146
+ if (ob.trigger) y.trigger = ob.trigger;
147
+ if (ob.sensor) y.sensor = ob.sensor;
148
+ if (ob.metric) y.metric = ob.metric;
149
+ if (ob.target != null) y.target = ob.target;
150
+ if (ob.direction) y.direction = ob.direction;
151
+ if (ob.tolerance != null) y.tolerance = ob.tolerance;
152
+ if (ob.cadence) y.cadence = ob.cadence;
153
+ if (ob.mode) y.mode = ob.mode;
154
+ if (ob.prompt) y.prompt = ob.prompt;
155
+ if (ob.guard_module) y.guard_module = ob.guard_module;
156
+ if (ob.collaborators && ob.collaborators.length) y.collaborators = ob.collaborators;
157
+ if (ob.purpose) y.purpose = ob.purpose;
158
+ return y;
159
+ }),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Write every artefact. Fail-closed on validation.
165
+ *
166
+ * @param {object} o - { agentRoot, plan, manifest, archetype?, enforcement?, at? }
167
+ * @param {object} [deps] - { writeFileSync, mkdirSync, renameSync, yaml }
168
+ * @returns {Promise<{ok:boolean, errors:string[], files:string[], cadences:object[], registry:object, tsv:string[]}>}
169
+ */
170
+ export async function emitPlan(o = {}, deps = {}) {
171
+ const { agentRoot, plan } = o;
172
+ if (!agentRoot) throw new Error("emitPlan: agentRoot required");
173
+
174
+ const v = validatePlan(plan, { manifest: o.manifest });
175
+ if (!v.ok) {
176
+ // FAIL-CLOSED, and loudly: nothing is written, and the caller gets every reason.
177
+ return { ok: false, errors: v.errors, files: [], cadences: [], registry: {}, tsv: [] };
178
+ }
179
+
180
+ const yaml = deps.yaml || (await loadYaml());
181
+ const wr = deps.writeFileSync || writeFileSync;
182
+ const mk = deps.mkdirSync || mkdirSync;
183
+ const mv = deps.renameSync || renameSync;
184
+ const atomic = (rel, body) => {
185
+ const p = join(agentRoot, rel);
186
+ mk(dirOf(p), { recursive: true });
187
+ const tmp = `${p}.${process.pid}.tmp`;
188
+ wr(tmp, body, "utf-8");
189
+ mv(tmp, p);
190
+ return p;
191
+ };
192
+
193
+ const cadences = toCadenceEntries(plan.obligations);
194
+ const registry = toRegistry(plan.obligations);
195
+ const tsv = toTsvLines(plan.obligations);
196
+ const files = [];
197
+
198
+ // NO-OP GUARD (SPEC §6.4: "if obligationsHash is unchanged, nothing on disk
199
+ // moves"). config/plan.yaml is TRACKED, and its `generatedAt` is a wall clock:
200
+ // without this, every recompile — including the ones the org-pulse cadence
201
+ // fires on an unchanged mandate — rewrites the one artefact whose whole job is
202
+ // to make a schedule change legible in a PR diff. The result was a file that
203
+ // churned constantly and therefore proved nothing.
204
+ //
205
+ // Identity is the two hashes, not the bytes: inputsHash covers the mandate +
206
+ // manifest + profile + org strategy + compiler version, obligationsHash covers
207
+ // the emitted schedule. Equal on both ⇒ genuinely nothing to say.
208
+ if (!o.force) {
209
+ const ex = deps.existsSync || existsSync;
210
+ const prior = readLock(agentRoot, deps);
211
+ const same = prior
212
+ && prior.compilerVersion === plan.compilerVersion
213
+ && prior.inputsHash === plan.inputsHash
214
+ && prior.obligationsHash === plan.obligationsHash;
215
+ const allPresent = [PLAN_REL, CADENCES_REL, REGISTRY_REL, TSV_REL].every((rel) => ex(join(agentRoot, rel)));
216
+ if (same && allPresent) {
217
+ return { ok: true, unchanged: true, errors: [], files: [], cadences, registry, tsv };
218
+ }
219
+ }
220
+
221
+ files.push(atomic(PLAN_REL, yaml.dump(toPlanDoc(plan, o), { lineWidth: 100, noRefs: true, sortKeys: false })));
222
+ files.push(atomic(CADENCES_REL, yaml.dump({ archetype: o.archetype || null, source: "plan-compiler", cadences }, { lineWidth: 100, noRefs: true })));
223
+ files.push(atomic(REGISTRY_REL, JSON.stringify(registry, null, 2) + "\n"));
224
+ files.push(atomic(TSV_REL, tsv.join("\n") + (tsv.length ? "\n" : "")));
225
+ files.push(atomic(LOCK_REL, JSON.stringify({
226
+ compilerVersion: plan.compilerVersion,
227
+ inputsHash: plan.inputsHash,
228
+ obligationsHash: plan.obligationsHash,
229
+ mandateVersion: plan.mandateVersion,
230
+ at: o.at || null,
231
+ }, null, 2) + "\n"));
232
+
233
+ return { ok: true, errors: [], files, cadences, registry, tsv };
234
+ }
235
+
236
+ /** Read state/plan/compile.lock (null when absent/unparseable). */
237
+ export function readLock(agentRoot, deps = {}) {
238
+ const ex = deps.existsSync || existsSync;
239
+ const rd = deps.readFileSync || readFileSync;
240
+ const p = join(agentRoot, LOCK_REL);
241
+ if (!ex(p)) return null;
242
+ try { return JSON.parse(rd(p, "utf-8")); } catch { return null; }
243
+ }
244
+
245
+ /** Directory of a path (avoids importing path.dirname twice over). */
246
+ function dirOf(p) {
247
+ const i = p.lastIndexOf("/");
248
+ return i > 0 ? p.slice(0, i) : ".";
249
+ }
250
+
251
+ async function loadYaml() {
252
+ const m = await import("js-yaml");
253
+ return m && m.default ? m.default : m;
254
+ }
255
+
256
+ export default { emitPlan, toCadenceEntries, toRegistry, toTsvLines, toPlanDoc, scheduleXml, readLock, PLAN_REL, CADENCES_REL, REGISTRY_REL, TSV_REL, LOCK_REL };