@cohortapp/agent-sdk 2.3.2 → 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 (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * gap.test.mjs — the three-way diff (§2.3): missing / unrostered / idle, and the
3
+ * deliberately drop-biased rewire classifier.
4
+ * Run: node --test lib/subagents/gap.test.mjs
5
+ */
6
+ "use strict";
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+
11
+ import {
12
+ REWIRE_THRESHOLD,
13
+ agentCorpus,
14
+ classifyRef,
15
+ computeGaps,
16
+ contextFromConfig,
17
+ rosterFromSurface,
18
+ scoreRef,
19
+ tokenize,
20
+ } from "./gap.mjs";
21
+
22
+ const resolvedOf = (slugs) => ({ entries: slugs.map((s) => ({ slug: s })), bySlug: new Map(slugs.map((s) => [s, { slug: s }])), localEdits: [], emptyDirs: [] });
23
+ const ref = (r, file = "workflows/a.yaml", line = 3, kind = "workflow") => ({ ref: r, file, line, kind });
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // tokenisation / scoring
27
+ // ---------------------------------------------------------------------------
28
+
29
+ test("tokenize: handles kebab, snake, camel and free text; drops stopwords and 1-2 char noise", () => {
30
+ assert.deepEqual(tokenize("regulatory-watch"), ["regulatory", "watch"]);
31
+ assert.deepEqual(tokenize("regulatory_watch"), ["regulatory", "watch"]);
32
+ assert.deepEqual(tokenize("regulatoryWatch"), ["regulatory", "watch"]);
33
+ assert.deepEqual(tokenize("the compliance agent for ops"), ["compliance"]);
34
+ assert.deepEqual(tokenize(null), []);
35
+ });
36
+
37
+ test("scoreRef: reference-normalised — a rich corpus is not penalised for being rich", () => {
38
+ const corpus = new Set(["compliance", "regulatory", "watch", "filings", "audit"]);
39
+ assert.equal(scoreRef("regulatory-watch", corpus), 1);
40
+ assert.equal(scoreRef("regulatory-digest", corpus), 0.5);
41
+ assert.equal(scoreRef("nothing-here", corpus), 0);
42
+ assert.equal(scoreRef("the-a", corpus), 0, "a ref with no signal tokens scores 0, never NaN");
43
+ });
44
+
45
+ test("agentCorpus: contextual skills/pillars only reinforce a corpus they already touch", () => {
46
+ const agent = { id: "compliance-officer", role: "Compliance", purpose: "regulatory filings" };
47
+ const bare = agentCorpus(agent);
48
+ assert.equal(bare.has("regulatory"), true);
49
+ assert.equal(bare.has("sox"), false);
50
+ const reinforced = agentCorpus(agent, { skills: ["regulatory sox reporting"], pillars: [], governance: [] });
51
+ assert.equal(reinforced.has("sox"), true, "shares 'regulatory' → the whole skill joins the corpus");
52
+ const unrelated = agentCorpus(agent, { skills: ["kubernetes cluster tuning"] });
53
+ assert.equal(unrelated.has("kubernetes"), false, "an unrelated skill must not pollute the corpus");
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // the classifier — biased to DROP because the signal is usually empty
58
+ // ---------------------------------------------------------------------------
59
+
60
+ test("classifyRef: proposes a rewire only on real lexical evidence", () => {
61
+ const roster = [{ id: "compliance-officer", role: "Compliance", purpose: "regulatory watch and filings" }];
62
+ const hit = classifyRef("regulatory-watch", { roster });
63
+ assert.equal(hit.action, "rewire");
64
+ assert.equal(hit.suggest, "compliance-officer");
65
+ assert.equal(hit.recommend, "rewire");
66
+ assert.ok(hit.score >= REWIRE_THRESHOLD);
67
+ });
68
+
69
+ test("classifyRef: below the bar it does NOT guess — the verdict is drop-or-create", () => {
70
+ // With title:"" and company:"UNCONFIGURED" common in the wild, a classifier
71
+ // with no signal that still proposes a rewire silently re-points a workflow
72
+ // step at the wrong agent.
73
+ const roster = [{ id: "compliance-officer", role: "Compliance", purpose: "regulatory filings" }];
74
+ const miss = classifyRef("slide-deck-builder", { roster });
75
+ assert.equal(miss.action, "drop-or-create");
76
+ assert.equal(miss.recommend, "drop");
77
+ assert.match(miss.reason, /vestigial step copied by `create`/);
78
+ });
79
+
80
+ test("classifyRef: an EMPTY roster still yields a usable drop verdict", () => {
81
+ const none = classifyRef("ghost-writer", { roster: [] });
82
+ assert.equal(none.action, "drop-or-create");
83
+ assert.equal(none.suggest, null);
84
+ assert.equal(none.score, 0);
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // roster shaping
89
+ // ---------------------------------------------------------------------------
90
+
91
+ test("rosterFromSurface: standardAgents ∪ roleAgents, deduped, tagged with its source", () => {
92
+ const roster = rosterFromSurface({
93
+ standardAgents: [{ id: "alpha", role: "A" }, { id: "shared", role: "S-standard" }],
94
+ roleAgents: [{ id: "shared", role: "S-role" }, { id: "beta", role: "B" }],
95
+ });
96
+ assert.deepEqual(roster.map((a) => [a.id, a.source]), [["alpha", "standard"], ["shared", "standard"], ["beta", "role"]]);
97
+ });
98
+
99
+ test("rosterFromSurface: tolerates a null/empty surface", () => {
100
+ assert.deepEqual(rosterFromSurface(null), []);
101
+ assert.deepEqual(rosterFromSurface({ standardAgents: [{ noId: 1 }] }), []);
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // the three-way diff
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test("computeGaps: missing = roster ∖ resolved layers", () => {
109
+ const g = computeGaps({
110
+ roster: [{ id: "alpha", role: "A" }, { id: "beta", role: "B" }],
111
+ resolved: resolvedOf(["alpha"]),
112
+ refs: [ref("alpha"), ref("beta")],
113
+ });
114
+ assert.deepEqual(g.missing.map((m) => m.slug), ["beta"]);
115
+ assert.deepEqual(g.missing[0].actions, ["adopt-sdk", "fork", "create", "defer"]);
116
+ });
117
+
118
+ test("computeGaps: unrostered = refs ∖ roster, with file:line for the human to delete", () => {
119
+ const g = computeGaps({
120
+ roster: [{ id: "alpha" }],
121
+ resolved: resolvedOf(["alpha"]),
122
+ refs: [ref("alpha"), ref("ghost-writer", "workflows/vestigial.yaml", 12)],
123
+ });
124
+ assert.deepEqual(g.unrostered.map((u) => u.ref), ["ghost-writer"]);
125
+ assert.deepEqual(g.unrostered[0].locations, [{ file: "workflows/vestigial.yaml", line: 12, kind: "workflow" }]);
126
+ });
127
+
128
+ test("computeGaps: idle = roster agents NOTHING references (the check that did not exist)", () => {
129
+ const g = computeGaps({
130
+ roster: [{ id: "alpha" }, { id: "never-called", role: "Idle" }],
131
+ resolved: resolvedOf(["alpha", "never-called"]),
132
+ refs: [ref("alpha")],
133
+ });
134
+ assert.deepEqual(g.idle.map((i) => i.slug), ["never-called"]);
135
+ assert.equal(g.idle[0].resolves, true, "it exists — it is just dispatched to by nothing");
136
+ });
137
+
138
+ test("computeGaps: status is complete only with no missing AND no open drops", () => {
139
+ const clean = computeGaps({ roster: [{ id: "alpha" }], resolved: resolvedOf(["alpha"]), refs: [ref("alpha")] });
140
+ assert.equal(clean.status, "complete");
141
+ assert.match(clean.summary, /roster complete/);
142
+
143
+ const dirty = computeGaps({ roster: [{ id: "alpha" }], resolved: resolvedOf(["alpha"]), refs: [ref("ghost")] });
144
+ assert.equal(dirty.status, "partial");
145
+ assert.equal(dirty.openDrops.length, 1);
146
+ });
147
+
148
+ test("computeGaps: an ACCEPTED drop stops blocking completion (and stays visible)", () => {
149
+ const lock = { acceptedDrops: [{ ref: "ghost", file: "workflows/a.yaml", line: 3 }] };
150
+ const g = computeGaps({ roster: [{ id: "alpha" }], resolved: resolvedOf(["alpha"]), refs: [ref("alpha"), ref("ghost")], lock });
151
+ assert.equal(g.status, "complete");
152
+ assert.equal(g.openDrops.length, 0);
153
+ assert.equal(g.unrostered[0].accepted, true, "still reported — accepting is not forgetting");
154
+ });
155
+
156
+ test("computeGaps: an off-roster ref that DOES resolve on disk is not an open drop", () => {
157
+ const g = computeGaps({ roster: [{ id: "alpha" }], resolved: resolvedOf(["alpha", "extra"]), refs: [ref("alpha"), ref("extra")] });
158
+ assert.equal(g.unrostered[0].resolves, true);
159
+ assert.equal(g.openDrops.length, 0);
160
+ assert.equal(g.status, "complete");
161
+ });
162
+
163
+ test("computeGaps: a rewire suggestion alone is advisory and does not block completion", () => {
164
+ const roster = [{ id: "compliance-officer", role: "Compliance", purpose: "regulatory watch and filings" }];
165
+ const g = computeGaps({ roster, resolved: resolvedOf(["compliance-officer"]), refs: [ref("compliance-officer"), ref("regulatory-watch")] });
166
+ assert.equal(g.unrostered[0].action, "rewire");
167
+ assert.equal(g.openDrops.length, 0);
168
+ assert.equal(g.status, "complete");
169
+ });
170
+
171
+ test("computeGaps: unrostered is sorted by score, then alphabetically", () => {
172
+ const roster = [{ id: "compliance-officer", purpose: "regulatory watch" }];
173
+ const g = computeGaps({
174
+ roster,
175
+ resolved: resolvedOf([]),
176
+ refs: [ref("zulu-thing"), ref("alpha-thing"), ref("regulatory-watch")],
177
+ });
178
+ assert.deepEqual(g.unrostered.map((u) => u.ref), ["regulatory-watch", "alpha-thing", "zulu-thing"]);
179
+ });
180
+
181
+ test("computeGaps: carries localEdits/emptyDirs through and counts everything", () => {
182
+ const resolved = { ...resolvedOf(["alpha"]), localEdits: ["alpha"], emptyDirs: ["hollow"] };
183
+ const g = computeGaps({ roster: [{ id: "alpha" }, { id: "beta" }], resolved, refs: [ref("alpha"), ref("ghost")] });
184
+ assert.deepEqual(g.localEdits, ["alpha"]);
185
+ assert.deepEqual(g.emptyDirs, ["hollow"]);
186
+ assert.deepEqual(g.counts, { roster: 2, resolved: 1, missing: 1, unrostered: 1, idle: 1 });
187
+ });
188
+
189
+ test("computeGaps: nothing at all is 'complete' — there is nothing to decide", () => {
190
+ // An agent whose function has no capability pack has no authoritative roster.
191
+ // That must not block setup, so it reports complete rather than unconfigured.
192
+ const g = computeGaps({});
193
+ assert.equal(g.status, "complete");
194
+ assert.deepEqual(g.counts, { roster: 0, resolved: 0, missing: 0, unrostered: 0, idle: 0 });
195
+ });
196
+
197
+ test("computeGaps: 'unconfigured' means we know what is needed and NONE of it resolves", () => {
198
+ const g = computeGaps({ roster: [{ id: "alpha" }, { id: "beta" }], resolved: resolvedOf([]), refs: [] });
199
+ assert.equal(g.status, "unconfigured");
200
+ assert.equal(g.missing.length, 2);
201
+ // …and once even one resolves, it is merely partial.
202
+ assert.equal(computeGaps({ roster: [{ id: "alpha" }, { id: "beta" }], resolved: resolvedOf(["alpha"]), refs: [] }).status, "partial");
203
+ });
204
+
205
+ test("computeGaps: accepts a resolved shape with entries but no bySlug Map", () => {
206
+ const g = computeGaps({ roster: [{ id: "alpha" }], resolved: { entries: [{ slug: "alpha" }] }, refs: [ref("alpha")] });
207
+ assert.equal(g.status, "complete");
208
+ });
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // context extraction
212
+ // ---------------------------------------------------------------------------
213
+
214
+ test("contextFromConfig: pulls skills, responsibilities/charter pillars and governance themes", () => {
215
+ const c = contextFromConfig({
216
+ agentConfig: {
217
+ orgProfile: {
218
+ skills: ["regulatory reporting"],
219
+ governance: [{ theme: "risk", title: "Risk council", description: "quarterly" }, "plain string"],
220
+ },
221
+ responsibilities: ["own compliance"],
222
+ charter: { sections: [{ title: "Mandate", body: "watch filings" }] },
223
+ },
224
+ });
225
+ assert.deepEqual(c.skills, ["regulatory reporting"]);
226
+ assert.deepEqual(c.pillars, ["own compliance", "Mandate watch filings"]);
227
+ assert.equal(c.governance.length, 2);
228
+ assert.match(c.governance[0], /risk/);
229
+ });
230
+
231
+ test("contextFromConfig: every field absent is the COMMON case, and must not throw", () => {
232
+ assert.deepEqual(contextFromConfig({}), { skills: [], pillars: [], governance: [] });
233
+ assert.deepEqual(contextFromConfig({ agentConfig: { orgProfile: { skills: "not-an-array" } } }).skills, []);
234
+ });
@@ -0,0 +1,296 @@
1
+ /**
2
+ * lib/subagents/lock.mjs — the registry's on-disk state (§3, §4).
3
+ *
4
+ * Four artefacts, each with a distinct job and a distinct failure mode:
5
+ *
6
+ * .maestro/subagents.lock.json WHAT WAS MATERIALISED AND FROM WHERE. This is
7
+ * the only thing that makes local-edit detection possible: without a record
8
+ * of the bytes we wrote, an updated file and an edited file are
9
+ * indistinguishable. Committed with the agent repo — it is provenance, not
10
+ * cache.
11
+ *
12
+ * state/subagents/cache/<slug>@<v>.md THE OFFLINE FOURTH LAYER. Bodies fetched
13
+ * from hq, kept so `apply` can materialise with no network. Accepted risk
14
+ * (§6): it can go stale invisibly; `rosterVersion` in the lock vs the roster
15
+ * read is the only tell, which is why `doctor` surfaces it.
16
+ *
17
+ * state/subagents/outbox/<ts>-<op>.json WRITES MADE OFFLINE. Replayed by
18
+ * `maestro sync`. Idempotent by `contentHash` + `(orgId, slug)`, so a replay
19
+ * of a write that actually landed is a no-op server-side.
20
+ *
21
+ * state/subagents/gaps.json THE HEADLESS VERDICT. `apply` under `--headless`
22
+ * never creates an agent unattended; it writes what it found here for a human.
23
+ *
24
+ * `.maestro/` is the agent-local metadata directory (same convention as the rest
25
+ * of the wizard's checkpoints); `state/` is runtime scratch. The split matters:
26
+ * blowing away `state/` must degrade the registry (cold cache) but must NOT lose
27
+ * provenance.
28
+ *
29
+ * Every read is fail-open and returns a fully-shaped object; every write is
30
+ * atomic (lib/fs-atomic) because a half-written lock is worse than no lock — it
31
+ * would mark managed files as locally edited and freeze them.
32
+ *
33
+ * Node builtins only. ESM.
34
+ *
35
+ * @module lib/subagents/lock
36
+ */
37
+
38
+ "use strict";
39
+
40
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
41
+ import { randomBytes } from "node:crypto";
42
+ import { join } from "node:path";
43
+ import { writeJsonAtomic, writeFileAtomic } from "../fs-atomic.mjs";
44
+
45
+ export const LOCK_REL = join(".maestro", "subagents.lock.json");
46
+ export const CACHE_REL = join("state", "subagents", "cache");
47
+ export const OUTBOX_REL = join("state", "subagents", "outbox");
48
+ export const GAPS_REL = join("state", "subagents", "gaps.json");
49
+
50
+ /** The empty-but-valid lock. Every reader gets this shape, never null. */
51
+ export function emptyLock() {
52
+ return { version: 1, rosterVersion: 0, degraded: [], acceptedDrops: [], agents: {} };
53
+ }
54
+
55
+ export function lockPath(agentRoot) { return join(agentRoot, LOCK_REL); }
56
+ export function cacheDir(agentRoot) { return join(agentRoot, CACHE_REL); }
57
+ export function outboxDir(agentRoot) { return join(agentRoot, OUTBOX_REL); }
58
+ export function gapsPath(agentRoot) { return join(agentRoot, GAPS_REL); }
59
+
60
+ /**
61
+ * Read the lock. Fail-open → {@link emptyLock}. Fields are coerced to their
62
+ * declared types so a hand-edited lock can never make a consumer crash on
63
+ * `.agents[x].contentHash` of a string.
64
+ * @param {string} agentRoot
65
+ * @returns {{version:number, rosterVersion:number, degraded:string[], acceptedDrops:object[], agents:Record<string,object>}}
66
+ */
67
+ export function readLock(agentRoot) {
68
+ const p = lockPath(agentRoot);
69
+ if (!existsSync(p)) return emptyLock();
70
+ let doc;
71
+ try { doc = JSON.parse(readFileSync(p, "utf8")); } catch { return emptyLock(); }
72
+ if (!doc || typeof doc !== "object") return emptyLock();
73
+ const agents = {};
74
+ for (const [slug, e] of Object.entries(doc.agents && typeof doc.agents === "object" ? doc.agents : {})) {
75
+ if (!e || typeof e !== "object") continue;
76
+ agents[slug] = {
77
+ layer: String(e.layer || ""),
78
+ definitionId: e.definitionId ? String(e.definitionId) : null,
79
+ version: Number.isFinite(e.version) ? e.version : null,
80
+ contentHash: e.contentHash ? String(e.contentHash) : "",
81
+ fileHash: e.fileHash ? String(e.fileHash) : "",
82
+ source: e.source ? String(e.source) : "",
83
+ localEdits: !!e.localEdits,
84
+ materialisedAt: e.materialisedAt ? String(e.materialisedAt) : "",
85
+ };
86
+ }
87
+ return {
88
+ version: Number.isFinite(doc.version) ? doc.version : 1,
89
+ rosterVersion: Number.isFinite(doc.rosterVersion) ? doc.rosterVersion : 0,
90
+ degraded: Array.isArray(doc.degraded) ? doc.degraded.map(String) : [],
91
+ acceptedDrops: Array.isArray(doc.acceptedDrops) ? doc.acceptedDrops.filter((d) => d && typeof d === "object") : [],
92
+ agents,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Write the lock atomically. Returns false (never throws) on IO failure — a
98
+ * failed lock write degrades the NEXT run to "everything looks locally edited",
99
+ * which is safe, so it must not abort the current one (and `apply` throws are
100
+ * fatal in lib/setup/runner.mjs:220-227).
101
+ * @param {string} agentRoot @param {object} lock @returns {boolean}
102
+ */
103
+ export function writeLock(agentRoot, lock) {
104
+ try {
105
+ writeJsonAtomic(lockPath(agentRoot), lock);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Read-modify-write the lock. `patch` may carry `agents` (merged per slug),
114
+ * `degraded`/`acceptedDrops` (replaced when supplied) and `rosterVersion`.
115
+ * @param {string} agentRoot
116
+ * @param {object} patch
117
+ * @returns {object} the merged lock (written to disk)
118
+ */
119
+ export function mergeLock(agentRoot, patch = {}) {
120
+ const cur = readLock(agentRoot);
121
+ const next = { ...cur };
122
+ if (Number.isFinite(patch.rosterVersion)) next.rosterVersion = patch.rosterVersion;
123
+ if (Array.isArray(patch.degraded)) next.degraded = [...new Set(patch.degraded.map(String))];
124
+ if (Array.isArray(patch.acceptedDrops)) next.acceptedDrops = patch.acceptedDrops;
125
+ if (patch.agents && typeof patch.agents === "object") {
126
+ next.agents = { ...cur.agents };
127
+ for (const [slug, e] of Object.entries(patch.agents)) {
128
+ if (e === null) { delete next.agents[slug]; continue; }
129
+ next.agents[slug] = { ...(cur.agents[slug] || {}), ...e };
130
+ }
131
+ }
132
+ writeLock(agentRoot, next);
133
+ return next;
134
+ }
135
+
136
+ /**
137
+ * Record an accepted drop: the operator agreed that a reference is vestigial and
138
+ * the workflow step should go. We store the LOCATION, never edit the file (§2.3 —
139
+ * "never auto-edits or deletes a workflow"), because a workflow step's removal
140
+ * changes runtime behaviour and only a human should own that diff.
141
+ * @param {string} agentRoot
142
+ * @param {{ref:string, file?:string, line?:number, reason?:string}} drop
143
+ * @returns {object} the merged lock
144
+ */
145
+ export function acceptDrop(agentRoot, drop) {
146
+ const cur = readLock(agentRoot);
147
+ const key = `${drop.ref}@${drop.file || ""}:${drop.line || 0}`;
148
+ const kept = cur.acceptedDrops.filter((d) => `${d.ref}@${d.file || ""}:${d.line || 0}` !== key);
149
+ kept.push({
150
+ ref: String(drop.ref),
151
+ file: drop.file ? String(drop.file) : "",
152
+ line: Number.isFinite(drop.line) ? drop.line : 0,
153
+ reason: drop.reason ? String(drop.reason) : "",
154
+ acceptedAt: new Date().toISOString(),
155
+ });
156
+ return mergeLock(agentRoot, { acceptedDrops: kept });
157
+ }
158
+
159
+ /** Is this reference already an accepted drop? @returns {boolean} */
160
+ export function isAcceptedDrop(lock, ref) {
161
+ return (lock && Array.isArray(lock.acceptedDrops) ? lock.acceptedDrops : []).some((d) => d && d.ref === ref);
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Cache (the offline fourth layer)
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /** `<slug>@<version>.md` — version in the NAME so several versions coexist. */
169
+ export function cachePath(agentRoot, slug, version) {
170
+ return join(cacheDir(agentRoot), `${slug}@${version == null ? "head" : version}.md`);
171
+ }
172
+
173
+ /** Write a fetched body to the cache. Fail-open → false. */
174
+ export function writeCache(agentRoot, slug, version, text) {
175
+ try {
176
+ writeFileAtomic(cachePath(agentRoot, slug, version), typeof text === "string" ? text : "");
177
+ return true;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ /** Read a specific cached version. @returns {string|null} */
184
+ export function readCache(agentRoot, slug, version) {
185
+ const p = cachePath(agentRoot, slug, version);
186
+ if (!existsSync(p)) return null;
187
+ try { return readFileSync(p, "utf8"); } catch { return null; }
188
+ }
189
+
190
+ /**
191
+ * The highest-versioned cached body for a slug — what a cold-start offline
192
+ * resolution falls back to. Returns null when nothing is cached.
193
+ * @param {string} agentRoot @param {string} slug
194
+ * @returns {{version:number|null, text:string, path:string}|null}
195
+ */
196
+ export function latestCached(agentRoot, slug) {
197
+ const dir = cacheDir(agentRoot);
198
+ if (!existsSync(dir)) return null;
199
+ let names;
200
+ try { names = readdirSync(dir); } catch { return null; }
201
+ const prefix = `${slug}@`;
202
+ let best = null;
203
+ for (const n of names) {
204
+ if (!n.startsWith(prefix) || !n.endsWith(".md")) continue;
205
+ const v = n.slice(prefix.length, -3);
206
+ const num = /^\d+$/.test(v) ? Number(v) : null;
207
+ if (!best || (num !== null && (best.version === null || num > best.version))) best = { version: num, name: n };
208
+ }
209
+ if (!best) return null;
210
+ const p = join(dir, best.name);
211
+ try { return { version: best.version, text: readFileSync(p, "utf8"), path: p }; } catch { return null; }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Outbox (offline writes)
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Monotonic within a process. Together with the random suffix below it is what
220
+ * makes two queued writes in the SAME millisecond distinct files.
221
+ */
222
+ let outboxSeq = 0;
223
+
224
+ /**
225
+ * Queue a write that could not reach hq. The filename carries the timestamp so
226
+ * replay is chronological, and the payload carries everything needed to retry
227
+ * verbatim — no re-derivation at replay time (the local file may have changed
228
+ * again by then, and replaying a DIFFERENT body than the operator approved is the
229
+ * worst outcome available).
230
+ *
231
+ * WHY THE NAME IS NOT JUST THE TIMESTAMP. `create` immediately followed by `pin`
232
+ * (the `add`/`fork` path, and the section's apply) queues two writes inside the
233
+ * same millisecond. With a timestamp-only name the second silently OVERWRITES
234
+ * the first and the operator's work is lost — the exact failure the outbox
235
+ * exists to prevent. The sequence counter orders writes within a process and the
236
+ * random suffix keeps two processes from colliding on the same millisecond.
237
+ *
238
+ * @param {string} agentRoot @param {string} op @param {object} payload
239
+ * @returns {string|null} the queued path, or null on IO failure
240
+ */
241
+ export function queueOutbox(agentRoot, op, payload) {
242
+ try {
243
+ mkdirSync(outboxDir(agentRoot), { recursive: true });
244
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
245
+ const seq = String(++outboxSeq).padStart(4, "0");
246
+ const salt = randomBytes(3).toString("hex");
247
+ const p = join(outboxDir(agentRoot), `${ts}-${seq}-${salt}-${String(op).replace(/[^\w.-]/g, "_")}.json`);
248
+ writeFileSync(p, JSON.stringify({ op: String(op), payload: payload || {}, queuedAt: new Date().toISOString() }, null, 2));
249
+ return p;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /** List queued writes, oldest first. Fail-open → []. */
256
+ export function listOutbox(agentRoot) {
257
+ const dir = outboxDir(agentRoot);
258
+ if (!existsSync(dir)) return [];
259
+ let names;
260
+ try { names = readdirSync(dir).filter((n) => n.endsWith(".json")).sort(); } catch { return []; }
261
+ const out = [];
262
+ for (const n of names) {
263
+ const p = join(dir, n);
264
+ try {
265
+ const doc = JSON.parse(readFileSync(p, "utf8"));
266
+ if (doc && doc.op) out.push({ path: p, op: String(doc.op), payload: doc.payload || {}, queuedAt: doc.queuedAt || "" });
267
+ } catch { /* a corrupt entry is skipped, not fatal */ }
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /** Remove a replayed outbox entry. Fail-open → false. */
273
+ export function removeOutbox(path) {
274
+ try { unlinkSync(path); return true; } catch { return false; }
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Gaps report
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /** Persist the headless gap verdict. Fail-open → false. */
282
+ export function writeGaps(agentRoot, gaps) {
283
+ try {
284
+ writeJsonAtomic(gapsPath(agentRoot), { generatedAt: new Date().toISOString(), ...gaps });
285
+ return true;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ /** Read the last gap verdict. @returns {object|null} */
292
+ export function readGaps(agentRoot) {
293
+ const p = gapsPath(agentRoot);
294
+ if (!existsSync(p)) return null;
295
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
296
+ }