@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.
- package/bin/maestro.mjs +37 -50
- 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/channels/inbox-item.mjs +20 -0
- 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 +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- 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 +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -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 +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -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 +9 -4
- 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 +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- 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/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- 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/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- 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,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/subagents/resolve.mjs — three-layer, total, per-slug resolution (§3).
|
|
3
|
+
*
|
|
4
|
+
* THE MODEL IN ONE SENTENCE: for every slug, exactly one layer wins and layers
|
|
5
|
+
* NEVER merge — LOCAL > WORKSPACE > SDK — and the winner is written to
|
|
6
|
+
* `agents/<slug>/agent.md` with its provenance stamped into the frontmatter.
|
|
7
|
+
*
|
|
8
|
+
* WHY NO MERGING. The obvious alternative (section-level `mode:"extend"` over an
|
|
9
|
+
* informal `## Mandate / ## Responsibilities / …` skeleton) is brittle exactly
|
|
10
|
+
* where it matters: the shipped agents and the generated ones already disagree
|
|
11
|
+
* about that skeleton, so a heading rename upstream silently drops an operator's
|
|
12
|
+
* override. Total precedence is legible — you can always answer "why does this
|
|
13
|
+
* file say that" with one word. "Extend" is spelled `subagent.fork`: copy the
|
|
14
|
+
* body, own it.
|
|
15
|
+
*
|
|
16
|
+
* WHY RESOLUTION IS LOCAL-FIRST AND NEVER NETWORKS. `detect` and `verify` run
|
|
17
|
+
* under `--headless`, in `doctor`, and in CI. The lock + `agents/` + the SDK
|
|
18
|
+
* manifest are sufficient for all three layers (layer 1 via the offline cache),
|
|
19
|
+
* so a dead hq degrades the roster, it never blocks the agent. hq is not on the
|
|
20
|
+
* runtime hot path at all.
|
|
21
|
+
*
|
|
22
|
+
* THE LOCAL RULE, SPELLED OUT. A file is a LOCAL EDIT when it exists and either:
|
|
23
|
+
* (a) the lock has an entry for the slug and sha256(file) ≠ the fileHash we
|
|
24
|
+
* recorded when we wrote it — i.e. something changed it after us; or
|
|
25
|
+
* (b) the lock has NO entry and sha256(file) matches no sha the SDK manifest
|
|
26
|
+
* has ever shipped — i.e. it did not come from us and it did not come from
|
|
27
|
+
* the package, so a human wrote it.
|
|
28
|
+
* Everything else is MANAGED and may be silently re-materialised. That is the
|
|
29
|
+
* whole reason `create`'s copy list and `UPGRADE_PATHS` can stay untouched.
|
|
30
|
+
*
|
|
31
|
+
* Note (a) and (b) also give the safe default in every degraded case: no lock and
|
|
32
|
+
* no manifest ⇒ everything reads as a local edit ⇒ nothing is overwritten.
|
|
33
|
+
*
|
|
34
|
+
* Node builtins only. ESM.
|
|
35
|
+
*
|
|
36
|
+
* @module lib/subagents/resolve
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
"use strict";
|
|
40
|
+
|
|
41
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
42
|
+
import { join } from "node:path";
|
|
43
|
+
import { writeFileAtomic } from "../fs-atomic.mjs";
|
|
44
|
+
import { listAgentDirs, manifestShas, readManifest, readSdkAgent } from "./manifest.mjs";
|
|
45
|
+
import { latestCached, readLock } from "./lock.mjs";
|
|
46
|
+
import {
|
|
47
|
+
KNOWN_TOKENS,
|
|
48
|
+
hashBody,
|
|
49
|
+
hashFile,
|
|
50
|
+
parseAgentMd,
|
|
51
|
+
stampProvenance,
|
|
52
|
+
unsupportedTokens,
|
|
53
|
+
validateAgentMd,
|
|
54
|
+
} from "./schema.mjs";
|
|
55
|
+
|
|
56
|
+
/** Layer names, in precedence order (first wins). */
|
|
57
|
+
export const LAYERS = Object.freeze(["local", "workspace", "sdk"]);
|
|
58
|
+
|
|
59
|
+
/** @param {string} agentRoot @param {string} slug @returns {string} */
|
|
60
|
+
export function agentFilePath(agentRoot, slug) {
|
|
61
|
+
return join(agentRoot, "agents", String(slug), "agent.md");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read every on-disk agent.md in the agent repo.
|
|
66
|
+
* @param {string} agentRoot
|
|
67
|
+
* @returns {{files:Map<string,{slug:string, path:string, text:string, fileHash:string}>, empty:string[]}}
|
|
68
|
+
*/
|
|
69
|
+
export function readLocalAgents(agentRoot) {
|
|
70
|
+
const { slugs, empty } = listAgentDirs(agentRoot);
|
|
71
|
+
const files = new Map();
|
|
72
|
+
for (const slug of slugs) {
|
|
73
|
+
const p = agentFilePath(agentRoot, slug);
|
|
74
|
+
let text;
|
|
75
|
+
try { text = readFileSync(p, "utf8"); } catch { continue; }
|
|
76
|
+
files.set(slug, { slug, path: p, text, fileHash: hashFile(text) });
|
|
77
|
+
}
|
|
78
|
+
return { files, empty };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classify an on-disk file as a local edit or a managed copy.
|
|
83
|
+
* @param {{fileHash:string, lockEntry?:object, shas:Set<string>}} o
|
|
84
|
+
* @returns {{localEdits:boolean, reason:string}}
|
|
85
|
+
*/
|
|
86
|
+
export function classifyLocal(o = {}) {
|
|
87
|
+
const { fileHash, lockEntry, shas } = o;
|
|
88
|
+
|
|
89
|
+
// `fileHash` in the lock means "bytes WE materialised". It is only ever
|
|
90
|
+
// written after a successful write, so a match is proof the file is managed.
|
|
91
|
+
if (lockEntry && lockEntry.fileHash) {
|
|
92
|
+
if (lockEntry.fileHash === fileHash) return { localEdits: false, reason: "matches lock fileHash" };
|
|
93
|
+
return { localEdits: true, reason: "differs from the bytes recorded in the lock" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A file that has been restored to canonical bytes is managed again — checked
|
|
97
|
+
// BEFORE the sticky flag so reverting an edit re-adopts the file rather than
|
|
98
|
+
// marking it dirty forever.
|
|
99
|
+
if (shas && shas.has(fileHash)) return { localEdits: false, reason: "matches an SDK manifest sha" };
|
|
100
|
+
|
|
101
|
+
// STICKY: a previously-recorded local edit stays a local edit.
|
|
102
|
+
//
|
|
103
|
+
// Without this, a refusal was protective for exactly one run and destructive
|
|
104
|
+
// on the next: `materialise` correctly refused to overwrite a human's file,
|
|
105
|
+
// but the caller then recorded that file's OWN hash as `fileHash` — i.e. as
|
|
106
|
+
// bytes we had written — so the next run matched it, classified the file as
|
|
107
|
+
// managed, and overwrote the human's work with no `--force` and no warning.
|
|
108
|
+
// Reproduced end to end: run 1 refuses, run 2 silently destroys the edit.
|
|
109
|
+
//
|
|
110
|
+
// The flag is now authoritative and the hash of an unmanaged file is never
|
|
111
|
+
// recorded as ours (see the persist site in `materialiseAll`).
|
|
112
|
+
if (lockEntry && lockEntry.localEdits) {
|
|
113
|
+
return { localEdits: true, reason: "lock records an unresolved local edit" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { localEdits: true, reason: lockEntry ? "lock entry carries no fileHash" : "no lock entry and no SDK sha match" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the WORKSPACE layer's per-slug body source from a `subagent.resolve`
|
|
121
|
+
* payload and/or the offline cache.
|
|
122
|
+
*
|
|
123
|
+
* `pins` is whatever the client last got from hq — either live or from the
|
|
124
|
+
* cache. Each entry is `{slug, definitionId, version, contentHash, frontmatter,
|
|
125
|
+
* body}`. When an entry carries no body (e.g. it came from the cheap
|
|
126
|
+
* `subagent.roster` read), we look for the body in the local cache; if it is not
|
|
127
|
+
* there either, the slug simply has no workspace layer this run and resolution
|
|
128
|
+
* falls through to SDK. That is the degraded-but-correct behaviour §4 demands.
|
|
129
|
+
*
|
|
130
|
+
* @param {{agentRoot:string, pins?:Array<object>}} o
|
|
131
|
+
* @returns {Map<string, {slug:string, definitionId:string|null, version:number|null, contentHash:string, body:string, frontmatter:object, fromCache:boolean}>}
|
|
132
|
+
*/
|
|
133
|
+
export function buildWorkspaceLayer(o = {}) {
|
|
134
|
+
const out = new Map();
|
|
135
|
+
for (const p of Array.isArray(o.pins) ? o.pins : []) {
|
|
136
|
+
if (!p || !p.slug) continue;
|
|
137
|
+
let body = typeof p.body === "string" ? p.body : "";
|
|
138
|
+
let frontmatter = p.frontmatter && typeof p.frontmatter === "object" ? { ...p.frontmatter } : null;
|
|
139
|
+
let fromCache = false;
|
|
140
|
+
if (!body && o.agentRoot) {
|
|
141
|
+
const cached = latestCached(o.agentRoot, p.slug);
|
|
142
|
+
if (cached) {
|
|
143
|
+
const parsed = parseAgentMd(cached.text);
|
|
144
|
+
body = parsed.body;
|
|
145
|
+
if (!frontmatter) frontmatter = parsed.frontmatter;
|
|
146
|
+
fromCache = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!body) continue;
|
|
150
|
+
out.set(String(p.slug), {
|
|
151
|
+
slug: String(p.slug),
|
|
152
|
+
definitionId: p.definitionId ? String(p.definitionId) : null,
|
|
153
|
+
version: Number.isFinite(p.version) ? p.version : null,
|
|
154
|
+
contentHash: p.contentHash ? String(p.contentHash) : hashBody(body),
|
|
155
|
+
body,
|
|
156
|
+
frontmatter: frontmatter || {},
|
|
157
|
+
fromCache,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve every slug in `(SDK manifest) ∪ (workspace pins) ∪ (on-disk agents/*)`.
|
|
165
|
+
*
|
|
166
|
+
* Pure over its injected inputs (the only I/O is reading files). Never networks.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} o
|
|
169
|
+
* @param {string} o.agentRoot
|
|
170
|
+
* @param {string} o.maestroRoot
|
|
171
|
+
* @param {Array<object>} [o.pins] the workspace layer (from client/cache)
|
|
172
|
+
* @param {object} [o.manifest] injectable (defaults to reading the SDK manifest)
|
|
173
|
+
* @param {object} [o.lock] injectable (defaults to reading the lock)
|
|
174
|
+
* @param {boolean} [o.force] DEMOTE the local layer: a locally-edited file is
|
|
175
|
+
* still REPORTED in `localEdits`, but it no longer wins, so `pull --force`
|
|
176
|
+
* actually re-resolves to workspace/SDK. Without this the local layer absorbs
|
|
177
|
+
* the slug before precedence is ever consulted and `--force` would only ever
|
|
178
|
+
* rewrite a file from itself. `--force` is the ONLY thing that does this.
|
|
179
|
+
* @returns {{entries:Array<object>, bySlug:Map<string,object>, localEdits:string[], unresolved:string[], emptyDirs:string[], degraded:string[]}}
|
|
180
|
+
*/
|
|
181
|
+
export function resolveSubagents(o = {}) {
|
|
182
|
+
const agentRoot = o.agentRoot;
|
|
183
|
+
const maestroRoot = o.maestroRoot;
|
|
184
|
+
const manifest = o.manifest || readManifest(maestroRoot);
|
|
185
|
+
const lock = o.lock || readLock(agentRoot);
|
|
186
|
+
const shas = manifestShas(manifest);
|
|
187
|
+
const { files, empty } = readLocalAgents(agentRoot);
|
|
188
|
+
const workspace = buildWorkspaceLayer({ agentRoot, pins: o.pins });
|
|
189
|
+
|
|
190
|
+
const slugs = new Set([...Object.keys(manifest.agents || {}), ...workspace.keys(), ...files.keys()]);
|
|
191
|
+
const entries = [];
|
|
192
|
+
const localEdits = [];
|
|
193
|
+
const unresolved = [];
|
|
194
|
+
const degraded = [];
|
|
195
|
+
if (workspace.size && [...workspace.values()].some((w) => w.fromCache)) degraded.push("workspace-from-cache");
|
|
196
|
+
|
|
197
|
+
for (const slug of [...slugs].sort()) {
|
|
198
|
+
const file = files.get(slug) || null;
|
|
199
|
+
const lockEntry = lock.agents ? lock.agents[slug] : null;
|
|
200
|
+
|
|
201
|
+
// ── Layer 2: LOCAL ────────────────────────────────────────────────────
|
|
202
|
+
// `localEdited` is computed once and reused by the fallback branch below, so
|
|
203
|
+
// an overridden (--force) file is still reported as edited even though it
|
|
204
|
+
// lost. Classification and precedence are deliberately separate questions.
|
|
205
|
+
let localEdited = false;
|
|
206
|
+
if (file) {
|
|
207
|
+
const cls = classifyLocal({ fileHash: file.fileHash, lockEntry, shas });
|
|
208
|
+
localEdited = cls.localEdits;
|
|
209
|
+
if (cls.localEdits) localEdits.push(slug);
|
|
210
|
+
if (cls.localEdits && !o.force) {
|
|
211
|
+
const parsed = parseAgentMd(file.text);
|
|
212
|
+
entries.push({
|
|
213
|
+
slug,
|
|
214
|
+
layer: "local",
|
|
215
|
+
source: `file://${file.path}`,
|
|
216
|
+
version: null,
|
|
217
|
+
definitionId: null,
|
|
218
|
+
contentHash: hashBody(parsed.body),
|
|
219
|
+
fileHash: file.fileHash,
|
|
220
|
+
localEdits: true,
|
|
221
|
+
reason: cls.reason,
|
|
222
|
+
frontmatter: parsed.frontmatter,
|
|
223
|
+
body: parsed.body,
|
|
224
|
+
path: file.path,
|
|
225
|
+
onDisk: true,
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Layer 1: WORKSPACE ────────────────────────────────────────────────
|
|
232
|
+
const w = workspace.get(slug);
|
|
233
|
+
if (w) {
|
|
234
|
+
entries.push({
|
|
235
|
+
slug,
|
|
236
|
+
layer: "workspace",
|
|
237
|
+
source: w.definitionId ? `cohort://${o.orgSlug || "workspace"}/${slug}` : `cohort://workspace/${slug}`,
|
|
238
|
+
version: w.version,
|
|
239
|
+
definitionId: w.definitionId,
|
|
240
|
+
contentHash: w.contentHash,
|
|
241
|
+
fileHash: file ? file.fileHash : "",
|
|
242
|
+
localEdits: false,
|
|
243
|
+
reason: w.fromCache ? "workspace pin (offline cache)" : "workspace pin",
|
|
244
|
+
frontmatter: w.frontmatter,
|
|
245
|
+
body: w.body,
|
|
246
|
+
path: agentFilePath(agentRoot, slug),
|
|
247
|
+
onDisk: !!file,
|
|
248
|
+
fromCache: w.fromCache,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Layer 0: SDK ──────────────────────────────────────────────────────
|
|
254
|
+
if (manifest.agents && manifest.agents[slug]) {
|
|
255
|
+
const sdk = readSdkAgent(maestroRoot, slug);
|
|
256
|
+
if (sdk) {
|
|
257
|
+
entries.push({
|
|
258
|
+
slug,
|
|
259
|
+
layer: "sdk",
|
|
260
|
+
source: `sdk://${manifest.sdkVersion || "unknown"}/${slug}`,
|
|
261
|
+
version: manifest.sdkVersion || null,
|
|
262
|
+
definitionId: null,
|
|
263
|
+
contentHash: hashBody(sdk.body),
|
|
264
|
+
fileHash: file ? file.fileHash : "",
|
|
265
|
+
localEdits: false,
|
|
266
|
+
reason: "shipped in the SDK manifest",
|
|
267
|
+
frontmatter: sdk.frontmatter,
|
|
268
|
+
body: sdk.body,
|
|
269
|
+
path: agentFilePath(agentRoot, slug),
|
|
270
|
+
onDisk: !!file,
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A managed on-disk file whose layer has disappeared (yanked pin, dropped
|
|
277
|
+
// from the manifest). It still RESOLVES — the bytes are right there — but it
|
|
278
|
+
// is orphaned, and saying so is more useful than pretending it is SDK.
|
|
279
|
+
// Under --force a locally-edited file also lands here when no higher layer
|
|
280
|
+
// offers the slug: there is nothing to override it WITH, so it keeps the
|
|
281
|
+
// file and stays flagged as an edit rather than being silently blanked.
|
|
282
|
+
if (file) {
|
|
283
|
+
const parsed = parseAgentMd(file.text);
|
|
284
|
+
entries.push({
|
|
285
|
+
slug,
|
|
286
|
+
layer: "local",
|
|
287
|
+
source: `file://${file.path}`,
|
|
288
|
+
version: lockEntry ? lockEntry.version : null,
|
|
289
|
+
definitionId: lockEntry ? lockEntry.definitionId : null,
|
|
290
|
+
contentHash: hashBody(parsed.body),
|
|
291
|
+
fileHash: file.fileHash,
|
|
292
|
+
localEdits: localEdited,
|
|
293
|
+
orphaned: !localEdited,
|
|
294
|
+
reason: localEdited
|
|
295
|
+
? "locally edited, and no other layer offers this slug"
|
|
296
|
+
: "managed file whose source layer no longer offers this slug",
|
|
297
|
+
frontmatter: parsed.frontmatter,
|
|
298
|
+
body: parsed.body,
|
|
299
|
+
path: file.path,
|
|
300
|
+
onDisk: true,
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
unresolved.push(slug);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const bySlug = new Map(entries.map((e) => [e.slug, e]));
|
|
309
|
+
return { entries, bySlug, localEdits, unresolved, emptyDirs: empty, degraded };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Render a resolved entry to the EXACT bytes that belong on disk.
|
|
314
|
+
*
|
|
315
|
+
* This is deliberately one function shared by `materialise` and by
|
|
316
|
+
* `subagents diff`. When `diff` re-derived the bytes itself it skipped
|
|
317
|
+
* validation — so it compared the raw layer frontmatter (`model: sonnet`, no
|
|
318
|
+
* `tokens:`) against the normalised bytes materialise writes (`model:
|
|
319
|
+
* claude-sonnet-4-6`, `tokens: []`) and reported EVERY managed file as differing
|
|
320
|
+
* from the layer it had just been written from. One renderer, one answer.
|
|
321
|
+
*
|
|
322
|
+
* Refusals, in order, and why each one exists:
|
|
323
|
+
* 1. Body fails the schema — the registry never puts prose on disk it could not
|
|
324
|
+
* validate; a bad publish upstream must not become a bad file downstream.
|
|
325
|
+
* 2. Declared tokens the local renderer does not know — hq does not vendor
|
|
326
|
+
* KNOWN_TOKENS, so the fetching agent is the only place this can be caught.
|
|
327
|
+
* Refusing here makes `scripts/ci/check-unresolved-tokens.mjs` structurally
|
|
328
|
+
* unable to fail after a fetch.
|
|
329
|
+
*
|
|
330
|
+
* @param {object} entry a resolveSubagents entry
|
|
331
|
+
* @param {{known?:Set<string>}} [o]
|
|
332
|
+
* @returns {{ok:boolean, text:string, reason:string, errors?:string[]}}
|
|
333
|
+
*/
|
|
334
|
+
export function renderEntry(entry, o = {}) {
|
|
335
|
+
const known = o.known || KNOWN_TOKENS;
|
|
336
|
+
const check = validateAgentMd({ frontmatter: entry.frontmatter, body: entry.body, slug: entry.slug, known });
|
|
337
|
+
if (!check.ok) return { ok: false, text: "", reason: "failed schema validation", errors: check.errors };
|
|
338
|
+
|
|
339
|
+
const bad = unsupportedTokens(check.normalised.frontmatter.tokens, known);
|
|
340
|
+
if (bad.length) {
|
|
341
|
+
return { ok: false, text: "", reason: `declares token(s) this agent cannot render: ${bad.join(", ")}`, errors: bad };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
reason: "",
|
|
347
|
+
text: stampProvenance({
|
|
348
|
+
frontmatter: check.normalised.frontmatter,
|
|
349
|
+
body: entry.body,
|
|
350
|
+
layer: entry.layer,
|
|
351
|
+
source: entry.source,
|
|
352
|
+
version: entry.version,
|
|
353
|
+
contentHash: entry.contentHash,
|
|
354
|
+
}),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Materialise a resolved entry to `agents/<slug>/agent.md`.
|
|
360
|
+
*
|
|
361
|
+
* The first refusal is precedence, the rest are {@link renderEntry}'s:
|
|
362
|
+
* 1. LOCAL without `--force` — the file IS the winner; rewriting it would be
|
|
363
|
+
* the clobber lib/setup/enroll-from-cohort.mjs:1-31 exists to forbid.
|
|
364
|
+
*
|
|
365
|
+
* Tokens are NOT rendered: `{{agent.*}}` survives to disk and lib/render.mjs
|
|
366
|
+
* resolves it at load, which is what keeps one shipped body valid for every agent.
|
|
367
|
+
*
|
|
368
|
+
* @param {{agentRoot:string, entry:object, force?:boolean, known?:Set<string>, orgSlug?:string}} o
|
|
369
|
+
* @returns {{written:boolean, path:string, reason:string, fileHash?:string, errors?:string[]}}
|
|
370
|
+
*/
|
|
371
|
+
export function materialise(o = {}) {
|
|
372
|
+
const { agentRoot, entry } = o;
|
|
373
|
+
const path = agentFilePath(agentRoot, entry.slug);
|
|
374
|
+
|
|
375
|
+
if (entry.layer === "local" && !o.force) {
|
|
376
|
+
return { written: false, path, reason: entry.orphaned ? "orphaned local file left untouched" : "local edit wins (use --force to overwrite)" };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const rendered = renderEntry(entry, { known: o.known });
|
|
380
|
+
if (!rendered.ok) return { written: false, path, reason: rendered.reason, errors: rendered.errors };
|
|
381
|
+
const text = rendered.text;
|
|
382
|
+
|
|
383
|
+
// Idempotence: if the bytes already on disk are byte-identical, do not write.
|
|
384
|
+
// Not just an optimisation — a no-op write would churn mtime on every `doctor`
|
|
385
|
+
// run and make "when did this last change" useless.
|
|
386
|
+
if (existsSync(path)) {
|
|
387
|
+
try {
|
|
388
|
+
if (readFileSync(path, "utf8") === text) {
|
|
389
|
+
return { written: false, path, reason: "already up to date", fileHash: hashFile(text) };
|
|
390
|
+
}
|
|
391
|
+
} catch { /* fall through and write */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
writeFileAtomic(path, text);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
return { written: false, path, reason: `write failed: ${e && e.message ? e.message : e}` };
|
|
398
|
+
}
|
|
399
|
+
return { written: true, path, reason: `materialised from ${entry.layer}`, fileHash: hashFile(text) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Materialise every resolved entry and return the lock patch describing what
|
|
404
|
+
* ended up on disk. The caller writes the lock (so a caller can dry-run).
|
|
405
|
+
*
|
|
406
|
+
* @param {{agentRoot:string, resolved:object, force?:boolean, known?:Set<string>, only?:string[]}} o
|
|
407
|
+
* @returns {{results:Array<object>, lockAgents:Record<string,object>, written:number, refused:Array<object>}}
|
|
408
|
+
*/
|
|
409
|
+
export function materialiseAll(o = {}) {
|
|
410
|
+
const results = [];
|
|
411
|
+
const lockAgents = {};
|
|
412
|
+
const refused = [];
|
|
413
|
+
let written = 0;
|
|
414
|
+
const only = Array.isArray(o.only) && o.only.length ? new Set(o.only) : null;
|
|
415
|
+
|
|
416
|
+
for (const entry of (o.resolved && o.resolved.entries) || []) {
|
|
417
|
+
if (only && !only.has(entry.slug)) continue;
|
|
418
|
+
const r = materialise({ agentRoot: o.agentRoot, entry, force: o.force, known: o.known });
|
|
419
|
+
results.push({ slug: entry.slug, layer: entry.layer, ...r });
|
|
420
|
+
if (r.written) written++;
|
|
421
|
+
if (!r.written && r.errors) refused.push({ slug: entry.slug, reason: r.reason, errors: r.errors });
|
|
422
|
+
|
|
423
|
+
// `fileHash` means ONE thing: the bytes WE materialised. It is the proof
|
|
424
|
+
// `classifyLocal` uses to call a file managed, so recording anything else
|
|
425
|
+
// there is a licence to overwrite.
|
|
426
|
+
//
|
|
427
|
+
// It previously fell back to `entry.fileHash` — the hash of whatever was on
|
|
428
|
+
// disk — including on a REFUSAL. So refusing to overwrite a human's file
|
|
429
|
+
// recorded that human's bytes as ours, and the next run overwrote them with
|
|
430
|
+
// no `--force` and no warning. The refusal protected the file for exactly
|
|
431
|
+
// one run and then destroyed it.
|
|
432
|
+
//
|
|
433
|
+
// Now: only a real write (or a confirmed byte-identical no-op) records a
|
|
434
|
+
// fileHash. A refusal records none, and `localEdits` carries the state
|
|
435
|
+
// forward instead.
|
|
436
|
+
const materialised = r.written || r.reason === "already up to date";
|
|
437
|
+
const fileHash = materialised ? r.fileHash || "" : "";
|
|
438
|
+
lockAgents[entry.slug] = {
|
|
439
|
+
layer: entry.layer,
|
|
440
|
+
definitionId: entry.definitionId || null,
|
|
441
|
+
version: entry.version === undefined ? null : entry.version,
|
|
442
|
+
contentHash: entry.contentHash || "",
|
|
443
|
+
fileHash,
|
|
444
|
+
source: entry.source || "",
|
|
445
|
+
// A successful materialise RESOLVES the edit (we just replaced the file
|
|
446
|
+
// with canonical bytes, by force or because nothing was protecting it).
|
|
447
|
+
// A refusal leaves it unresolved, and `classifyLocal` now treats that as
|
|
448
|
+
// authoritative on the next run.
|
|
449
|
+
localEdits: materialised ? false : !!entry.localEdits,
|
|
450
|
+
materialisedAt: r.written ? new Date().toISOString() : (r.reason === "already up to date" ? new Date().toISOString() : ""),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { results, lockAgents, written, refused };
|
|
455
|
+
}
|