@mnmal-ai/cortext 0.0.0 → 1.0.0-alpha.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/LICENSE +202 -0
- package/README.md +70 -1
- package/config.schema.json +62 -0
- package/default-config.json +10 -0
- package/dist/auth-policy.d.ts +44 -0
- package/dist/bootstrap.d.ts +36 -0
- package/dist/bootstrap.js +69 -0
- package/dist/compact.d.ts +37 -0
- package/dist/config.d.ts +40 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +5 -0
- package/dist/kernel/bootstrap.d.ts +11 -0
- package/dist/kernel/cortext-namespace.d.ts +96 -0
- package/dist/kernel/index.d.ts +15 -0
- package/dist/kernel/invariant-audit.d.ts +190 -0
- package/dist/kernel/migrations/0001_baseline.d.ts +11 -0
- package/dist/kernel/plugin.d.ts +29 -0
- package/dist/kernel/repair.d.ts +94 -0
- package/dist/kernel/rescope.d.ts +87 -0
- package/dist/kernel/substrate-contract.d.ts +19 -0
- package/dist/kernel/todo-vocab.d.ts +17 -0
- package/dist/kernel-bootstrap.js +21 -0
- package/dist/kernel-invariants.d.ts +62 -0
- package/dist/kernel.js +3 -0
- package/dist/mailbox.d.ts +80 -0
- package/dist/namespace.d.ts +159 -0
- package/dist/nl-rulings.d.ts +76 -0
- package/dist/onboard.d.ts +15 -0
- package/dist/participants.d.ts +32 -0
- package/dist/plugin-27UpMnoE.js +811 -0
- package/dist/release-surface.d.ts +206 -0
- package/dist/src-BKaGTnRw.js +1931 -0
- package/dist/todo-resolution.d.ts +18 -0
- package/kernel.config.schema.json +8 -0
- package/kernel.default-config.json +1 -0
- package/package.json +80 -4
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
import { A as CORTEXT_PROMOTED_COLUMNS, D as ARCHITECTURE_NAMESPACE, O as CORTEXT_COORDINATOR_NAME, _ as TODO_LIFECYCLE_VOCAB, g as AGENT_MESSAGE_STATUS_VOCAB, k as CORTEXT_NAMESPACE, n as SUBSTRATE_CONTRACT_RANGE, v as TODO_PRIORITY_VOCAB, y as TODO_RESOLUTION_VOCAB } from "./plugin-27UpMnoE.js";
|
|
2
|
+
import { JSON_HEADERS, RESERVED_AUTOCRUD_FIELDS, ResolverFrameError, and, applyUpdatePatch, createResolverWithEdges, defineNamespace, deleteResolver, denyAll, denyVerbs, field, formatQualifiedKey, getResolver, grantOf, grantedIn, jsonResponse, listResolver, makeCtx, or, queryAll, requireIdentity, resolveEntityRow, resolveIdentity, selectAll, updateResolver } from "@mnmal-ai/substrate/sdk";
|
|
3
|
+
|
|
4
|
+
//#region src/auth-policy.ts
|
|
5
|
+
/**
|
|
6
|
+
* The default: anonymous read/create/update under
|
|
7
|
+
* attribute mode, identity-gated hard-deletes. Archive stays the open soft path.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_AUTH = Object.freeze({
|
|
10
|
+
reads: "open",
|
|
11
|
+
writes: "open",
|
|
12
|
+
deletes: "identity"
|
|
13
|
+
});
|
|
14
|
+
const rank = (mode) => mode === "identity" ? 1 : 0;
|
|
15
|
+
/** Strictness must be non-decreasing along reads → writes → deletes. */
|
|
16
|
+
function isMonotonicAuth(auth) {
|
|
17
|
+
return rank(auth.reads) <= rank(auth.writes) && rank(auth.writes) <= rank(auth.deletes);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Map a posture to the plugin-level policy list. Combinator order matters:
|
|
21
|
+
* `or()` returns the LAST deny verbatim, so the deny arm's actionable reason
|
|
22
|
+
* surfaces on denial instead of requireIdentity's terse default.
|
|
23
|
+
*
|
|
24
|
+
* Monotonicity makes the mapping a ladder — the strictest gated surface
|
|
25
|
+
* determines the single composed policy:
|
|
26
|
+
* reads gated → everything requires identity
|
|
27
|
+
* writes gated → create/update/delete require identity, reads stay open
|
|
28
|
+
* deletes gated → only the destructive verbs require identity
|
|
29
|
+
* fully open → no policy contributed at all
|
|
30
|
+
*/
|
|
31
|
+
function buildAuthPolicies(auth) {
|
|
32
|
+
if (!isMonotonicAuth(auth)) throw new Error(`cortext: auth posture must be monotonic (reads ≤ writes ≤ deletes in strictness); got reads=${auth.reads}, writes=${auth.writes}, deletes=${auth.deletes}`);
|
|
33
|
+
if (auth.reads === "identity") return [or(requireIdentity(), denyAll("cortext: all access requires an authenticated identity (auth.reads=identity)"))];
|
|
34
|
+
if (auth.writes === "identity") return [or(requireIdentity(), denyVerbs([
|
|
35
|
+
"create",
|
|
36
|
+
"update",
|
|
37
|
+
"delete"
|
|
38
|
+
], "cortext: writes require an authenticated identity (auth.writes=identity; reads stay open)"))];
|
|
39
|
+
if (auth.deletes === "identity") return [or(requireIdentity(), denyVerbs(["delete"], "cortext: deletes require an authenticated identity (auth.deletes=identity)"))];
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/compact.ts
|
|
45
|
+
/**
|
|
46
|
+
* Acked agent mail older than this is archived by the compact sweep (fan-out
|
|
47
|
+
* writes one row per recipient, and without a policy the
|
|
48
|
+
* acked majority accumulates in the mailbox scan forever). 30 days keeps a
|
|
49
|
+
* generous correspondence-history window; `acked` means the recipient
|
|
50
|
+
* explicitly actioned it, and `archived` only drops it from inbox-shaped
|
|
51
|
+
* queries — the row remains queryable.
|
|
52
|
+
*/
|
|
53
|
+
const MAIL_ARCHIVE_AFTER_MS = 2592e6;
|
|
54
|
+
/** The ids a Decision's `supersedes` names, whichever shape it was stored in. */
|
|
55
|
+
function supersededIds(d) {
|
|
56
|
+
const v = d.supersedes;
|
|
57
|
+
if (typeof v === "string" && v !== "") return [v];
|
|
58
|
+
if (Array.isArray(v)) return v.filter((x) => typeof x === "string" && x !== "");
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
async function mutateBatch(engine, ns, mutationName, paramsList, fields) {
|
|
62
|
+
if (paramsList.length === 0) return;
|
|
63
|
+
const key = formatQualifiedKey(ns, mutationName);
|
|
64
|
+
const selected = selectAll(fields);
|
|
65
|
+
const failed = (await engine.mutate({
|
|
66
|
+
frame: { [key]: paramsList.map((params) => ({
|
|
67
|
+
params,
|
|
68
|
+
fields: selected
|
|
69
|
+
})) },
|
|
70
|
+
ctx: {
|
|
71
|
+
...makeCtx(ns, "mutate"),
|
|
72
|
+
identity: {
|
|
73
|
+
kind: "system",
|
|
74
|
+
id: "cortext:compact",
|
|
75
|
+
grants: { [ns]: [
|
|
76
|
+
"read",
|
|
77
|
+
"mutate",
|
|
78
|
+
"admin"
|
|
79
|
+
] }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
})).errors?.[0];
|
|
83
|
+
if (failed !== void 0) throw new Error(`cortext compact: batched ${mutationName} failed: ${failed.message}`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Hard ceiling on per-call compaction reads. Without this, queryAll
|
|
87
|
+
* pulls every SessionLog + Decision row into Node.js memory regardless of
|
|
88
|
+
* corpus size — fine at 100s of rows, an OOM risk at 10k+ once
|
|
89
|
+
* multi-project use accumulates. 10k is well above any realistic active
|
|
90
|
+
* corpus and still bounds memory at single-MB. Beyond it, compaction
|
|
91
|
+
* processes the most-recent 10k window per call; subsequent invocations
|
|
92
|
+
* pick up the rest. Project-scoped compaction (passing a `where: { project }`
|
|
93
|
+
* filter) is the proper long-term fix.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_COMPACT_SCAN = 1e4;
|
|
96
|
+
async function runCompact(engine, ns, retention, dryRun, opts = {}) {
|
|
97
|
+
const sessions = await queryAll(engine, ns, "SessionLog", {
|
|
98
|
+
where: { archived: { eq: false } },
|
|
99
|
+
limit: MAX_COMPACT_SCAN,
|
|
100
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }]
|
|
101
|
+
}, ["id", "project"]);
|
|
102
|
+
const keptPerProject = /* @__PURE__ */ new Map();
|
|
103
|
+
const retained = [];
|
|
104
|
+
const toArchive = [];
|
|
105
|
+
for (const session of sessions) {
|
|
106
|
+
const group = session.project ?? "";
|
|
107
|
+
const kept = keptPerProject.get(group) ?? 0;
|
|
108
|
+
if (kept < retention) {
|
|
109
|
+
keptPerProject.set(group, kept + 1);
|
|
110
|
+
retained.push(session);
|
|
111
|
+
} else toArchive.push(session);
|
|
112
|
+
}
|
|
113
|
+
const archivedSessionIds = new Set(toArchive.map((s) => s.id));
|
|
114
|
+
const decisions = await queryAll(engine, ns, "Decision", {
|
|
115
|
+
where: { archived: { eq: false } },
|
|
116
|
+
limit: MAX_COMPACT_SCAN
|
|
117
|
+
}, [
|
|
118
|
+
"id",
|
|
119
|
+
"sessionId",
|
|
120
|
+
"supersedes"
|
|
121
|
+
]);
|
|
122
|
+
const superseded = new Set(decisions.flatMap(supersededIds));
|
|
123
|
+
const decisionsToArchive = decisions.filter((d) => d.sessionId != null && archivedSessionIds.has(d.sessionId) || superseded.has(d.id));
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
await mutateBatch(engine, ns, "updateDecision", decisionsToArchive.map((d) => ({
|
|
126
|
+
id: d.id,
|
|
127
|
+
archived: true
|
|
128
|
+
})), ["id"]);
|
|
129
|
+
await mutateBatch(engine, ns, "updateSessionLog", toArchive.map((s) => ({
|
|
130
|
+
id: s.id,
|
|
131
|
+
archived: true
|
|
132
|
+
})), ["id"]);
|
|
133
|
+
}
|
|
134
|
+
const mailCutoff = new Date(Date.now() - (opts.mailMaxAgeMs ?? MAIL_ARCHIVE_AFTER_MS));
|
|
135
|
+
const ackedMail = await queryAll(engine, ns, "AgentMessage", {
|
|
136
|
+
where: {
|
|
137
|
+
status: { eq: "acked" },
|
|
138
|
+
archived: { eq: false },
|
|
139
|
+
updatedAt: { lt: mailCutoff.toISOString() }
|
|
140
|
+
},
|
|
141
|
+
limit: MAX_COMPACT_SCAN
|
|
142
|
+
}, ["id"]);
|
|
143
|
+
if (!dryRun) await mutateBatch(engine, ns, "updateAgentMessage", ackedMail.map((m) => ({
|
|
144
|
+
id: m.id,
|
|
145
|
+
archived: true
|
|
146
|
+
})), ["id"]);
|
|
147
|
+
return {
|
|
148
|
+
dryRun,
|
|
149
|
+
sessionsArchived: toArchive.length,
|
|
150
|
+
decisionsArchived: decisionsToArchive.length,
|
|
151
|
+
sessionsRetained: retained.length,
|
|
152
|
+
messagesArchived: ackedMail.length
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function parseBody(req) {
|
|
156
|
+
const raw = req.body;
|
|
157
|
+
if (raw === null || raw.length === 0) return { dryRun: false };
|
|
158
|
+
const text = raw.toString("utf8");
|
|
159
|
+
if (text.trim().length === 0) return { dryRun: false };
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(text);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { error: err instanceof Error ? err.message : "invalid JSON" };
|
|
165
|
+
}
|
|
166
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { dryRun: false };
|
|
167
|
+
return { dryRun: parsed.dryRun === true };
|
|
168
|
+
}
|
|
169
|
+
function registerCompactRoute(router, ctx, retention, namespace, auth = DEFAULT_AUTH) {
|
|
170
|
+
router.post("/compact", async (req) => {
|
|
171
|
+
if (auth.writes === "identity") {
|
|
172
|
+
const caps = grantedIn(grantOf(req.identity ?? null), namespace);
|
|
173
|
+
if (!caps.includes("admin") && !caps.includes("*")) return jsonResponse(403, {
|
|
174
|
+
code: "operator_only",
|
|
175
|
+
message: `POST /compact archives across every project and needs an operator (admin in '${namespace}')`
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const parsed = parseBody(req);
|
|
179
|
+
if ("error" in parsed) return jsonResponse(400, {
|
|
180
|
+
code: "bad_request",
|
|
181
|
+
message: parsed.error
|
|
182
|
+
});
|
|
183
|
+
const result = await runCompact(ctx.engine, namespace, retention, parsed.dryRun);
|
|
184
|
+
return jsonResponse(200, result);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/kernel-invariants.ts
|
|
190
|
+
/**
|
|
191
|
+
* Boot-time reporting for the namespaces the kernel cannot follow.
|
|
192
|
+
*
|
|
193
|
+
* The cortext kernel plugin scopes every CHECK constraint and partial index
|
|
194
|
+
* with a `namespace = <literal>` predicate, baked in at authoring time because
|
|
195
|
+
* a migration cannot read config (`KernelMigrationContext` is `{ sql, metric }`).
|
|
196
|
+
* So a deployment that renames a namespace gets NO invariants, silently: a
|
|
197
|
+
* constraint scoped to a namespace with no rows passes trivially, and an index
|
|
198
|
+
* whose predicate no query can satisfy is just a heap scan.
|
|
199
|
+
*
|
|
200
|
+
* This warns rather than refuses. Per-cohort context stores are a designed
|
|
201
|
+
* capability (`bootstrap-config.test.ts` names `codex` as an override target),
|
|
202
|
+
* so the knob keeps working and the consequence is stated at boot.
|
|
203
|
+
*
|
|
204
|
+
* The kernel's own `invariant-audit.ts` reads the catalog too, but answers a
|
|
205
|
+
* different question. HERE (coordinator): "the namespace I am CONFIGURED for
|
|
206
|
+
* is not the one the kernel scopes to"; only the coordinator knows
|
|
207
|
+
* `cfg.namespace`. THERE (kernel): "my scope literal is declared by no
|
|
208
|
+
* coordinator", plus objects dropped, left NOT VALID, or re-scoped.
|
|
209
|
+
*
|
|
210
|
+
* Neither re-scopes automatically; that is the opt-in `./repair.ts`, because a
|
|
211
|
+
* store that ran unscoped may hold rows violating the vocabulary, so
|
|
212
|
+
* `ADD CONSTRAINT` can fail on exactly the databases a repair exists for.
|
|
213
|
+
* Parameterising the literals alone would not help either: migrations are
|
|
214
|
+
* recorded once by `id`, so a later rename never re-scopes existing objects.
|
|
215
|
+
*/
|
|
216
|
+
/**
|
|
217
|
+
* Which configured namespaces the kernel does not cover. Empty is the
|
|
218
|
+
* healthy case.
|
|
219
|
+
*
|
|
220
|
+
* Pure and separately exported so the condition can be tested directly rather
|
|
221
|
+
* than through a lifecycle harness — the reporting below is a thin shell over
|
|
222
|
+
* it.
|
|
223
|
+
*/
|
|
224
|
+
function uncoveredNamespaces(cfg) {
|
|
225
|
+
return [{
|
|
226
|
+
field: "namespace",
|
|
227
|
+
configured: cfg.namespace,
|
|
228
|
+
scopedTo: CORTEXT_NAMESPACE
|
|
229
|
+
}, {
|
|
230
|
+
field: "architectureNamespace",
|
|
231
|
+
configured: cfg.architectureNamespace,
|
|
232
|
+
scopedTo: ARCHITECTURE_NAMESPACE
|
|
233
|
+
}].filter((c) => c.configured !== c.scopedTo);
|
|
234
|
+
}
|
|
235
|
+
/** The operator-facing sentence for one uncovered namespace. */
|
|
236
|
+
function uncoveredNamespaceMessage({ field, configured, scopedTo }) {
|
|
237
|
+
return `[cortext] ${field} is '${configured}', but the cortext kernel scopes its constraints and indexes to '${scopedTo}'. Every kernel invariant for '${configured}' is therefore INACTIVE — Todo/AgentMessage vocabularies are unenforced, the mailbox inbox index covers no rows, and AgentIdentity.kid uniqueness is NOT held. Writes succeed and queries answer, so nothing else will report this. Use '${scopedTo}', or accept the loss deliberately.`;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Emit the warnings at plugin start. Returns what it reported, so a caller (or
|
|
241
|
+
* a test) can assert on it without capturing the console.
|
|
242
|
+
*
|
|
243
|
+
* Both sinks on purpose: `metric` is the
|
|
244
|
+
* structured channel an operator can alert on, and the console line is what
|
|
245
|
+
* actually appears in the server journal next to substrate's own loader
|
|
246
|
+
* warnings, where someone will see it.
|
|
247
|
+
*/
|
|
248
|
+
function reportUncoveredNamespaces(cfg, metric, warn = console.warn) {
|
|
249
|
+
const uncovered = uncoveredNamespaces(cfg);
|
|
250
|
+
for (const entry of uncovered) {
|
|
251
|
+
metric("cortext.kernel_invariants_inactive", { ...entry });
|
|
252
|
+
warn(uncoveredNamespaceMessage(entry));
|
|
253
|
+
}
|
|
254
|
+
return uncovered;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/participants.ts
|
|
259
|
+
/** Whether the caller holds `admin` (or `*`) in the namespace it is calling. */
|
|
260
|
+
function isOperator(ctx) {
|
|
261
|
+
const caps = grantedIn(grantOf(ctx.identity), ctx.namespace);
|
|
262
|
+
return caps.includes("admin") || caps.includes("*");
|
|
263
|
+
}
|
|
264
|
+
function rowId(args) {
|
|
265
|
+
const raw = args.params?.id;
|
|
266
|
+
return typeof raw === "string" || typeof raw === "number" ? raw : void 0;
|
|
267
|
+
}
|
|
268
|
+
function callerKid(ctx) {
|
|
269
|
+
const id = ctx.identity?.id;
|
|
270
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* AgentMessage update/delete: the sender, the recipient, or an operator.
|
|
274
|
+
* The sender is the key that signed the send (`_metadata.createdBy`); the
|
|
275
|
+
* recipient is the kid on the addressed directory row. Archiving someone
|
|
276
|
+
* else's mail hides it from their inbox, and deleting it destroys it, so
|
|
277
|
+
* neither is a bystander's call.
|
|
278
|
+
*/
|
|
279
|
+
function messageParticipantsOnly(installCtx, name, inner) {
|
|
280
|
+
return async (ctx, args) => {
|
|
281
|
+
const id = rowId(args);
|
|
282
|
+
if (id === void 0 || isOperator(ctx)) return inner(ctx, args);
|
|
283
|
+
const ds = args.dataset ?? installCtx.dataset;
|
|
284
|
+
const message = await resolveEntityRow(ds, "AgentMessage", id);
|
|
285
|
+
if (message === null) return inner(ctx, args);
|
|
286
|
+
const caller = callerKid(ctx);
|
|
287
|
+
const sender = message._metadata?.createdBy?.id;
|
|
288
|
+
let recipient = null;
|
|
289
|
+
if (typeof message.recipient === "string" && message.recipient !== "") {
|
|
290
|
+
const { rows } = await ds.read("AgentIdentity", { id: message.recipient });
|
|
291
|
+
recipient = rows[0]?.kid ?? null;
|
|
292
|
+
}
|
|
293
|
+
if (caller !== null && (caller === sender || caller === recipient)) return inner(ctx, args);
|
|
294
|
+
throw new ResolverFrameError("not_participant", `${name}: only this message's sender ('${String(sender ?? "unknown")}') or recipient ('${String(recipient ?? "unknown")}'), or an operator, may change or delete it`, { id: message.id });
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* AgentIdentity update: the identity itself (the caller's kid is the row's
|
|
299
|
+
* kid) or an operator. `kid` is the row's identity, so changing it is an
|
|
300
|
+
* operator act even on your own row: otherwise a row could be renamed onto a
|
|
301
|
+
* kid whose mail it would then receive.
|
|
302
|
+
*/
|
|
303
|
+
function identitySelfOnly(installCtx, name, inner) {
|
|
304
|
+
return async (ctx, args) => {
|
|
305
|
+
const id = rowId(args);
|
|
306
|
+
if (id === void 0 || isOperator(ctx)) return inner(ctx, args);
|
|
307
|
+
const ds = args.dataset ?? installCtx.dataset;
|
|
308
|
+
const row = await resolveEntityRow(ds, "AgentIdentity", id);
|
|
309
|
+
if (row === null) return inner(ctx, args);
|
|
310
|
+
const params = args.params ?? {};
|
|
311
|
+
if ("kid" in params && params.kid !== row.kid) throw new ResolverFrameError("operator_only", `${name}: 'kid' is the row's identity and only an operator may change it`, { field: "kid" });
|
|
312
|
+
if (callerKid(ctx) === row.kid) return inner(ctx, args);
|
|
313
|
+
throw new ResolverFrameError("not_self", `${name}: only '${String(row.kid)}' itself, or an operator, may edit this identity row`, { id: row.id });
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** AgentIdentity delete: operators only. Mail hangs off these rows. */
|
|
317
|
+
function operatorOnly(name, inner) {
|
|
318
|
+
return async (ctx, args) => {
|
|
319
|
+
if (isOperator(ctx)) return inner(ctx, args);
|
|
320
|
+
throw new ResolverFrameError("operator_only", `${name}: only an operator may delete an identity row; retire it with active: false instead`, {});
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/mailbox.ts
|
|
326
|
+
const TYPE = "AgentMessage";
|
|
327
|
+
const DIRECTORY_TYPE = "AgentIdentity";
|
|
328
|
+
/**
|
|
329
|
+
* Fields whose writes are reserved to the transition mutations. Checked by
|
|
330
|
+
* `mailboxWriteGuard` against update params; there is no create verb to deny
|
|
331
|
+
* wholesale — the mutation is not registered.
|
|
332
|
+
*/
|
|
333
|
+
const TRANSITION_FIELDS = [
|
|
334
|
+
"status",
|
|
335
|
+
"ackedBy",
|
|
336
|
+
"ackedAt"
|
|
337
|
+
];
|
|
338
|
+
/**
|
|
339
|
+
* Fields written once, by sendAgentMessage, and never edited. `instance`
|
|
340
|
+
* is the sender's own account of which writer it was; letting a later update
|
|
341
|
+
* change it would make it say whatever the last editor wanted.
|
|
342
|
+
*/
|
|
343
|
+
const SEND_ONLY_FIELDS = ["instance"];
|
|
344
|
+
/**
|
|
345
|
+
* The only fields updateAgentMessage may write. Everything else a
|
|
346
|
+
* message carries is fixed at send; a denylist would let any agent rewrite
|
|
347
|
+
* `recipient` to its own identity row and then ack, or edit `body` after delivery.
|
|
348
|
+
* `id` addresses the row; `_`-prefixed keys are request controls, not fields.
|
|
349
|
+
*/
|
|
350
|
+
const UPDATABLE_FIELDS = /* @__PURE__ */ new Set([
|
|
351
|
+
"id",
|
|
352
|
+
"archived",
|
|
353
|
+
"tags"
|
|
354
|
+
]);
|
|
355
|
+
/**
|
|
356
|
+
* sendAgentMessage's introspection/registration param schema. `to`
|
|
357
|
+
* wire-accepts a single kid string, a list of kids, or '*' (broadcast to
|
|
358
|
+
* every active identity except the sender); declared as list-of-string for
|
|
359
|
+
* introspection, the resolver normalises at runtime.
|
|
360
|
+
*/
|
|
361
|
+
const SEND_PARAMS = {
|
|
362
|
+
to: field.list(field.string()),
|
|
363
|
+
subject: field.string(),
|
|
364
|
+
body: field.string({ indexed: false }),
|
|
365
|
+
inReplyTo: field.string({ nullable: true }),
|
|
366
|
+
tags: field.list(field.string(), { nullable: true }),
|
|
367
|
+
instance: field.string({ nullable: true })
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* Runtime allowlist derived from the declared params — one
|
|
371
|
+
* authoritative declaration, so the schema can never advertise a param the
|
|
372
|
+
* resolver rejects (or vice versa).
|
|
373
|
+
*/
|
|
374
|
+
const SEND_PARAM_NAMES = new Set(Object.keys(SEND_PARAMS));
|
|
375
|
+
/**
|
|
376
|
+
* Upper bound on a directory read. The cohort is ~10 identities; 1000 exists
|
|
377
|
+
* so a `'*'` fan-out can never be silently truncated by a kernel default
|
|
378
|
+
* limit, not because that scale is anticipated.
|
|
379
|
+
*/
|
|
380
|
+
const DIRECTORY_READ_LIMIT = 1e3;
|
|
381
|
+
/**
|
|
382
|
+
* Policy over AgentMessage's hand-registered surface — `installMailboxMutations`
|
|
383
|
+
* composes it with the plugin policy, the way defineNamespace composes type
|
|
384
|
+
* policies for autoCRUD types. Retires the direct write paths:
|
|
385
|
+
* - create → always denied. The verb is not registered (autoCrud:false), so
|
|
386
|
+
* this branch only fires if something re-registers it; the schema never
|
|
387
|
+
* advertises it.
|
|
388
|
+
* - update → only archived and tags may change; a transition field
|
|
389
|
+
* or anything fixed at send is denied, each with its own reason.
|
|
390
|
+
*/
|
|
391
|
+
const mailboxWriteGuard = (ctx, args) => {
|
|
392
|
+
const ns = ctx.namespace;
|
|
393
|
+
if (ctx.verb === "create") return {
|
|
394
|
+
allow: false,
|
|
395
|
+
reason: `${ns}/AgentMessage: direct createAgentMessage is retired — use ${ns}/sendAgentMessage (kid-addressed; to: '<kid>' | ['<kid>', …] | '*')`
|
|
396
|
+
};
|
|
397
|
+
if (ctx.verb === "update") {
|
|
398
|
+
const params = args.params ?? {};
|
|
399
|
+
for (const fieldName of TRANSITION_FIELDS) if (fieldName in params) return {
|
|
400
|
+
allow: false,
|
|
401
|
+
reason: `${ns}/AgentMessage: '${fieldName}' transitions only via ${ns}/markAgentMessageRead / ${ns}/ackAgentMessage (recipient-only)`
|
|
402
|
+
};
|
|
403
|
+
for (const fieldName of SEND_ONLY_FIELDS) if (fieldName in params) return {
|
|
404
|
+
allow: false,
|
|
405
|
+
reason: `${ns}/AgentMessage: '${fieldName}' is set once by ${ns}/sendAgentMessage and cannot be edited`
|
|
406
|
+
};
|
|
407
|
+
for (const fieldName of Object.keys(params)) {
|
|
408
|
+
if (UPDATABLE_FIELDS.has(fieldName) || fieldName.startsWith("_")) continue;
|
|
409
|
+
return {
|
|
410
|
+
allow: false,
|
|
411
|
+
reason: `${ns}/AgentMessage: '${fieldName}' is fixed at send; updateAgentMessage may change only archived and tags. Reply with ${ns}/sendAgentMessage instead.`
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { allow: true };
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* Resolve the `to` param into directory rows, validate-first so a bad address
|
|
419
|
+
* rejects before any row is written:
|
|
420
|
+
* - wildcard (`['*']` canonical; bare `'*'` accepted as sugar) → every
|
|
421
|
+
* active identity except the sender; empty → no_recipients. Mixing the
|
|
422
|
+
* wildcard with explicit kids is ambiguous → wildcard_mixed.
|
|
423
|
+
* - kid / kid[] → deduped; unknown kid → unknown_recipient; inactive →
|
|
424
|
+
* inactive_recipient.
|
|
425
|
+
*/
|
|
426
|
+
/**
|
|
427
|
+
* Whether `to:'*'` should fan a copy out to this identity.
|
|
428
|
+
*
|
|
429
|
+
* ABSENT AND NULL BOTH MEAN ELIGIBLE, and that is the whole reason this is a
|
|
430
|
+
* predicate rather than a `where` clause. `broadcastEligible` is declared
|
|
431
|
+
* `default: true`, but a default is applied on WRITE — every AgentIdentity row
|
|
432
|
+
* created before the field existed carries no such key and reads back as
|
|
433
|
+
* `null`.
|
|
434
|
+
*
|
|
435
|
+
* So `where: { broadcastEligible: { eq: true } }` would match NONE of them
|
|
436
|
+
* (and `eq: null` matches nothing either, under SQL three-valued logic),
|
|
437
|
+
* turning every broadcast into `no_recipients`. Opting OUT has to be an
|
|
438
|
+
* explicit `false`.
|
|
439
|
+
*
|
|
440
|
+
* Exported because it cannot be exercised through `createAgentIdentity`: that
|
|
441
|
+
* path applies the declared default, so a row written through the schema always
|
|
442
|
+
* carries the key. The pre-field shape is only reachable directly, which is
|
|
443
|
+
* what `mailbox-broadcast-eligibility.test.ts` does.
|
|
444
|
+
*/
|
|
445
|
+
function isBroadcastEligible(row) {
|
|
446
|
+
return row.broadcastEligible !== false;
|
|
447
|
+
}
|
|
448
|
+
async function resolveRecipients(ds, to, senderId, ns) {
|
|
449
|
+
const list = Array.isArray(to) ? to : [to];
|
|
450
|
+
if (list.includes("*")) {
|
|
451
|
+
if (list.length > 1) throw new ResolverFrameError("wildcard_mixed", "sendAgentMessage: the '*' wildcard cannot be combined with explicit kids — it already reaches every active identity", { field: "to" });
|
|
452
|
+
const { rows } = await ds.read(DIRECTORY_TYPE, {
|
|
453
|
+
where: { active: { eq: true } },
|
|
454
|
+
limit: DIRECTORY_READ_LIMIT
|
|
455
|
+
});
|
|
456
|
+
const others = rows.filter((r) => r.kid !== senderId);
|
|
457
|
+
const eligible = others.filter(isBroadcastEligible);
|
|
458
|
+
if (eligible.length === 0) throw new ResolverFrameError("no_recipients", others.length === 0 ? "sendAgentMessage: to:'*' found no active cortext/AgentIdentity besides the sender" : `sendAgentMessage: to:'*' found ${others.length} active cortext/AgentIdentity besides the sender, but all of them have broadcastEligible: false`, {
|
|
459
|
+
sender: senderId,
|
|
460
|
+
active: others.length
|
|
461
|
+
});
|
|
462
|
+
return eligible;
|
|
463
|
+
}
|
|
464
|
+
if (list.length === 0 || !list.every((k) => typeof k === "string" && k.length > 0)) throw new ResolverFrameError("missing_required_field", "sendAgentMessage: params.to must be a kid, a non-empty list of kids, or ['*']", { field: "to" });
|
|
465
|
+
const kids = [...new Set(list)];
|
|
466
|
+
const { rows } = await ds.read(DIRECTORY_TYPE, {
|
|
467
|
+
where: { kid: { in: kids } },
|
|
468
|
+
limit: DIRECTORY_READ_LIMIT
|
|
469
|
+
});
|
|
470
|
+
const byKid = /* @__PURE__ */ new Map();
|
|
471
|
+
for (const r of rows) {
|
|
472
|
+
const kid = String(r.kid);
|
|
473
|
+
if (byKid.has(kid)) throw new ResolverFrameError("ambiguous_recipient", `sendAgentMessage: kid '${kid}' matches more than one ${ns}/AgentIdentity row — directory uniqueness is violated; resolve the duplicate before sending`, { kid });
|
|
474
|
+
byKid.set(kid, r);
|
|
475
|
+
}
|
|
476
|
+
return kids.map((kid) => {
|
|
477
|
+
const row = byKid.get(kid);
|
|
478
|
+
if (row === void 0) throw new ResolverFrameError("unknown_recipient", `sendAgentMessage: no ${ns}/AgentIdentity row with kid '${kid}'`, { kid });
|
|
479
|
+
if (row.active !== true) throw new ResolverFrameError("inactive_recipient", `sendAgentMessage: identity '${kid}' is inactive — messages to retired identities are refused`, { kid });
|
|
480
|
+
return row;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
function sendResolver(installCtx, createMessage) {
|
|
484
|
+
return async (ctx, args) => {
|
|
485
|
+
const params = args.params ?? {};
|
|
486
|
+
for (const key of Object.keys(params)) if (!SEND_PARAM_NAMES.has(key)) throw new ResolverFrameError("unknown_field", `sendAgentMessage: unknown param '${key}'. Allowed: ${[...SEND_PARAM_NAMES].sort().join(", ")}.`, { field: key });
|
|
487
|
+
const ds = args.dataset ?? installCtx.dataset;
|
|
488
|
+
const actor = resolveIdentity(ctx);
|
|
489
|
+
const recipients = await resolveRecipients(ds, params.to, actor.id, ctx.namespace);
|
|
490
|
+
return Promise.all(recipients.map((recipient) => {
|
|
491
|
+
const rowParams = {
|
|
492
|
+
recipient: recipient.id,
|
|
493
|
+
subject: params.subject,
|
|
494
|
+
body: params.body
|
|
495
|
+
};
|
|
496
|
+
if (params.inReplyTo !== void 0) rowParams.inReplyTo = params.inReplyTo;
|
|
497
|
+
if (params.tags !== void 0) rowParams.tags = params.tags;
|
|
498
|
+
if (params.instance !== void 0) rowParams.instance = params.instance;
|
|
499
|
+
return createMessage(ctx, {
|
|
500
|
+
...args,
|
|
501
|
+
params: rowParams
|
|
502
|
+
});
|
|
503
|
+
}));
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* One entry per transition: the Record forces exhaustiveness —
|
|
508
|
+
* adding a TransitionTarget without a spec is a compile error rather than a
|
|
509
|
+
* silent no-op.
|
|
510
|
+
*/
|
|
511
|
+
const TRANSITIONS = {
|
|
512
|
+
read: {
|
|
513
|
+
mutationName: "markAgentMessageRead",
|
|
514
|
+
isNoOp: (status) => status !== "unread",
|
|
515
|
+
stamp: () => ({})
|
|
516
|
+
},
|
|
517
|
+
acked: {
|
|
518
|
+
mutationName: "ackAgentMessage",
|
|
519
|
+
isNoOp: (status) => status === "acked",
|
|
520
|
+
stamp: (actor, at) => ({
|
|
521
|
+
ackedBy: actor.id,
|
|
522
|
+
ackedAt: at
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
function transitionResolver(installCtx, target) {
|
|
527
|
+
const { mutationName: name, isNoOp, stamp } = TRANSITIONS[target];
|
|
528
|
+
return async (ctx, args) => {
|
|
529
|
+
const params = args.params ?? {};
|
|
530
|
+
for (const key of Object.keys(params)) if (key !== "id") throw new ResolverFrameError("unknown_param", `${name}: unknown param '${key}'. Allowed: id.`, { field: key });
|
|
531
|
+
const ds = args.dataset ?? installCtx.dataset;
|
|
532
|
+
const raw = params.id;
|
|
533
|
+
const id = typeof raw === "string" || typeof raw === "number" ? raw : void 0;
|
|
534
|
+
if (id === void 0) throw new ResolverFrameError("missing_id", `${name}: params.id is required`, { field: "id" });
|
|
535
|
+
const message = await resolveEntityRow(ds, TYPE, id);
|
|
536
|
+
if (message === null) return null;
|
|
537
|
+
const recipientId = message.recipient;
|
|
538
|
+
if (typeof recipientId !== "string" || recipientId.length === 0) throw new ResolverFrameError("no_recipient", `${name}: message has no resolvable recipient (broadcast-era or orphaned row) — transitions are recipient-only`, { id: message.id });
|
|
539
|
+
const { rows: identityRows } = await ds.read(DIRECTORY_TYPE, { id: recipientId });
|
|
540
|
+
const identityRow = identityRows[0];
|
|
541
|
+
if (identityRow === void 0) throw new ResolverFrameError("no_recipient", `${name}: the recipient identity row no longer exists`, { id: message.id });
|
|
542
|
+
const actor = resolveIdentity(ctx);
|
|
543
|
+
if (actor.id !== identityRow.kid) throw new ResolverFrameError("not_recipient", `${name}: only the recipient ('${String(identityRow.kid)}') may transition this message`, { recipient: identityRow.kid });
|
|
544
|
+
if (isNoOp(message.status)) return message;
|
|
545
|
+
const at = ctx.now.toISOString();
|
|
546
|
+
return applyUpdatePatch(ds, TYPE, message.id, {
|
|
547
|
+
status: target,
|
|
548
|
+
...stamp(actor, at)
|
|
549
|
+
}, ctx, args.onWrite);
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Register AgentMessage's whole surface. The type declares `autoCrud: false`,
|
|
554
|
+
* so defineNamespace installed nothing for it and this function owns the lot:
|
|
555
|
+
* the type entry, get/update/delete — same resolvers and same params/returns
|
|
556
|
+
* installAutoCrud would have emitted — plus the custom family (sendAgentMessage,
|
|
557
|
+
* markAgentMessageRead, ackAgentMessage). What is deliberately absent is
|
|
558
|
+
* `createAgentMessage`: an advertised mutation the route only denies teaches
|
|
559
|
+
* callers to build a shape that cannot work, and the refusal reads like a
|
|
560
|
+
* grant problem. Field defs arrive pre-augmentation; the reserved engine
|
|
561
|
+
* fields are added here, the way defineNamespace adds them for autoCRUD types.
|
|
562
|
+
*/
|
|
563
|
+
function installMailboxMutations(ctx, messageFields) {
|
|
564
|
+
const fields = {
|
|
565
|
+
...messageFields,
|
|
566
|
+
...RESERVED_AUTOCRUD_FIELDS
|
|
567
|
+
};
|
|
568
|
+
const writePolicy = ctx.pluginPolicy === void 0 ? mailboxWriteGuard : and(ctx.pluginPolicy, mailboxWriteGuard);
|
|
569
|
+
ctx.registry.register(TYPE, {
|
|
570
|
+
resolver: listResolver(TYPE, ctx.dataset, { fields }),
|
|
571
|
+
kind: "type",
|
|
572
|
+
policy: writePolicy,
|
|
573
|
+
fields
|
|
574
|
+
});
|
|
575
|
+
ctx.registry.register(`get${TYPE}`, {
|
|
576
|
+
resolver: getResolver(TYPE, ctx.dataset),
|
|
577
|
+
kind: "mutation",
|
|
578
|
+
verb: "get",
|
|
579
|
+
params: { id: field.string() },
|
|
580
|
+
returns: field.struct(fields, { nullable: true }),
|
|
581
|
+
policy: writePolicy,
|
|
582
|
+
fields
|
|
583
|
+
});
|
|
584
|
+
ctx.registry.register(`update${TYPE}`, {
|
|
585
|
+
resolver: messageParticipantsOnly(ctx, `update${TYPE}`, updateResolver(TYPE, ctx.dataset, ctx.edges, { fields })),
|
|
586
|
+
kind: "mutation",
|
|
587
|
+
verb: "update",
|
|
588
|
+
params: {
|
|
589
|
+
id: field.string(),
|
|
590
|
+
...fields
|
|
591
|
+
},
|
|
592
|
+
returns: field.struct(fields, { nullable: true }),
|
|
593
|
+
policy: writePolicy,
|
|
594
|
+
fields
|
|
595
|
+
});
|
|
596
|
+
ctx.registry.register(`delete${TYPE}`, {
|
|
597
|
+
resolver: messageParticipantsOnly(ctx, `delete${TYPE}`, deleteResolver(TYPE, ctx.dataset, ctx.edges)),
|
|
598
|
+
kind: "mutation",
|
|
599
|
+
verb: "delete",
|
|
600
|
+
params: { id: field.string() },
|
|
601
|
+
returns: field.struct({
|
|
602
|
+
id: field.string(),
|
|
603
|
+
deleted: field.boolean()
|
|
604
|
+
}),
|
|
605
|
+
policy: writePolicy
|
|
606
|
+
});
|
|
607
|
+
const createMessage = createResolverWithEdges(TYPE, ctx.dataset, ctx.edges, { fields });
|
|
608
|
+
const requireSender = or(requireIdentity(), denyAll("sendAgentMessage requires an authenticated identity — the sender is the signed principal (spec §3)"));
|
|
609
|
+
const sendPolicy = ctx.pluginPolicy !== void 0 ? and(ctx.pluginPolicy, requireSender) : requireSender;
|
|
610
|
+
const transitionPolicy = ctx.pluginPolicy;
|
|
611
|
+
const messageStruct = field.struct(fields);
|
|
612
|
+
ctx.registry.register("sendAgentMessage", {
|
|
613
|
+
resolver: sendResolver(ctx, createMessage),
|
|
614
|
+
kind: "mutation",
|
|
615
|
+
verb: "create",
|
|
616
|
+
params: SEND_PARAMS,
|
|
617
|
+
returns: field.list(messageStruct),
|
|
618
|
+
policy: sendPolicy,
|
|
619
|
+
fields
|
|
620
|
+
});
|
|
621
|
+
for (const [target, spec] of Object.entries(TRANSITIONS)) ctx.registry.register(spec.mutationName, {
|
|
622
|
+
resolver: transitionResolver(ctx, target),
|
|
623
|
+
kind: "mutation",
|
|
624
|
+
verb: "update",
|
|
625
|
+
params: { id: field.string() },
|
|
626
|
+
returns: field.struct(fields, { nullable: true }),
|
|
627
|
+
...transitionPolicy !== void 0 ? { policy: transitionPolicy } : {},
|
|
628
|
+
fields
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/nl-rulings.ts
|
|
634
|
+
/**
|
|
635
|
+
* How people talk about cortext, for substrate's NL parser and judge. Curated
|
|
636
|
+
* operator rulings, kept here so the eval and production read one copy, owned
|
|
637
|
+
* by the namespace. Declared via `defineNamespace` annotations; substrate's NL
|
|
638
|
+
* layer validates them and the runtime can override per key.
|
|
639
|
+
*/
|
|
640
|
+
const CORTEXT_NL_RULINGS = {
|
|
641
|
+
unscopedTypes: [
|
|
642
|
+
"AgentIdentity",
|
|
643
|
+
"AgentMessage",
|
|
644
|
+
"Skill"
|
|
645
|
+
],
|
|
646
|
+
liveStatus: { Todo: "open" },
|
|
647
|
+
timeField: { SessionLog: "startedAt" },
|
|
648
|
+
typeAliases: {
|
|
649
|
+
inbox: "AgentMessage",
|
|
650
|
+
mail: "AgentMessage",
|
|
651
|
+
agents: "AgentIdentity",
|
|
652
|
+
agent: "AgentIdentity",
|
|
653
|
+
session: "SessionLog",
|
|
654
|
+
sessions: "SessionLog",
|
|
655
|
+
anchor: "AnchorIntent",
|
|
656
|
+
rule: "AgentRule",
|
|
657
|
+
rules: "AgentRule",
|
|
658
|
+
task: "Todo",
|
|
659
|
+
tasks: "Todo",
|
|
660
|
+
decide: "Decision",
|
|
661
|
+
decided: "Decision"
|
|
662
|
+
},
|
|
663
|
+
fieldAliases: {
|
|
664
|
+
repo: "project",
|
|
665
|
+
repository: "project"
|
|
666
|
+
},
|
|
667
|
+
valueAliases: {
|
|
668
|
+
"in force": [{
|
|
669
|
+
type: "Decision",
|
|
670
|
+
field: "status",
|
|
671
|
+
eq: "active"
|
|
672
|
+
}],
|
|
673
|
+
"signal-gated": [{
|
|
674
|
+
type: "Todo",
|
|
675
|
+
field: "title",
|
|
676
|
+
startsWith: "[SG]"
|
|
677
|
+
}],
|
|
678
|
+
ship: [{
|
|
679
|
+
type: "Todo",
|
|
680
|
+
field: "resolution",
|
|
681
|
+
eq: "completed"
|
|
682
|
+
}],
|
|
683
|
+
shipped: [{
|
|
684
|
+
type: "Todo",
|
|
685
|
+
field: "resolution",
|
|
686
|
+
eq: "completed"
|
|
687
|
+
}],
|
|
688
|
+
drop: [{
|
|
689
|
+
type: "Todo",
|
|
690
|
+
field: "resolution",
|
|
691
|
+
in: ["wont_do", "obsolete"]
|
|
692
|
+
}],
|
|
693
|
+
dropped: [{
|
|
694
|
+
type: "Todo",
|
|
695
|
+
field: "resolution",
|
|
696
|
+
in: ["wont_do", "obsolete"]
|
|
697
|
+
}]
|
|
698
|
+
},
|
|
699
|
+
typeNotes: {
|
|
700
|
+
SessionLog: "a record of one working session: what was done, when, on which branch",
|
|
701
|
+
Decision: "a choice that was made, with its rationale",
|
|
702
|
+
Todo: "a tracked piece of work",
|
|
703
|
+
Memory: "a durable fact an agent recorded to remember",
|
|
704
|
+
AgentMessage: "mail between agents; the sender is createdById",
|
|
705
|
+
AgentIdentity: "a registered agent: kid (name@host), display name, project",
|
|
706
|
+
Concept: "an operational concept, with a kind",
|
|
707
|
+
AgentRule: "a standing rule agents follow"
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
//#endregion
|
|
712
|
+
//#region src/namespace.ts
|
|
713
|
+
/**
|
|
714
|
+
* Decision lifecycle states this vocabulary DOCUMENTS. ADVISORY, not enforced,
|
|
715
|
+
* like `AgentRule.category`. This is a list, not a census: nothing reconciles
|
|
716
|
+
* it against the store, so it never claims what is "in use".
|
|
717
|
+
*
|
|
718
|
+
* A free string is the honest encoding of a taxonomy nobody designed. An enum
|
|
719
|
+
* freezes it, and agents then either invent undeclared synonyms or pick the
|
|
720
|
+
* nearest legal word. `open` names a decision recorded before it was made.
|
|
721
|
+
*
|
|
722
|
+
* Nothing branches on `status` today (cold-start projects and renders it), but
|
|
723
|
+
* that is not the argument: `status` is a conventionally mechanical name, so a
|
|
724
|
+
* future consumer is likely to branch on it.
|
|
725
|
+
*
|
|
726
|
+
* THE DIRECTION MATTERS. Widening cannot invalidate stored data; it only
|
|
727
|
+
* surprises readers. Narrowing STRANDS rows — readable, not round-trippable,
|
|
728
|
+
* and selectively so, since a partial update avoiding the field still succeeds
|
|
729
|
+
* while a read-modify-write client gets `invalid_enum_value` on a row it never
|
|
730
|
+
* touched. So re-adding `field.enum` here later is not a symmetric undo. Do
|
|
731
|
+
* not do it.
|
|
732
|
+
*
|
|
733
|
+
* Add to this list when a genuinely NEW state earns its place; it feeds the
|
|
734
|
+
* onboarding contract, so what is here is what an agent is taught. `accepted`
|
|
735
|
+
* is deliberately an alias of `active` rather than a state of its own.
|
|
736
|
+
*/
|
|
737
|
+
const DECISION_STATUS_VOCAB = [
|
|
738
|
+
"open",
|
|
739
|
+
"active",
|
|
740
|
+
"superseded",
|
|
741
|
+
"deferred"
|
|
742
|
+
];
|
|
743
|
+
/**
|
|
744
|
+
* Synonyms the server normalises on every write and filter. `field.vocab` with
|
|
745
|
+
* `strict: false` is NOT the enum warned against above: unknown values are
|
|
746
|
+
* still admitted, so nothing narrows and nothing strands. It only maps the
|
|
747
|
+
* words agents reach for onto the state they mean. `proposed` is a decision
|
|
748
|
+
* recorded before it was made, which is what `open` documents.
|
|
749
|
+
*/
|
|
750
|
+
const DECISION_STATUS_ALIASES = {
|
|
751
|
+
accepted: "active",
|
|
752
|
+
adopted: "active",
|
|
753
|
+
decided: "active",
|
|
754
|
+
proposed: "open"
|
|
755
|
+
};
|
|
756
|
+
/**
|
|
757
|
+
* Words agents reach for when a Todo stops, mapped onto lifecycle + outcome.
|
|
758
|
+
* Each closing word sets `status: closed` AND the resolution it
|
|
759
|
+
* means, so `status: done` still works and now records WHY. A caller that
|
|
760
|
+
* also sends a contradicting `resolution` is refused (`conflicting_implication`).
|
|
761
|
+
*/
|
|
762
|
+
const TODO_STATUS_ALIASES = {
|
|
763
|
+
done: "closed",
|
|
764
|
+
finished: "closed",
|
|
765
|
+
completed: "closed",
|
|
766
|
+
shipped: "closed",
|
|
767
|
+
cancelled: "closed",
|
|
768
|
+
dropped: "closed",
|
|
769
|
+
superseded: "closed",
|
|
770
|
+
not_started: "open"
|
|
771
|
+
};
|
|
772
|
+
const TODO_STATUS_IMPLIES = {
|
|
773
|
+
done: { resolution: "completed" },
|
|
774
|
+
finished: { resolution: "completed" },
|
|
775
|
+
completed: { resolution: "completed" },
|
|
776
|
+
shipped: { resolution: "completed" },
|
|
777
|
+
cancelled: { resolution: "wont_do" },
|
|
778
|
+
dropped: { resolution: "wont_do" },
|
|
779
|
+
superseded: { resolution: "superseded" }
|
|
780
|
+
};
|
|
781
|
+
const TODO_RESOLUTION_ALIASES = {
|
|
782
|
+
done: "completed",
|
|
783
|
+
shipped: "completed",
|
|
784
|
+
wontfix: "wont_do",
|
|
785
|
+
declined: "wont_do",
|
|
786
|
+
dup: "duplicate",
|
|
787
|
+
moot: "obsolete"
|
|
788
|
+
};
|
|
789
|
+
/**
|
|
790
|
+
* AgentRule severity — CLOSED, unlike `category`. Cold-start branches on it:
|
|
791
|
+
* `must` rules render in the binding brief. `high` and `medium` are aliases
|
|
792
|
+
* rather than stranded values.
|
|
793
|
+
*/
|
|
794
|
+
const AGENT_RULE_SEVERITY_VOCAB = ["must", "should"];
|
|
795
|
+
const AGENT_RULE_SEVERITY_ALIASES = {
|
|
796
|
+
high: "must",
|
|
797
|
+
medium: "should"
|
|
798
|
+
};
|
|
799
|
+
/**
|
|
800
|
+
* AgentRule categories. ADVISORY, not enforced.
|
|
801
|
+
*
|
|
802
|
+
* An enum here arrives narrower than the data and freezes the taxonomy. The
|
|
803
|
+
* damage is not a write rejection anyone notices but SILENT SUBSTITUTION:
|
|
804
|
+
* agents pick the nearest legal word and move on, and the taxonomy flattens
|
|
805
|
+
* toward whatever is allowed. Nothing consumes `category` mechanically —
|
|
806
|
+
* cold-start projects it and renders it, never groups or filters on it — and
|
|
807
|
+
* `ArchitectureConcern.category` in this same file is a free string too.
|
|
808
|
+
* Widening an enum would only move the wall to the next category.
|
|
809
|
+
*
|
|
810
|
+
* Add to this list when a genuinely new category earns its place; it feeds the
|
|
811
|
+
* onboarding contract so agents see the real taxonomy. Do NOT re-add
|
|
812
|
+
* `field.enum` over it.
|
|
813
|
+
*/
|
|
814
|
+
const AGENT_RULE_CATEGORY_VOCAB = [
|
|
815
|
+
"git",
|
|
816
|
+
"testing",
|
|
817
|
+
"style",
|
|
818
|
+
"process",
|
|
819
|
+
"tooling",
|
|
820
|
+
"communication",
|
|
821
|
+
"investigation",
|
|
822
|
+
"migration-patterns",
|
|
823
|
+
"permissions",
|
|
824
|
+
"scope-discipline",
|
|
825
|
+
"side-findings",
|
|
826
|
+
"verification"
|
|
827
|
+
];
|
|
828
|
+
/**
|
|
829
|
+
* HostInstall.fragmentState: the cold-start fragment drift check's own verdict,
|
|
830
|
+
* plus `source` for the repo that ships the fragment.
|
|
831
|
+
*/
|
|
832
|
+
const HOST_INSTALL_FRAGMENT_STATES = [
|
|
833
|
+
"current",
|
|
834
|
+
"stale",
|
|
835
|
+
"missing",
|
|
836
|
+
"undigested",
|
|
837
|
+
"uncomparable",
|
|
838
|
+
"source",
|
|
839
|
+
"absent"
|
|
840
|
+
];
|
|
841
|
+
/** HostInstall's per-check verdicts: the worst finding each check produced. */
|
|
842
|
+
const HOST_INSTALL_CHECK_STATES = [
|
|
843
|
+
"ok",
|
|
844
|
+
"warn",
|
|
845
|
+
"unknown"
|
|
846
|
+
];
|
|
847
|
+
const CONCEPT_NAME_TTL_MS = 6e4;
|
|
848
|
+
/** Long-form length past which `embed.source` appends the long form to the synopsis. */
|
|
849
|
+
const LONGFORM_EMBED_CHARS = 1200;
|
|
850
|
+
function buildClaudeNamespace(ns) {
|
|
851
|
+
/**
|
|
852
|
+
* Concept names are near-static (concepts evolve rarely) but `Decision.embed.source`
|
|
853
|
+
* reads one on every Decision write — the most common write in this namespace —
|
|
854
|
+
* purely to inline a string into the embedding text. Cache the
|
|
855
|
+
* (conceptId → name) lookup with a short TTL: collapses repeated reads on the
|
|
856
|
+
* write path; a renamed Concept re-embeds on the next Decision write past the TTL.
|
|
857
|
+
*
|
|
858
|
+
* The cache lives in this closure — not at module level — so each namespace
|
|
859
|
+
* instance owns its cache lifecycle and `createCortextPlugin` stays the pure
|
|
860
|
+
* factory index.ts claims: no state shared across engines or
|
|
861
|
+
* surviving engine teardown in multi-engine processes.
|
|
862
|
+
*/
|
|
863
|
+
const conceptNameCache = /* @__PURE__ */ new Map();
|
|
864
|
+
async function resolveConceptName(ctx, conceptId) {
|
|
865
|
+
const now = Date.now();
|
|
866
|
+
const hit = conceptNameCache.get(conceptId);
|
|
867
|
+
if (hit !== void 0 && hit.expiresAt > now) return hit.name;
|
|
868
|
+
for (const [key, entry] of conceptNameCache) if (entry.expiresAt <= now) conceptNameCache.delete(key);
|
|
869
|
+
const concept = await ctx.read(ns, "Concept", { id: conceptId });
|
|
870
|
+
const name = concept !== null && typeof concept.name === "string" ? concept.name : null;
|
|
871
|
+
conceptNameCache.set(conceptId, {
|
|
872
|
+
name,
|
|
873
|
+
expiresAt: now + CONCEPT_NAME_TTL_MS
|
|
874
|
+
});
|
|
875
|
+
return name;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Embedding text for a synopsis-bearing row: the synopsis, followed by
|
|
879
|
+
* `long` once `long` passes `LONGFORM_EMBED_CHARS`; `long` alone when there
|
|
880
|
+
* is no synopsis. A long row's synopsis is a handle, not a summary (median
|
|
881
|
+
* 10% of the long form), so embedding it alone leaves the body unreachable
|
|
882
|
+
* by dense recall.
|
|
883
|
+
*/
|
|
884
|
+
function embedText(synopsis, long) {
|
|
885
|
+
const syn = typeof synopsis === "string" ? synopsis : "";
|
|
886
|
+
if (syn === "") return long;
|
|
887
|
+
return long.length > LONGFORM_EMBED_CHARS ? `${syn}\n\n${long}` : syn;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Join the parts of a `lexical.source` into one indexable string.
|
|
891
|
+
*
|
|
892
|
+
* Empty parts are dropped rather than left as blank paragraphs, and an
|
|
893
|
+
* all-empty result becomes `null` — the same un-indexable sentinel
|
|
894
|
+
* `embed.source` uses, so a row with no long-form text is left out of the
|
|
895
|
+
* index rather than entered at the empty string.
|
|
896
|
+
*/
|
|
897
|
+
function lexicalText(...parts) {
|
|
898
|
+
const kept = parts.filter((p) => typeof p === "string" && p.length > 0);
|
|
899
|
+
return kept.length === 0 ? null : kept.join("\n\n");
|
|
900
|
+
}
|
|
901
|
+
const types = {
|
|
902
|
+
SessionLog: {
|
|
903
|
+
fields: {
|
|
904
|
+
branch: field.string(),
|
|
905
|
+
startedAt: field.date(),
|
|
906
|
+
summary: field.string({ indexed: false }),
|
|
907
|
+
synopsis: field.string({ nullable: true }),
|
|
908
|
+
decisionsCount: field.number(),
|
|
909
|
+
packages: field.string(),
|
|
910
|
+
body: field.string({
|
|
911
|
+
nullable: true,
|
|
912
|
+
indexed: false
|
|
913
|
+
}),
|
|
914
|
+
tags: field.list(field.string(), { nullable: true }),
|
|
915
|
+
archived: field.boolean({ default: false }),
|
|
916
|
+
project: field.string({ nullable: true })
|
|
917
|
+
},
|
|
918
|
+
embed: { source: (r) => embedText(r.synopsis, typeof r.summary === "string" ? r.summary : "") },
|
|
919
|
+
/**
|
|
920
|
+
* The LEXICAL index takes the long form, where `embed` above takes the
|
|
921
|
+
* handle. That asymmetry is the whole point of the arm: `embed.source`
|
|
922
|
+
* is conventionally `synopsis || <long form>`, and a dense vector cannot
|
|
923
|
+
* recover the proper nouns the synopsis drops — most synopses drop a
|
|
924
|
+
* capitalised token that IS present in the long form.
|
|
925
|
+
*
|
|
926
|
+
* The synopsis is included ALONGSIDE the long form rather than replaced by
|
|
927
|
+
* it. A rare token in a synopsis is worth finding too, and unlike vector
|
|
928
|
+
* concatenation this is not dilution: a lexical index has no centroid to
|
|
929
|
+
* pull off-target, so more text only adds reachable tokens and costs index
|
|
930
|
+
* size.
|
|
931
|
+
*
|
|
932
|
+
* Note `lexical.source` takes `(row)` only — there is no `ctx`, so unlike
|
|
933
|
+
* `embed.source` it cannot follow a crossRef to inline related text.
|
|
934
|
+
*/
|
|
935
|
+
lexical: { source: (r) => lexicalText(r.synopsis, r.summary, r.body) }
|
|
936
|
+
},
|
|
937
|
+
Decision: {
|
|
938
|
+
fields: {
|
|
939
|
+
topic: field.string(),
|
|
940
|
+
choice: field.string(),
|
|
941
|
+
rationale: field.string({ indexed: false }),
|
|
942
|
+
sessionId: field.crossRef(ns, "SessionLog", {
|
|
943
|
+
nullable: true,
|
|
944
|
+
onDelete: "set_null"
|
|
945
|
+
}),
|
|
946
|
+
supersedes: field.crossRef(ns, "Decision", {
|
|
947
|
+
nullable: true,
|
|
948
|
+
onDelete: "set_null",
|
|
949
|
+
spread: true
|
|
950
|
+
}),
|
|
951
|
+
status: field.vocab(DECISION_STATUS_VOCAB, {
|
|
952
|
+
aliases: DECISION_STATUS_ALIASES,
|
|
953
|
+
strict: false,
|
|
954
|
+
default: "active"
|
|
955
|
+
}),
|
|
956
|
+
tags: field.list(field.string(), { nullable: true }),
|
|
957
|
+
revisitWhen: field.string({
|
|
958
|
+
nullable: true,
|
|
959
|
+
indexed: false
|
|
960
|
+
}),
|
|
961
|
+
establishesConcept: field.crossRef(ns, "Concept", {
|
|
962
|
+
nullable: true,
|
|
963
|
+
onDelete: "set_null",
|
|
964
|
+
spread: true,
|
|
965
|
+
expandCrossType: true
|
|
966
|
+
}),
|
|
967
|
+
archived: field.boolean({ default: false }),
|
|
968
|
+
project: field.string({ nullable: true })
|
|
969
|
+
},
|
|
970
|
+
embed: { source: async (r, ctx) => {
|
|
971
|
+
const base = `${typeof r.topic === "string" ? r.topic : ""}\n\n${typeof r.choice === "string" ? r.choice : ""}\n\n${typeof r.rationale === "string" ? r.rationale : ""}`;
|
|
972
|
+
if (ctx === void 0 || typeof r.establishesConcept !== "string") return base;
|
|
973
|
+
const conceptName = await resolveConceptName(ctx, r.establishesConcept);
|
|
974
|
+
return conceptName !== null ? `${base}\n\n[concept] ${conceptName}` : base;
|
|
975
|
+
} },
|
|
976
|
+
/**
|
|
977
|
+
* Decision is the one type whose `embed.source` is ALREADY the long form,
|
|
978
|
+
* so it gains least here — declared anyway, deliberately, so that "which
|
|
979
|
+
* types are lexically indexed" has no per-type exception to remember.
|
|
980
|
+
*
|
|
981
|
+
* It is not a no-op: `embed.source` inlines the linked Concept's NAME via
|
|
982
|
+
* `ctx.read`, and `lexical.source` gets no `ctx`, so the two texts differ
|
|
983
|
+
* on any Decision carrying `establishesConcept`.
|
|
984
|
+
*/
|
|
985
|
+
lexical: { source: (r) => lexicalText(r.topic, r.choice, r.rationale) }
|
|
986
|
+
},
|
|
987
|
+
Todo: { fields: {
|
|
988
|
+
title: field.string(),
|
|
989
|
+
area: field.string(),
|
|
990
|
+
status: field.vocab(TODO_LIFECYCLE_VOCAB, {
|
|
991
|
+
aliases: TODO_STATUS_ALIASES,
|
|
992
|
+
implies: TODO_STATUS_IMPLIES
|
|
993
|
+
}),
|
|
994
|
+
resolution: field.vocab(TODO_RESOLUTION_VOCAB, {
|
|
995
|
+
aliases: TODO_RESOLUTION_ALIASES,
|
|
996
|
+
nullable: true
|
|
997
|
+
}),
|
|
998
|
+
priority: field.enum(TODO_PRIORITY_VOCAB),
|
|
999
|
+
notes: field.string({
|
|
1000
|
+
nullable: true,
|
|
1001
|
+
indexed: false
|
|
1002
|
+
}),
|
|
1003
|
+
blockedBy: field.string({
|
|
1004
|
+
nullable: true,
|
|
1005
|
+
indexed: false
|
|
1006
|
+
}),
|
|
1007
|
+
revisitWhen: field.string({
|
|
1008
|
+
nullable: true,
|
|
1009
|
+
indexed: false
|
|
1010
|
+
}),
|
|
1011
|
+
body: field.string({
|
|
1012
|
+
nullable: true,
|
|
1013
|
+
indexed: false
|
|
1014
|
+
}),
|
|
1015
|
+
tags: field.list(field.string(), {
|
|
1016
|
+
nullable: true,
|
|
1017
|
+
indexed: true
|
|
1018
|
+
}),
|
|
1019
|
+
archived: field.boolean({ default: false }),
|
|
1020
|
+
targetDate: field.date({ nullable: true }),
|
|
1021
|
+
implementsDecision: field.crossRef(ns, "Decision", {
|
|
1022
|
+
nullable: true,
|
|
1023
|
+
onDelete: "set_null"
|
|
1024
|
+
}),
|
|
1025
|
+
owner: field.crossRef(ns, "AgentIdentity", {
|
|
1026
|
+
nullable: true,
|
|
1027
|
+
onDelete: "set_null"
|
|
1028
|
+
}),
|
|
1029
|
+
project: field.string({ nullable: true })
|
|
1030
|
+
} },
|
|
1031
|
+
AgentRule: {
|
|
1032
|
+
fields: {
|
|
1033
|
+
rule: field.string({ indexed: false }),
|
|
1034
|
+
category: field.vocab(AGENT_RULE_CATEGORY_VOCAB, { strict: false }),
|
|
1035
|
+
severity: field.vocab(AGENT_RULE_SEVERITY_VOCAB, {
|
|
1036
|
+
aliases: AGENT_RULE_SEVERITY_ALIASES,
|
|
1037
|
+
nullable: true
|
|
1038
|
+
}),
|
|
1039
|
+
active: field.boolean(),
|
|
1040
|
+
pinned: field.boolean({ nullable: true }),
|
|
1041
|
+
synopsis: field.string({ nullable: true }),
|
|
1042
|
+
rationale: field.string({ indexed: false }),
|
|
1043
|
+
expressesConcept: field.crossRef(ns, "Concept", {
|
|
1044
|
+
nullable: true,
|
|
1045
|
+
onDelete: "set_null"
|
|
1046
|
+
}),
|
|
1047
|
+
archived: field.boolean({ default: false }),
|
|
1048
|
+
project: field.string({ nullable: true })
|
|
1049
|
+
},
|
|
1050
|
+
embed: { source: (r) => {
|
|
1051
|
+
return `${embedText(r.synopsis, typeof r.rule === "string" ? r.rule : "")}${typeof r.category === "string" && r.category.length > 0 ? ` [${r.category}]` : ""}`;
|
|
1052
|
+
} },
|
|
1053
|
+
/**
|
|
1054
|
+
* `rationale` is included beside `rule` on the same reasoning as
|
|
1055
|
+
* SessionLog.body: it is long-form unindexed
|
|
1056
|
+
* prose carrying proper nouns, and the no-dilution argument above means
|
|
1057
|
+
* including it costs only index size.
|
|
1058
|
+
*
|
|
1059
|
+
* `category` is deliberately NOT folded in the way `embed.source` folds
|
|
1060
|
+
* it. There it is a topical anchor that steers a vector; here it would
|
|
1061
|
+
* just be a token repeated across every rule in the category, which is
|
|
1062
|
+
* what a filter on the indexed `category` field already does better.
|
|
1063
|
+
*/
|
|
1064
|
+
lexical: { source: (r) => lexicalText(r.synopsis, r.rule, r.rationale) }
|
|
1065
|
+
},
|
|
1066
|
+
CouncilSession: { fields: {
|
|
1067
|
+
question: field.string({ indexed: false }),
|
|
1068
|
+
slug: field.string(),
|
|
1069
|
+
context: field.string({ indexed: false }),
|
|
1070
|
+
options: field.string({ indexed: false }),
|
|
1071
|
+
agreement: field.string({ indexed: false }),
|
|
1072
|
+
disagreement: field.string({ indexed: false }),
|
|
1073
|
+
blindSpots: field.string({ indexed: false }),
|
|
1074
|
+
verdict: field.string({ indexed: false }),
|
|
1075
|
+
verdictAlignment: field.string(),
|
|
1076
|
+
sessionLogId: field.crossRef(ns, "SessionLog", {
|
|
1077
|
+
nullable: true,
|
|
1078
|
+
onDelete: "set_null"
|
|
1079
|
+
}),
|
|
1080
|
+
date: field.date(),
|
|
1081
|
+
project: field.string({ nullable: true })
|
|
1082
|
+
} },
|
|
1083
|
+
CouncilAdvisor: { fields: {
|
|
1084
|
+
councilSessionId: field.crossRef(ns, "CouncilSession", {
|
|
1085
|
+
nullable: true,
|
|
1086
|
+
onDelete: "cascade"
|
|
1087
|
+
}),
|
|
1088
|
+
role: field.string(),
|
|
1089
|
+
response: field.string({ indexed: false }),
|
|
1090
|
+
recommendation: field.string({ indexed: false })
|
|
1091
|
+
} },
|
|
1092
|
+
Memory: {
|
|
1093
|
+
fields: {
|
|
1094
|
+
name: field.string(),
|
|
1095
|
+
description: field.string(),
|
|
1096
|
+
type: field.string(),
|
|
1097
|
+
tags: field.list(field.string(), { nullable: true }),
|
|
1098
|
+
body: field.string({ indexed: false }),
|
|
1099
|
+
synopsis: field.string({ nullable: true }),
|
|
1100
|
+
archived: field.boolean({ default: false }),
|
|
1101
|
+
project: field.string({ nullable: true })
|
|
1102
|
+
},
|
|
1103
|
+
embed: { source: (r) => {
|
|
1104
|
+
const desc = typeof r.description === "string" ? r.description : "";
|
|
1105
|
+
const body = typeof r.body === "string" ? r.body : "";
|
|
1106
|
+
return embedText(r.synopsis, `${desc}\n\n${body}`);
|
|
1107
|
+
} },
|
|
1108
|
+
lexical: { source: (r) => lexicalText(r.synopsis, r.description, r.body) }
|
|
1109
|
+
},
|
|
1110
|
+
AnchorIntent: {
|
|
1111
|
+
fields: {
|
|
1112
|
+
synopsis: field.string(),
|
|
1113
|
+
body: field.string({
|
|
1114
|
+
nullable: true,
|
|
1115
|
+
indexed: false
|
|
1116
|
+
}),
|
|
1117
|
+
authoredBy: field.struct({ kind: field.string() }),
|
|
1118
|
+
project: field.string({ nullable: true }),
|
|
1119
|
+
archived: field.boolean({ default: false })
|
|
1120
|
+
},
|
|
1121
|
+
embed: { source: (r) => embedText(r.synopsis, typeof r.body === "string" ? r.body : "") },
|
|
1122
|
+
lexical: { source: (r) => lexicalText(r.synopsis, r.body) }
|
|
1123
|
+
},
|
|
1124
|
+
Concept: {
|
|
1125
|
+
fields: {
|
|
1126
|
+
name: field.string(),
|
|
1127
|
+
description: field.string(),
|
|
1128
|
+
kind: field.string(),
|
|
1129
|
+
body: field.string({ indexed: false }),
|
|
1130
|
+
tags: field.list(field.string(), { nullable: true }),
|
|
1131
|
+
synopsis: field.string({ nullable: true }),
|
|
1132
|
+
archived: field.boolean({ default: false }),
|
|
1133
|
+
project: field.string({ nullable: true })
|
|
1134
|
+
},
|
|
1135
|
+
embed: { source: (r) => {
|
|
1136
|
+
const desc = typeof r.description === "string" ? r.description : "";
|
|
1137
|
+
const body = typeof r.body === "string" ? r.body : "";
|
|
1138
|
+
return embedText(r.synopsis, `${desc}\n\n${body}`);
|
|
1139
|
+
} },
|
|
1140
|
+
lexical: { source: (r) => lexicalText(r.synopsis, r.description, r.body) }
|
|
1141
|
+
},
|
|
1142
|
+
ConceptLink: { fields: {
|
|
1143
|
+
fromConcept: field.crossRef(ns, "Concept", { onDelete: "cascade" }),
|
|
1144
|
+
toConcept: field.crossRef(ns, "Concept", { onDelete: "cascade" }),
|
|
1145
|
+
kind: field.string(),
|
|
1146
|
+
note: field.string({
|
|
1147
|
+
nullable: true,
|
|
1148
|
+
indexed: false
|
|
1149
|
+
}),
|
|
1150
|
+
archived: field.boolean({ default: false }),
|
|
1151
|
+
project: field.string({ nullable: true })
|
|
1152
|
+
} },
|
|
1153
|
+
Skill: { fields: {
|
|
1154
|
+
name: field.string(),
|
|
1155
|
+
description: field.string(),
|
|
1156
|
+
body: field.string({ indexed: false }),
|
|
1157
|
+
version: field.string(),
|
|
1158
|
+
active: field.boolean()
|
|
1159
|
+
} },
|
|
1160
|
+
AgentIdentity: { fields: {
|
|
1161
|
+
kid: field.string(),
|
|
1162
|
+
project: field.string({ nullable: true }),
|
|
1163
|
+
host: field.string(),
|
|
1164
|
+
displayName: field.string({ nullable: true }),
|
|
1165
|
+
description: field.string({
|
|
1166
|
+
nullable: true,
|
|
1167
|
+
indexed: false
|
|
1168
|
+
}),
|
|
1169
|
+
active: field.boolean({ default: true }),
|
|
1170
|
+
broadcastEligible: field.boolean({ default: true }),
|
|
1171
|
+
roaming: field.boolean({ default: false })
|
|
1172
|
+
} },
|
|
1173
|
+
AgentMessage: {
|
|
1174
|
+
autoCrud: false,
|
|
1175
|
+
fields: {
|
|
1176
|
+
shortId: field.number({
|
|
1177
|
+
indexed: true,
|
|
1178
|
+
readonly: true
|
|
1179
|
+
}),
|
|
1180
|
+
recipient: field.crossRef(ns, "AgentIdentity", {
|
|
1181
|
+
nullable: true,
|
|
1182
|
+
onDelete: "set_null"
|
|
1183
|
+
}),
|
|
1184
|
+
subject: field.string(),
|
|
1185
|
+
body: field.string({ indexed: false }),
|
|
1186
|
+
status: field.enum(AGENT_MESSAGE_STATUS_VOCAB, { default: "unread" }),
|
|
1187
|
+
ackedBy: field.string({ nullable: true }),
|
|
1188
|
+
ackedAt: field.date({
|
|
1189
|
+
nullable: true,
|
|
1190
|
+
serverSet: "both"
|
|
1191
|
+
}),
|
|
1192
|
+
inReplyTo: field.crossRef(ns, "AgentMessage", {
|
|
1193
|
+
nullable: true,
|
|
1194
|
+
onDelete: "set_null",
|
|
1195
|
+
spread: true
|
|
1196
|
+
}),
|
|
1197
|
+
tags: field.list(field.string(), { nullable: true }),
|
|
1198
|
+
instance: field.string({ nullable: true }),
|
|
1199
|
+
archived: field.boolean({ default: false })
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
HostInstall: { fields: {
|
|
1203
|
+
key: field.string(),
|
|
1204
|
+
host: field.string(),
|
|
1205
|
+
hostReported: field.string(),
|
|
1206
|
+
project: field.string(),
|
|
1207
|
+
kid: field.string({ nullable: true }),
|
|
1208
|
+
harness: field.string(),
|
|
1209
|
+
pluginVersion: field.string({ nullable: true }),
|
|
1210
|
+
fragmentVersion: field.string({ nullable: true }),
|
|
1211
|
+
fragmentDigest: field.string({ nullable: true }),
|
|
1212
|
+
fragmentState: field.enum(HOST_INSTALL_FRAGMENT_STATES),
|
|
1213
|
+
refreshedAt: field.date(),
|
|
1214
|
+
pinState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
|
|
1215
|
+
pinNote: field.string({
|
|
1216
|
+
nullable: true,
|
|
1217
|
+
indexed: false
|
|
1218
|
+
}),
|
|
1219
|
+
mcpJsonState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
|
|
1220
|
+
mcpJsonNote: field.string({
|
|
1221
|
+
nullable: true,
|
|
1222
|
+
indexed: false
|
|
1223
|
+
}),
|
|
1224
|
+
authorityState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
|
|
1225
|
+
authorityNote: field.string({
|
|
1226
|
+
nullable: true,
|
|
1227
|
+
indexed: false
|
|
1228
|
+
}),
|
|
1229
|
+
settingsState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
|
|
1230
|
+
settingsNote: field.string({
|
|
1231
|
+
nullable: true,
|
|
1232
|
+
indexed: false
|
|
1233
|
+
}),
|
|
1234
|
+
settingsEnv: field.json({ nullable: true })
|
|
1235
|
+
} }
|
|
1236
|
+
};
|
|
1237
|
+
const definition = defineNamespace(ns, types, {
|
|
1238
|
+
annotations: { nl: CORTEXT_NL_RULINGS },
|
|
1239
|
+
promotedColumns: CORTEXT_PROMOTED_COLUMNS
|
|
1240
|
+
});
|
|
1241
|
+
const baseInstall = definition.install.bind(definition);
|
|
1242
|
+
return {
|
|
1243
|
+
...definition,
|
|
1244
|
+
install(ctx) {
|
|
1245
|
+
baseInstall(ctx);
|
|
1246
|
+
installMailboxMutations(ctx, definition.types.AgentMessage.fields);
|
|
1247
|
+
for (const [name, wrap] of [["updateAgentIdentity", (r) => identitySelfOnly(ctx, "updateAgentIdentity", r)], ["deleteAgentIdentity", (r) => operatorOnly("deleteAgentIdentity", r)]]) {
|
|
1248
|
+
const entry = ctx.registry.get(name);
|
|
1249
|
+
if (entry === void 0) throw new Error(`cortext: autoCRUD did not register ${name}`);
|
|
1250
|
+
ctx.registry.replace(name, {
|
|
1251
|
+
...entry,
|
|
1252
|
+
resolver: wrap(entry.resolver)
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
/** The canonical ordered list of type names registered by this namespace. */
|
|
1259
|
+
const CLAUDE_TYPE_NAMES = [
|
|
1260
|
+
"SessionLog",
|
|
1261
|
+
"Decision",
|
|
1262
|
+
"Todo",
|
|
1263
|
+
"AgentRule",
|
|
1264
|
+
"CouncilSession",
|
|
1265
|
+
"CouncilAdvisor",
|
|
1266
|
+
"Memory",
|
|
1267
|
+
"AnchorIntent",
|
|
1268
|
+
"Concept",
|
|
1269
|
+
"ConceptLink",
|
|
1270
|
+
"Skill",
|
|
1271
|
+
"AgentIdentity",
|
|
1272
|
+
"AgentMessage",
|
|
1273
|
+
"HostInstall"
|
|
1274
|
+
];
|
|
1275
|
+
/**
|
|
1276
|
+
* Architecture namespace — Claude-lineage code-review findings. Kept separate
|
|
1277
|
+
* from the claude context namespace so lifecycle (compaction, retention,
|
|
1278
|
+
* audit) differs: architectural findings outlive session-scoped rationale.
|
|
1279
|
+
*
|
|
1280
|
+
* Single type today (ArchitectureConcern). Notes:
|
|
1281
|
+
* - `archived` is `field.boolean()` to match every other type that carries
|
|
1282
|
+
* archived state.
|
|
1283
|
+
* - `supersedes` is a self-ref `field.crossRef` edge with `set_null` —
|
|
1284
|
+
* a concern survives the one it superseded going away.
|
|
1285
|
+
*/
|
|
1286
|
+
function buildArchitectureNamespace(ns) {
|
|
1287
|
+
return defineNamespace(ns, { ArchitectureConcern: { fields: {
|
|
1288
|
+
title: field.string(),
|
|
1289
|
+
status: field.string(),
|
|
1290
|
+
archived: field.boolean({ default: false }),
|
|
1291
|
+
category: field.string(),
|
|
1292
|
+
evidence: field.string({ indexed: false }),
|
|
1293
|
+
proposal: field.string({ indexed: false }),
|
|
1294
|
+
severity: field.string(),
|
|
1295
|
+
supersedes: field.crossRef(ns, "ArchitectureConcern", {
|
|
1296
|
+
nullable: true,
|
|
1297
|
+
onDelete: "set_null"
|
|
1298
|
+
}),
|
|
1299
|
+
revisitWhen: field.string({
|
|
1300
|
+
nullable: true,
|
|
1301
|
+
indexed: false
|
|
1302
|
+
}),
|
|
1303
|
+
affectsTypes: field.string(),
|
|
1304
|
+
discoveredIn: field.string(),
|
|
1305
|
+
affectsPackages: field.string(),
|
|
1306
|
+
project: field.string({ nullable: true })
|
|
1307
|
+
} } }, { promotedColumns: CORTEXT_PROMOTED_COLUMNS });
|
|
1308
|
+
}
|
|
1309
|
+
const ARCHITECTURE_TYPE_NAMES = ["ArchitectureConcern"];
|
|
1310
|
+
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/onboard.ts
|
|
1313
|
+
/**
|
|
1314
|
+
* Render a controlled vocabulary as the agent-facing union string the onboard
|
|
1315
|
+
* surface uses (e.g. `'open' | 'done'`). Sourced from the kernel's exported
|
|
1316
|
+
* vocab constants so this description can't drift from the `field.enum` + pg
|
|
1317
|
+
* CHECK that actually enforce it.
|
|
1318
|
+
*/
|
|
1319
|
+
function vocabUnion(values) {
|
|
1320
|
+
return values.map((v) => `'${v}'`).join(" | ");
|
|
1321
|
+
}
|
|
1322
|
+
/** Render an alias table as `'a' → 'b', …` for the onboarding contract. */
|
|
1323
|
+
function aliasList(aliases) {
|
|
1324
|
+
return Object.entries(aliases).map(([alias, canonical]) => `'${alias}' → '${canonical}'`).join(", ");
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* The AgentMessage write surface is its custom mutation family, NOT autoCRUD:
|
|
1328
|
+
* the type declares `autoCrud: false`, so `createAgentMessage` is never
|
|
1329
|
+
* registered and the schema never advertises it (sendAgentMessage is the only
|
|
1330
|
+
* send path, with kid addressing + '*' fan-out), and status transitions go
|
|
1331
|
+
* through markAgentMessageRead / ackAgentMessage exclusively. updateAgentMessage survives for
|
|
1332
|
+
* non-transition fields (tags, archived); deleteAgentMessage is standard.
|
|
1333
|
+
*/
|
|
1334
|
+
const AGENT_MESSAGE_MUTATIONS = [
|
|
1335
|
+
"sendAgentMessage",
|
|
1336
|
+
"markAgentMessageRead",
|
|
1337
|
+
"ackAgentMessage",
|
|
1338
|
+
"updateAgentMessage",
|
|
1339
|
+
"deleteAgentMessage"
|
|
1340
|
+
];
|
|
1341
|
+
/**
|
|
1342
|
+
* Derived from CLAUDE_TYPE_NAMES so a newly registered type cannot be
|
|
1343
|
+
* silently missing from the advertised mutation surface — the same
|
|
1344
|
+
* drift-proofing move vocabUnion makes for vocabularies.
|
|
1345
|
+
*/
|
|
1346
|
+
function buildMutations() {
|
|
1347
|
+
const mutations = {};
|
|
1348
|
+
for (const typeName of CLAUDE_TYPE_NAMES) mutations[typeName] = typeName === "AgentMessage" ? AGENT_MESSAGE_MUTATIONS : [
|
|
1349
|
+
`create${typeName}`,
|
|
1350
|
+
`update${typeName}`,
|
|
1351
|
+
`delete${typeName}`
|
|
1352
|
+
];
|
|
1353
|
+
return mutations;
|
|
1354
|
+
}
|
|
1355
|
+
function buildBody(namespace, retention) {
|
|
1356
|
+
return {
|
|
1357
|
+
plugin: "cortext",
|
|
1358
|
+
namespace,
|
|
1359
|
+
description: "Persistent context store for Claude Code sessions",
|
|
1360
|
+
retention,
|
|
1361
|
+
types: {
|
|
1362
|
+
SessionLog: {
|
|
1363
|
+
purpose: "One entry per development session or stable milestone",
|
|
1364
|
+
fields: {
|
|
1365
|
+
id: "auto-generated",
|
|
1366
|
+
branch: "git branch name",
|
|
1367
|
+
startedAt: "ISO 8601 UTC datetime",
|
|
1368
|
+
summary: "one-paragraph session summary (not indexed)",
|
|
1369
|
+
decisionsCount: "count of decisions made this session",
|
|
1370
|
+
packages: "comma-separated list of packages touched",
|
|
1371
|
+
archived: "true if compacted"
|
|
1372
|
+
},
|
|
1373
|
+
writeWhen: ["At each stable milestone (PR merged, feature complete, significant fix)", "Not just at session end — a dropped session should lose at most one milestone"]
|
|
1374
|
+
},
|
|
1375
|
+
Decision: {
|
|
1376
|
+
purpose: "Architectural and design choices worth preserving across sessions",
|
|
1377
|
+
fields: {
|
|
1378
|
+
id: "auto-generated",
|
|
1379
|
+
topic: "short topic label",
|
|
1380
|
+
choice: "what was decided",
|
|
1381
|
+
rationale: "why (not indexed)",
|
|
1382
|
+
sessionId: "UUID crossRef to SessionLog, set_null on delete (nullable)",
|
|
1383
|
+
createdAt: "ISO 8601 UTC datetime",
|
|
1384
|
+
supersedes: "UUID crossRef to the Decision this replaces, set_null on delete (nullable)",
|
|
1385
|
+
status: `open vocabulary, not enforced; states this vocabulary documents (not a reading of what is stored): ${DECISION_STATUS_VOCAB.join(", ")}; synonyms normalised by the server: ${aliasList(DECISION_STATUS_ALIASES)}`,
|
|
1386
|
+
revisitWhen: "trigger condition for revisiting a deferred decision (not indexed)",
|
|
1387
|
+
archived: "true if compacted"
|
|
1388
|
+
},
|
|
1389
|
+
writeWhen: ["Immediately when a decision is made — do not defer to session end"]
|
|
1390
|
+
},
|
|
1391
|
+
Todo: {
|
|
1392
|
+
purpose: "Tracked work items and deferred tasks",
|
|
1393
|
+
fields: {
|
|
1394
|
+
id: "auto-generated",
|
|
1395
|
+
title: "short description",
|
|
1396
|
+
area: "free-text area/component label (e.g. 'substrate-core', 'infrastructure'); not schema-enforced",
|
|
1397
|
+
status: `${vocabUnion(TODO_LIFECYCLE_VOCAB)} (lifecycle only); closing words set the resolution too: ${aliasList(TODO_STATUS_ALIASES)}`,
|
|
1398
|
+
resolution: `${vocabUnion(TODO_RESOLUTION_VOCAB)} (nullable): why a closed Todo stopped`,
|
|
1399
|
+
priority: vocabUnion(TODO_PRIORITY_VOCAB),
|
|
1400
|
+
notes: "additional detail (not indexed)",
|
|
1401
|
+
blockedBy: "free-text description of the blocker (not indexed)",
|
|
1402
|
+
implementsDecision: "UUID crossRef to Decision, set_null on delete (nullable)",
|
|
1403
|
+
createdAt: "ISO 8601 UTC datetime",
|
|
1404
|
+
archived: "true if compacted"
|
|
1405
|
+
},
|
|
1406
|
+
writeWhen: ["On creation — do not batch to session end", "Freely without asking — when a gap, improvement, or deferred item is identified"]
|
|
1407
|
+
},
|
|
1408
|
+
AgentRule: {
|
|
1409
|
+
purpose: "Operational rule for agent behaviour, loaded into context at session start",
|
|
1410
|
+
fields: {
|
|
1411
|
+
id: "auto-generated",
|
|
1412
|
+
rule: "full rule body (not indexed)",
|
|
1413
|
+
category: `open vocabulary; categories in use: ${AGENT_RULE_CATEGORY_VOCAB.join(", ")}`,
|
|
1414
|
+
severity: `${vocabUnion(AGENT_RULE_SEVERITY_VOCAB)} (nullable); synonyms normalised by the server: ${aliasList(AGENT_RULE_SEVERITY_ALIASES)}`,
|
|
1415
|
+
active: "only active rules load at cold-start",
|
|
1416
|
+
pinned: "always-on rules exempt from salience ranking; absent = unpinned (nullable)",
|
|
1417
|
+
synopsis: "short retrieval-optimized summary; primary embedding source (nullable)",
|
|
1418
|
+
rationale: "why the rule exists (not indexed)",
|
|
1419
|
+
expressesConcept: "UUID crossRef to Concept, set_null on delete (nullable)",
|
|
1420
|
+
archived: "true if retired"
|
|
1421
|
+
},
|
|
1422
|
+
writeWhen: ["When the user gives durable operational guidance — corrections and confirmed approaches alike"]
|
|
1423
|
+
},
|
|
1424
|
+
CouncilSession: {
|
|
1425
|
+
purpose: "One pressure-test deliberation: question, advisor synthesis, verdict",
|
|
1426
|
+
fields: {
|
|
1427
|
+
id: "auto-generated",
|
|
1428
|
+
question: "the decision under pressure-test (not indexed)",
|
|
1429
|
+
slug: "short kebab-case handle",
|
|
1430
|
+
context: "framing given to the advisors (not indexed)",
|
|
1431
|
+
options: "candidate options considered (not indexed)",
|
|
1432
|
+
agreement: "where the advisors agreed (not indexed)",
|
|
1433
|
+
disagreement: "where the advisors split (not indexed)",
|
|
1434
|
+
blindSpots: "risks no option addresses (not indexed)",
|
|
1435
|
+
verdict: "final synthesis (not indexed)",
|
|
1436
|
+
verdictAlignment: "how aligned the verdict is with advisor consensus",
|
|
1437
|
+
sessionLogId: "UUID crossRef to SessionLog, set_null on delete (nullable)",
|
|
1438
|
+
date: "ISO 8601 date of the deliberation"
|
|
1439
|
+
},
|
|
1440
|
+
writeWhen: ["At the end of a council/pressure-test run, with one CouncilAdvisor row per advisor"]
|
|
1441
|
+
},
|
|
1442
|
+
CouncilAdvisor: {
|
|
1443
|
+
purpose: "One advisor's response within a CouncilSession",
|
|
1444
|
+
fields: {
|
|
1445
|
+
id: "auto-generated",
|
|
1446
|
+
councilSessionId: "UUID crossRef to CouncilSession, cascade on delete (nullable)",
|
|
1447
|
+
role: "advisor persona / thinking style",
|
|
1448
|
+
response: "full advisor response (not indexed)",
|
|
1449
|
+
recommendation: "the advisor's bottom-line recommendation (not indexed)"
|
|
1450
|
+
},
|
|
1451
|
+
writeWhen: ["Alongside its parent CouncilSession — an advisor row is meaningless without one"]
|
|
1452
|
+
},
|
|
1453
|
+
Memory: {
|
|
1454
|
+
purpose: "Persistent agent note that survives across conversations",
|
|
1455
|
+
fields: {
|
|
1456
|
+
id: "auto-generated",
|
|
1457
|
+
name: "canonical human label; deduped by convention, not schema",
|
|
1458
|
+
description: "one-line hook shown in the session-start index",
|
|
1459
|
+
type: "category: 'user' | 'feedback' | 'project' | 'reference' (convention, not schema-enforced)",
|
|
1460
|
+
body: "full memory body (not indexed)",
|
|
1461
|
+
synopsis: "short retrieval-optimized compression; preferred embedding source (nullable)",
|
|
1462
|
+
archived: "true if retired"
|
|
1463
|
+
},
|
|
1464
|
+
writeWhen: ["When a durable fact, preference, or lesson surfaces that the repo itself does not record"]
|
|
1465
|
+
},
|
|
1466
|
+
AnchorIntent: {
|
|
1467
|
+
purpose: "Topical anchor for hybrid recall — \"what I am focused on right now\"",
|
|
1468
|
+
fields: {
|
|
1469
|
+
id: "auto-generated",
|
|
1470
|
+
synopsis: "short intent summary (1-3 sentences); primary embedding source",
|
|
1471
|
+
body: "optional long-form continuation (not indexed, nullable)",
|
|
1472
|
+
authoredBy: "provenance struct, starts with kind: 'user' | 'agent'",
|
|
1473
|
+
project: "project scope; null = cross-project",
|
|
1474
|
+
archived: "true if retired"
|
|
1475
|
+
},
|
|
1476
|
+
writeWhen: ["At session boot if the newest anchor is stale, and at any material focus shift", "The newest non-archived row in scope IS the current anchor — there is no active flag"]
|
|
1477
|
+
},
|
|
1478
|
+
Concept: {
|
|
1479
|
+
purpose: "Operational semantics of a domain entity — the conceptual layer above types",
|
|
1480
|
+
fields: {
|
|
1481
|
+
id: "auto-generated",
|
|
1482
|
+
name: "canonical human label; deduped by write-path convention",
|
|
1483
|
+
description: "one-line hook for the cold-start frame",
|
|
1484
|
+
kind: "domain classifier (e.g. 'type', 'protocol', 'pattern', 'role', 'convention'); free text",
|
|
1485
|
+
body: "full operational semantics (not indexed)",
|
|
1486
|
+
synopsis: "short embedding-side compression (nullable)",
|
|
1487
|
+
archived: "true if retired"
|
|
1488
|
+
},
|
|
1489
|
+
writeWhen: ["When operational semantics stabilise around an entity worth teaching future sessions"]
|
|
1490
|
+
},
|
|
1491
|
+
ConceptLink: {
|
|
1492
|
+
purpose: "Typed edge between two Concepts",
|
|
1493
|
+
fields: {
|
|
1494
|
+
id: "auto-generated",
|
|
1495
|
+
fromConcept: "UUID crossRef to the source Concept, cascade on delete",
|
|
1496
|
+
toConcept: "UUID crossRef to the target Concept, cascade on delete",
|
|
1497
|
+
kind: "edge kind: 'depends_on' | 'specializes' | 'see_also' | 'extends' (convention, not schema-enforced)",
|
|
1498
|
+
note: "optional free-text annotation (not indexed, nullable)"
|
|
1499
|
+
},
|
|
1500
|
+
writeWhen: ["When a relationship between concepts carries operational meaning"]
|
|
1501
|
+
},
|
|
1502
|
+
Skill: {
|
|
1503
|
+
purpose: "Reusable agent-behaviour definition (cross-project)",
|
|
1504
|
+
fields: {
|
|
1505
|
+
id: "auto-generated",
|
|
1506
|
+
name: "skill name referenced by disk-side pointer stubs",
|
|
1507
|
+
description: "when to invoke",
|
|
1508
|
+
body: "full skill body (not indexed)",
|
|
1509
|
+
version: "skill version string",
|
|
1510
|
+
active: "inactive skills are retired"
|
|
1511
|
+
},
|
|
1512
|
+
writeWhen: ["When authoring or revising a reusable agent behaviour"]
|
|
1513
|
+
},
|
|
1514
|
+
AgentIdentity: {
|
|
1515
|
+
purpose: "Directory of registered agent identities — the social/discovery layer over _system/Key auth",
|
|
1516
|
+
fields: {
|
|
1517
|
+
id: "auto-generated",
|
|
1518
|
+
kid: "wire identity (e.g. 'peer-claude@host-a'); matches a registered _system/Key kid by convention",
|
|
1519
|
+
project: "repo basename; null = cross-cutting/fleet identity",
|
|
1520
|
+
host: "machine hostname",
|
|
1521
|
+
displayName: "optional human-friendly name (nullable)",
|
|
1522
|
+
description: "optional blurb (not indexed, nullable)",
|
|
1523
|
+
active: "retired/revoked identities flip false",
|
|
1524
|
+
broadcastEligible: "false = a to:'*' broadcast skips this identity",
|
|
1525
|
+
roaming: "true = works whichever repo it is sent into on its own host (no project); the fleet audit accepts it as any repo's HostInstall writer there"
|
|
1526
|
+
},
|
|
1527
|
+
writeWhen: ["On identity registration (normally via /substrate-setup or substrate-admin register)"]
|
|
1528
|
+
},
|
|
1529
|
+
AgentMessage: {
|
|
1530
|
+
purpose: "Durable agent-to-agent mail (1:1 + cohort fan-out)",
|
|
1531
|
+
fields: {
|
|
1532
|
+
id: "auto-generated",
|
|
1533
|
+
recipient: "UUID crossRef to AgentIdentity, set_null on delete (nullable). Address by kid via sendAgentMessage; the sender is NOT a field — it is _metadata.createdBy, server-stamped from the signed principal",
|
|
1534
|
+
subject: "message subject",
|
|
1535
|
+
body: "message body (not indexed)",
|
|
1536
|
+
status: `${vocabUnion(AGENT_MESSAGE_STATUS_VOCAB)} — recipient-only lifecycle, transitioned via markAgentMessageRead / ackAgentMessage exclusively`,
|
|
1537
|
+
ackedBy: "ack attribution kid, server-stamped by ackAgentMessage (nullable)",
|
|
1538
|
+
ackedAt: "ack timestamp, server-stamped by ackAgentMessage (nullable)",
|
|
1539
|
+
inReplyTo: "UUID crossRef to the AgentMessage this replies to, set_null on delete (nullable)",
|
|
1540
|
+
archived: "true once compacted (acked mail past the retention window) or manually archived"
|
|
1541
|
+
},
|
|
1542
|
+
writeWhen: ["Send via sendAgentMessage with `to` as a kid list ('*' fans out to all active identities except the sender)", "Recipients mark lifecycle with markAgentMessageRead / ackAgentMessage"]
|
|
1543
|
+
},
|
|
1544
|
+
HostInstall: {
|
|
1545
|
+
purpose: "What one host has installed for one repo under one harness, upserted by the cold-start each session; check refreshedAt before trusting it (fresh < 24h, quiet < 7d, stale beyond)",
|
|
1546
|
+
fields: {
|
|
1547
|
+
id: "auto-generated",
|
|
1548
|
+
key: "`<host>/<project>/<harness>`, unique; the upsert key",
|
|
1549
|
+
host: "normalised hostname (lowercase, domain dropped)",
|
|
1550
|
+
hostReported: "raw hostname as the host reported it",
|
|
1551
|
+
project: "repo basename",
|
|
1552
|
+
kid: "the identity the repo signs as (nullable)",
|
|
1553
|
+
harness: "claude-code | opencode | hook",
|
|
1554
|
+
pluginVersion: "plugin version that served the session (nullable)",
|
|
1555
|
+
fragmentVersion: "installed conventions fragment version (nullable)",
|
|
1556
|
+
fragmentDigest: "installed conventions fragment digest (nullable)",
|
|
1557
|
+
fragmentState: `${vocabUnion(HOST_INSTALL_FRAGMENT_STATES)} — the cold-start's fragment drift verdict`,
|
|
1558
|
+
refreshedAt: "last cold-start report, written only by the cold-start; the liveness signal",
|
|
1559
|
+
pinState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — marketplace pin vs the served plugin (nullable; claude-code only)`,
|
|
1560
|
+
pinNote: "the pin check's worst finding (nullable)",
|
|
1561
|
+
mcpJsonState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — the repo's .mcp.json (nullable)`,
|
|
1562
|
+
mcpJsonNote: "the .mcp.json check's worst finding (nullable)",
|
|
1563
|
+
authorityState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — whether the key works and its grant covers cortext (nullable)`,
|
|
1564
|
+
authorityNote: "the authority check's worst finding (nullable)",
|
|
1565
|
+
settingsState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — settings.local.json parses and enables the MCP server (nullable)`,
|
|
1566
|
+
settingsNote: "the settings check's worst finding (nullable)",
|
|
1567
|
+
settingsEnv: "json — allowlisted non-secret switch values, plus whether the identity keys are set (nullable)"
|
|
1568
|
+
},
|
|
1569
|
+
writeWhen: ["Written by the cold-start of every session with an agent identity; agents read it, they do not write it"]
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
mutations: buildMutations(),
|
|
1573
|
+
endpoints: {
|
|
1574
|
+
query: {
|
|
1575
|
+
method: "POST",
|
|
1576
|
+
path: "/substrate/v1/query"
|
|
1577
|
+
},
|
|
1578
|
+
mutate: {
|
|
1579
|
+
method: "POST",
|
|
1580
|
+
path: "/substrate/v1/mutate"
|
|
1581
|
+
},
|
|
1582
|
+
compact: {
|
|
1583
|
+
method: "POST",
|
|
1584
|
+
path: "/substrate/v1/plugins/cortext/compact"
|
|
1585
|
+
},
|
|
1586
|
+
ws: {
|
|
1587
|
+
path: "/substrate/v1/ws",
|
|
1588
|
+
namespace
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
contextLoading: { onSessionStart: {
|
|
1592
|
+
description: "Load recent context at the start of each session",
|
|
1593
|
+
query: {
|
|
1594
|
+
SessionLog: {
|
|
1595
|
+
params: { limit: 3 },
|
|
1596
|
+
fields: {
|
|
1597
|
+
id: true,
|
|
1598
|
+
branch: true,
|
|
1599
|
+
startedAt: true,
|
|
1600
|
+
summary: true,
|
|
1601
|
+
archived: true
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
Decision: {
|
|
1605
|
+
params: { limit: 80 },
|
|
1606
|
+
fields: {
|
|
1607
|
+
id: true,
|
|
1608
|
+
topic: true,
|
|
1609
|
+
choice: true,
|
|
1610
|
+
rationale: true,
|
|
1611
|
+
sessionId: true,
|
|
1612
|
+
status: true,
|
|
1613
|
+
revisitWhen: true,
|
|
1614
|
+
archived: true
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1617
|
+
Todo: {
|
|
1618
|
+
params: { limit: 50 },
|
|
1619
|
+
fields: {
|
|
1620
|
+
id: true,
|
|
1621
|
+
title: true,
|
|
1622
|
+
area: true,
|
|
1623
|
+
status: true,
|
|
1624
|
+
priority: true,
|
|
1625
|
+
notes: true,
|
|
1626
|
+
blockedBy: true,
|
|
1627
|
+
archived: true
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
surfaceToUser: [
|
|
1632
|
+
"The 3 most recent non-archived SessionLog entries",
|
|
1633
|
+
"Non-archived Decisions",
|
|
1634
|
+
"Todos with status 'open' or 'blocked'",
|
|
1635
|
+
"Skip archived records silently"
|
|
1636
|
+
]
|
|
1637
|
+
} },
|
|
1638
|
+
compaction: {
|
|
1639
|
+
description: "Archives old sessions and their decisions, keeping the most recent N live",
|
|
1640
|
+
default: {
|
|
1641
|
+
retention,
|
|
1642
|
+
route: "POST /substrate/v1/plugins/cortext/compact"
|
|
1643
|
+
},
|
|
1644
|
+
dryRun: {
|
|
1645
|
+
body: { dryRun: true },
|
|
1646
|
+
description: "Preview without committing"
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
function registerOnboardRoute(router, _ctx, retention, namespace) {
|
|
1652
|
+
const body = buildBody(namespace, retention);
|
|
1653
|
+
const serialised = Buffer.from(JSON.stringify(body), "utf8");
|
|
1654
|
+
router.get("/onboard", () => {
|
|
1655
|
+
return {
|
|
1656
|
+
status: 200,
|
|
1657
|
+
headers: JSON_HEADERS,
|
|
1658
|
+
body: serialised
|
|
1659
|
+
};
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
//#endregion
|
|
1664
|
+
//#region src/release-surface.ts
|
|
1665
|
+
/**
|
|
1666
|
+
* The plugin-facing surface, snapshotted so a release can be SIZED rather than
|
|
1667
|
+
* judged.
|
|
1668
|
+
*
|
|
1669
|
+
* ## Why this exists
|
|
1670
|
+
*
|
|
1671
|
+
* A release-class guard that defines the surface as a list of paths passes a
|
|
1672
|
+
* major as a patch with a green check the moment a type moves out of those
|
|
1673
|
+
* paths: `git diff` on the stale path is empty, so it approves.
|
|
1674
|
+
*
|
|
1675
|
+
* cortext's propagation is heavy — a
|
|
1676
|
+
* `packages/cortext/src` change reaches consumers as an npm tarball loaded by
|
|
1677
|
+
* the substrate runtime from `SUBSTRATE_PLUGIN_DIR`, needing a publish **and** a
|
|
1678
|
+
* runtime deploy. npm publishes are immutable, so a mis-sized release cannot be
|
|
1679
|
+
* withdrawn, only superseded.
|
|
1680
|
+
*
|
|
1681
|
+
* ## Why it resolves through the code, not through paths
|
|
1682
|
+
*
|
|
1683
|
+
* A path list rots silently. This calls
|
|
1684
|
+
* `buildClaudeNamespace()` and walks what it returns, so the surface is
|
|
1685
|
+
* DERIVED from the thing it describes. A type or field cannot move out from
|
|
1686
|
+
* under it, because there is no "under it" — moving a declaration between files
|
|
1687
|
+
* changes nothing here, and that is the point.
|
|
1688
|
+
*
|
|
1689
|
+
* The committed `release-surface.json` is kept honest by
|
|
1690
|
+
* `test/release-surface.test.ts`, exactly as `config.schema.json` is kept
|
|
1691
|
+
* honest by `schema-parity.test.ts`. So the release script never has to execute
|
|
1692
|
+
* TypeScript or parse a declaration: it diffs two committed JSON snapshots.
|
|
1693
|
+
*
|
|
1694
|
+
* ## What counts as breaking, and why `indexed` is in here
|
|
1695
|
+
*
|
|
1696
|
+
* Removing a type or field, making an optional field REQUIRED, narrowing an
|
|
1697
|
+
* enum, or moving the substrate contract range are all breaks for someone holding
|
|
1698
|
+
* data or code against this schema.
|
|
1699
|
+
*
|
|
1700
|
+
* `indexed` is included and it is not cosmetic: un-indexing a field turns a
|
|
1701
|
+
* working `where` filter into an `unindexed_field` rejection. That is a
|
|
1702
|
+
* runtime break for a caller who changed nothing, and it would otherwise be
|
|
1703
|
+
* invisible to a version-bump decision.
|
|
1704
|
+
*/
|
|
1705
|
+
function reduceField(raw) {
|
|
1706
|
+
const nullable = raw.nullable === true;
|
|
1707
|
+
const hasDefault = Object.hasOwn(raw, "default");
|
|
1708
|
+
const readonly = raw.readonly === true;
|
|
1709
|
+
const serverSet = raw.serverSet !== void 0 && raw.serverSet !== false;
|
|
1710
|
+
const out = {
|
|
1711
|
+
kind: typeof raw.kind === "string" ? raw.kind : "unknown",
|
|
1712
|
+
nullable,
|
|
1713
|
+
hasDefault,
|
|
1714
|
+
readonly,
|
|
1715
|
+
serverSet,
|
|
1716
|
+
indexed: raw.indexed === true,
|
|
1717
|
+
required: !(nullable || hasDefault || readonly || serverSet)
|
|
1718
|
+
};
|
|
1719
|
+
if (typeof raw.type === "string") {
|
|
1720
|
+
if (out.kind === "crossRef") out.target = raw.type;
|
|
1721
|
+
else out.type = raw.type;
|
|
1722
|
+
}
|
|
1723
|
+
if (Array.isArray(raw.enum)) out.enum = [...raw.enum].sort();
|
|
1724
|
+
if (out.kind === "list" && raw.of !== null && typeof raw.of === "object") {
|
|
1725
|
+
const inner = raw.of;
|
|
1726
|
+
if (typeof inner.type === "string") out.type = `list<${inner.type}>`;
|
|
1727
|
+
}
|
|
1728
|
+
return out;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Build the canonical surface.
|
|
1732
|
+
*
|
|
1733
|
+
* Keys are sorted so the committed JSON is stable — an unstable snapshot would
|
|
1734
|
+
* produce diff noise on every regeneration and train its reader to skim, which
|
|
1735
|
+
* is the failure this whole file is guarding against one level down.
|
|
1736
|
+
*/
|
|
1737
|
+
function buildReleaseSurface(namespace = "cortext") {
|
|
1738
|
+
const ns = buildClaudeNamespace(namespace);
|
|
1739
|
+
const types = {};
|
|
1740
|
+
for (const typeName of Object.keys(ns.types).sort()) {
|
|
1741
|
+
const declared = ns.types[typeName]?.fields ?? {};
|
|
1742
|
+
const fields = {};
|
|
1743
|
+
for (const fieldName of Object.keys(declared).sort()) {
|
|
1744
|
+
const raw = declared[fieldName];
|
|
1745
|
+
if (raw === void 0) continue;
|
|
1746
|
+
fields[fieldName] = reduceField(raw);
|
|
1747
|
+
}
|
|
1748
|
+
types[typeName] = fields;
|
|
1749
|
+
}
|
|
1750
|
+
return {
|
|
1751
|
+
contractVersion: SUBSTRATE_CONTRACT_RANGE,
|
|
1752
|
+
types
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
const CLASS_RANK = {
|
|
1756
|
+
patch: 0,
|
|
1757
|
+
minor: 1,
|
|
1758
|
+
major: 2
|
|
1759
|
+
};
|
|
1760
|
+
/**
|
|
1761
|
+
* Classify the difference between two surfaces.
|
|
1762
|
+
*
|
|
1763
|
+
* Reports EVERY reason rather than the first, because a release usually carries
|
|
1764
|
+
* several and a guard that stops at one teaches its reader to fix-and-rerun
|
|
1765
|
+
* instead of reading.
|
|
1766
|
+
*/
|
|
1767
|
+
function classifySurfaceChange(before, after) {
|
|
1768
|
+
const breaking = [];
|
|
1769
|
+
const additive = [];
|
|
1770
|
+
const review = [];
|
|
1771
|
+
if (before.contractVersion !== after.contractVersion) breaking.push(`substrate contract range moved ${before.contractVersion} -> ${after.contractVersion}`);
|
|
1772
|
+
for (const typeName of Object.keys(before.types)) {
|
|
1773
|
+
if (!Object.hasOwn(after.types, typeName)) {
|
|
1774
|
+
breaking.push(`type removed: ${typeName}`);
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
const b = before.types[typeName] ?? {};
|
|
1778
|
+
const a = after.types[typeName] ?? {};
|
|
1779
|
+
for (const fieldName of Object.keys(b)) {
|
|
1780
|
+
const bf = b[fieldName];
|
|
1781
|
+
const af = a[fieldName];
|
|
1782
|
+
const at = `${typeName}.${fieldName}`;
|
|
1783
|
+
if (bf === void 0) continue;
|
|
1784
|
+
if (af === void 0) {
|
|
1785
|
+
breaking.push(`field removed: ${at}`);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
if (bf.kind !== af.kind) breaking.push(`field kind changed: ${at} ${bf.kind} -> ${af.kind}`);
|
|
1789
|
+
if (bf.type !== af.type && (bf.type ?? af.type) !== void 0) breaking.push(`field type changed: ${at} ${bf.type ?? "?"} -> ${af.type ?? "?"}`);
|
|
1790
|
+
if (bf.target !== af.target && (bf.target ?? af.target) !== void 0) breaking.push(`crossRef target changed: ${at} ${bf.target ?? "?"} -> ${af.target ?? "?"}`);
|
|
1791
|
+
if (!bf.required && af.required) breaking.push(`field became REQUIRED: ${at}`);
|
|
1792
|
+
if (bf.required && !af.required) additive.push(`field relaxed to optional: ${at}`);
|
|
1793
|
+
if (bf.nullable && !af.nullable) breaking.push(`field no longer nullable: ${at}`);
|
|
1794
|
+
if (bf.indexed && !af.indexed) breaking.push(`field no longer indexed: ${at}`);
|
|
1795
|
+
if (!bf.indexed && af.indexed) additive.push(`field now indexed: ${at}`);
|
|
1796
|
+
const bEnum = bf.enum ?? null;
|
|
1797
|
+
const aEnum = af.enum ?? null;
|
|
1798
|
+
if (bEnum !== null && aEnum === null) additive.push(`enum constraint dropped: ${at}`);
|
|
1799
|
+
if (bEnum === null && aEnum !== null) breaking.push(`enum constraint added: ${at}`);
|
|
1800
|
+
if (bEnum !== null && aEnum !== null) {
|
|
1801
|
+
const removed = bEnum.filter((v) => !aEnum.includes(v));
|
|
1802
|
+
const added = aEnum.filter((v) => !bEnum.includes(v));
|
|
1803
|
+
if (removed.length > 0) breaking.push(`enum narrowed: ${at} lost ${removed.join(", ")}`);
|
|
1804
|
+
if (added.length > 0) additive.push(`enum widened: ${at} gained ${added.join(", ")}`);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
for (const fieldName of Object.keys(a)) {
|
|
1808
|
+
if (Object.hasOwn(b, fieldName)) continue;
|
|
1809
|
+
const af = a[fieldName];
|
|
1810
|
+
if (af === void 0) continue;
|
|
1811
|
+
const at = `${typeName}.${fieldName}`;
|
|
1812
|
+
if (af.required) breaking.push(`field added as REQUIRED: ${at}`);
|
|
1813
|
+
else additive.push(`field added: ${at}`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
for (const typeName of Object.keys(after.types)) if (!Object.hasOwn(before.types, typeName)) additive.push(`type added: ${typeName}`);
|
|
1817
|
+
const bApi = before.api ?? null;
|
|
1818
|
+
const aApi = after.api ?? null;
|
|
1819
|
+
const bFormat = before.surfaceFormat ?? 1;
|
|
1820
|
+
const aFormat = after.surfaceFormat ?? 1;
|
|
1821
|
+
if (bApi !== null && aApi !== null && bFormat !== aFormat) review.push(`published-API comparison SKIPPED: surface format ${bFormat} -> ${aFormat}. The export naming scheme changed, so a diff would be noise rather than signal. Size this release from the type section above and from reading the .d.ts diff by hand.`);
|
|
1822
|
+
else if (bApi !== null && aApi !== null) {
|
|
1823
|
+
for (const pkg of Object.keys(bApi)) {
|
|
1824
|
+
const b = bApi[pkg];
|
|
1825
|
+
const a = aApi[pkg];
|
|
1826
|
+
if (b === void 0) continue;
|
|
1827
|
+
if (a === void 0) {
|
|
1828
|
+
breaking.push(`package no longer published: ${pkg}`);
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
for (const name of b.exports) if (!a.exports.includes(name)) breaking.push(`export removed: ${pkg}#${name}`);
|
|
1832
|
+
for (const name of a.exports) if (!b.exports.includes(name)) additive.push(`export added: ${pkg}#${name}`);
|
|
1833
|
+
if (b.dtsDigest !== a.dtsDigest) review.push(`${pkg}: emitted declarations changed (${b.dtsDigest} -> ${a.dtsDigest}). A signature moved somewhere in this package. Read the .d.ts diff and decide whether it breaks a caller — this cannot tell a widened return type from a narrowed one.`);
|
|
1834
|
+
}
|
|
1835
|
+
for (const pkg of Object.keys(aApi)) if (!Object.hasOwn(bApi, pkg)) additive.push(`package now published: ${pkg}`);
|
|
1836
|
+
}
|
|
1837
|
+
return {
|
|
1838
|
+
class: breaking.length > 0 ? "major" : additive.length > 0 || review.length > 0 ? "minor" : "patch",
|
|
1839
|
+
breaking,
|
|
1840
|
+
additive,
|
|
1841
|
+
review
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
/** Is `proposed` at least as large as `required`? */
|
|
1845
|
+
function classSatisfies(proposed, required) {
|
|
1846
|
+
return CLASS_RANK[proposed] >= CLASS_RANK[required];
|
|
1847
|
+
}
|
|
1848
|
+
/** The class implied by a version bump. Returns null when it is not a bump. */
|
|
1849
|
+
function bumpClass(from, to) {
|
|
1850
|
+
const p = (v) => {
|
|
1851
|
+
const [a = NaN, b = NaN, c = NaN] = v.trim().replace(/^v/, "").split(".").map(Number);
|
|
1852
|
+
return [
|
|
1853
|
+
a,
|
|
1854
|
+
b,
|
|
1855
|
+
c
|
|
1856
|
+
];
|
|
1857
|
+
};
|
|
1858
|
+
const [fa, fb, fc] = p(from);
|
|
1859
|
+
const [ta, tb, tc] = p(to);
|
|
1860
|
+
if ([
|
|
1861
|
+
fa,
|
|
1862
|
+
fb,
|
|
1863
|
+
fc,
|
|
1864
|
+
ta,
|
|
1865
|
+
tb,
|
|
1866
|
+
tc
|
|
1867
|
+
].some((n) => !Number.isFinite(n))) return null;
|
|
1868
|
+
if (ta > fa) return "major";
|
|
1869
|
+
if (ta === fa && tb > fb) return "minor";
|
|
1870
|
+
if (ta === fa && tb === fb && tc > fc) return "patch";
|
|
1871
|
+
return null;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
//#endregion
|
|
1875
|
+
//#region src/todo-resolution.ts
|
|
1876
|
+
/** How much of the notes / body opening is read for a closure cue. */
|
|
1877
|
+
const OPENING = 240;
|
|
1878
|
+
const CUES = [
|
|
1879
|
+
["duplicate", /\b(duplicate of|dup(?:e)? of|duplicates #|same (?:defect|bug|issue) as #)/i],
|
|
1880
|
+
["superseded", /\b(superseded|folded into|replaced by|subsumed (?:by|into)|rolled into|merged into #)/i],
|
|
1881
|
+
["obsolete", /\b(obsolete|moot|no longer (?:relevant|needed|applies|an issue)|overtaken by events|as stale\b|already fixed)/i],
|
|
1882
|
+
["wont_do", /\b(won'?t[ -](?:do|fix)|wontfix|not (?:needed|worth (?:it|doing))|declined|cancell?ed|abandoned|not doing|invalid|not[ -]a[ -]bug)\b/i],
|
|
1883
|
+
["completed", /\b(done|merged|shipped|landed|released|fixed|implemented|deployed|resolved|verified live|complete|completed|delivered|(?:by|via|in) PR #?\d+)\b/i]
|
|
1884
|
+
];
|
|
1885
|
+
function inferTodoResolution(todo) {
|
|
1886
|
+
const opening = (t) => typeof t === "string" ? t.slice(0, OPENING) : "";
|
|
1887
|
+
const text = [
|
|
1888
|
+
/^\s*\[([^\]]*)\]/.exec(todo.title ?? "")?.[1] ?? "",
|
|
1889
|
+
opening(todo.notes),
|
|
1890
|
+
opening(todo.body)
|
|
1891
|
+
].join("\n");
|
|
1892
|
+
let best = null;
|
|
1893
|
+
for (const [resolution, cue] of CUES) {
|
|
1894
|
+
const m = cue.exec(text);
|
|
1895
|
+
if (m !== null && (best === null || m.index < best.at)) best = {
|
|
1896
|
+
at: m.index,
|
|
1897
|
+
resolution
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
return best?.resolution ?? "unspecified";
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region src/index.ts
|
|
1905
|
+
function createCortextPlugin(cfg) {
|
|
1906
|
+
return {
|
|
1907
|
+
name: CORTEXT_COORDINATOR_NAME,
|
|
1908
|
+
contractVersion: SUBSTRATE_CONTRACT_RANGE,
|
|
1909
|
+
onStart: (ctx) => {
|
|
1910
|
+
reportUncoveredNamespaces(cfg, ctx.metric);
|
|
1911
|
+
},
|
|
1912
|
+
namespaces: [buildClaudeNamespace(cfg.namespace), buildArchitectureNamespace(cfg.architectureNamespace)],
|
|
1913
|
+
policies: buildAuthPolicies(cfg.auth ?? DEFAULT_AUTH),
|
|
1914
|
+
schedule: cfg.autoCompactIntervalMs !== void 0 ? [{
|
|
1915
|
+
name: `${cfg.namespace}-auto-compact`,
|
|
1916
|
+
intervalMs: cfg.autoCompactIntervalMs,
|
|
1917
|
+
runOnStart: false,
|
|
1918
|
+
handler: async (taskCtx) => {
|
|
1919
|
+
await runCompact(taskCtx.engine, cfg.namespace, cfg.compactRetention, false);
|
|
1920
|
+
taskCtx.metric("compact_applied", { namespace: cfg.namespace });
|
|
1921
|
+
}
|
|
1922
|
+
}] : [],
|
|
1923
|
+
routes: (router, routeCtx) => {
|
|
1924
|
+
registerOnboardRoute(router, routeCtx, cfg.compactRetention, cfg.namespace);
|
|
1925
|
+
registerCompactRoute(router, routeCtx, cfg.compactRetention, cfg.namespace, cfg.auth ?? DEFAULT_AUTH);
|
|
1926
|
+
}
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
//#endregion
|
|
1931
|
+
export { runCompact as C, isMonotonicAuth as E, registerCompactRoute as S, buildAuthPolicies as T, TODO_RESOLUTION_ALIASES as _, classSatisfies as a, buildArchitectureNamespace as b, AGENT_RULE_CATEGORY_VOCAB as c, ARCHITECTURE_TYPE_NAMES as d, CLAUDE_TYPE_NAMES as f, HOST_INSTALL_FRAGMENT_STATES as g, HOST_INSTALL_CHECK_STATES as h, bumpClass as i, AGENT_RULE_SEVERITY_ALIASES as l, DECISION_STATUS_VOCAB as m, inferTodoResolution as n, classifySurfaceChange as o, DECISION_STATUS_ALIASES as p, buildReleaseSurface as r, registerOnboardRoute as s, createCortextPlugin as t, AGENT_RULE_SEVERITY_VOCAB as u, TODO_STATUS_ALIASES as v, DEFAULT_AUTH as w, buildClaudeNamespace as x, TODO_STATUS_IMPLIES as y };
|