loadout-ai 0.8.0 → 0.9.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/CHANGELOG.md +76 -1
- package/README.md +149 -304
- package/SECURITY.md +21 -1
- package/catalog/discovered.json +27897 -25327
- package/catalog/packages.json +4 -4
- package/dist/src/cli.js +12 -4
- package/dist/src/commands/agents.js +3 -1
- package/dist/src/commands/catalog-candidate.js +150 -0
- package/dist/src/commands/catalog-workflows.js +147 -0
- package/dist/src/commands/catalog.js +9 -354
- package/dist/src/commands/coordinate.js +586 -0
- package/dist/src/commands/coordination-discussions.js +194 -0
- package/dist/src/commands/coordination-sessions.js +197 -0
- package/dist/src/commands/inventory.js +4 -2
- package/dist/src/core/{routing → agents}/model-config.js +7 -2
- package/dist/src/core/catalog/catalog.js +1 -0
- package/dist/src/core/catalog/registry.js +5 -19
- package/dist/src/core/coordination/adapters/claude-code.js +154 -0
- package/dist/src/core/coordination/adapters/codex.js +137 -0
- package/dist/src/core/coordination/adapters/types.js +64 -0
- package/dist/src/core/coordination/auth.js +59 -0
- package/dist/src/core/coordination/bridge-lease.js +79 -0
- package/dist/src/core/coordination/conflict-preview.js +167 -0
- package/dist/src/core/coordination/contract-diff.js +106 -0
- package/dist/src/core/coordination/coordinator.js +552 -0
- package/dist/src/core/coordination/crash-recovery.js +169 -0
- package/dist/src/core/coordination/daemon.js +667 -0
- package/dist/src/core/coordination/discussion.js +340 -0
- package/dist/src/core/coordination/events.js +161 -0
- package/dist/src/core/coordination/http-api.js +118 -0
- package/dist/src/core/coordination/interrupt-policy.js +89 -0
- package/dist/src/core/coordination/lock.js +110 -0
- package/dist/src/core/coordination/mcp-server.js +424 -0
- package/dist/src/core/coordination/redaction.js +85 -0
- package/dist/src/core/coordination/replay.js +188 -0
- package/dist/src/core/coordination/retention.js +128 -0
- package/dist/src/core/coordination/runtime.js +21 -0
- package/dist/src/core/coordination/session-manager.js +366 -0
- package/dist/src/core/coordination/watcher.js +128 -0
- package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
- package/dist/src/core/{routing → delegation}/handoff.js +40 -9
- package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
- package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence.js +3 -214
- package/dist/src/core/discovery/community.js +12 -4
- package/dist/src/core/discovery/github-discovery.js +6 -2
- package/dist/src/core/discovery/private-discovery.js +6 -2
- package/dist/src/core/install/reconcile.js +1 -1
- package/dist/src/core/install/uninstall.js +39 -3
- package/dist/src/core/reporting/cli-guide.js +9 -5
- package/dist/src/core/reporting/completion.js +43 -38
- package/dist/src/core/reporting/doctor.js +1 -1
- package/dist/src/core/runtime/bounded-json.js +65 -0
- package/dist/src/core/runtime/github.js +30 -16
- package/dist/src/core/runtime/mcp-recipes.js +1 -1
- package/dist/src/core/workspace/active-policy.js +1 -1
- package/docs/DEMO_SCRIPT.md +19 -23
- package/docs/DISCOVERED.md +251 -251
- package/docs/FEATURE_TEST_MATRIX.md +3 -3
- package/docs/LIVE_COLLABORATION.md +234 -0
- package/docs/REFERENCE.md +163 -0
- package/docs/RELEASE_REVIEW.md +1 -1
- package/docs/TESTING.md +17 -1
- package/docs/USER_TEST_GUIDE.md +138 -3
- package/docs/assets/loadout-discover-activate.webp +0 -0
- package/docs/assets/loadout-handoff-coordinate.webp +0 -0
- package/docs/assets/loadout-social-preview.png +0 -0
- package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
- package/docs/decisions/002-local-daemon-authentication.md +32 -0
- package/docs/decisions/003-bounded-agent-discussions.md +91 -0
- package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
- package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
- package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
- package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
- package/package.json +13 -4
- package/skills/loadout-handoff/SKILL.md +238 -0
- package/dist/src/core/routing/policy.js +0 -147
- package/dist/src/core/routing/route.js +0 -411
- package/docs/assets/loadout-workflow.png +0 -0
- package/skills/loadout-router/SKILL.md +0 -85
- /package/dist/src/core/{routing → agents}/credentials.js +0 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live collaboration coordinator — Phase 1 (structured async).
|
|
3
|
+
*
|
|
4
|
+
* Extends the JSONL handoff log with typed events, monotonic sequence numbers,
|
|
5
|
+
* cursor-based reads, acknowledgement tracking, file ownership, and conflict
|
|
6
|
+
* detection. No daemon — reads and writes go through the same append-only log.
|
|
7
|
+
*/
|
|
8
|
+
import { chmod, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
9
|
+
import { isAbsolute, join, posix } from "node:path";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { coordinationEventSchema, validatePayload, } from "./events.js";
|
|
12
|
+
import { withCoordinationLock } from "./lock.js";
|
|
13
|
+
import { redactCoordinationInput } from "./redaction.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Paths
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const COORD_DIR = ".handoff";
|
|
18
|
+
const COORD_LOG = "coordination.jsonl";
|
|
19
|
+
const KILL_SWITCH_FILE = "KILL_SWITCH";
|
|
20
|
+
function coordDir(projectRoot) {
|
|
21
|
+
return join(projectRoot, COORD_DIR);
|
|
22
|
+
}
|
|
23
|
+
function coordLogPath(projectRoot) {
|
|
24
|
+
return join(coordDir(projectRoot), COORD_LOG);
|
|
25
|
+
}
|
|
26
|
+
export class CoordinationHaltedError extends Error {
|
|
27
|
+
constructor(reason) {
|
|
28
|
+
super(`Coordination kill switch is active${reason ? `: ${reason}` : ""}`);
|
|
29
|
+
this.name = "CoordinationHaltedError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class CoordinationConflictError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = "CoordinationConflictError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function assertCoordinationEnabled(projectRoot) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(join(coordDir(projectRoot), KILL_SWITCH_FILE), "utf8");
|
|
43
|
+
let reason;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (typeof parsed.reason === "string")
|
|
47
|
+
reason = parsed.reason;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// The file itself is authoritative even if its explanatory JSON is damaged.
|
|
51
|
+
}
|
|
52
|
+
throw new CoordinationHaltedError(reason);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (typeof error === "object" &&
|
|
56
|
+
error !== null &&
|
|
57
|
+
"code" in error &&
|
|
58
|
+
error.code === "ENOENT") {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function readCoordLog(projectRoot) {
|
|
65
|
+
let content;
|
|
66
|
+
try {
|
|
67
|
+
content = await readFile(coordLogPath(projectRoot), "utf8");
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (typeof error === "object" &&
|
|
71
|
+
error !== null &&
|
|
72
|
+
"code" in error &&
|
|
73
|
+
error.code === "ENOENT") {
|
|
74
|
+
return { events: [], corrupt: [], highSeq: -1 };
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const events = [];
|
|
79
|
+
const corrupt = [];
|
|
80
|
+
let highSeq = -1;
|
|
81
|
+
content.split("\n").forEach((raw, index) => {
|
|
82
|
+
if (!raw.trim())
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = coordinationEventSchema.safeParse(JSON.parse(raw));
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
const reason = parsed.error.issues
|
|
88
|
+
.map((i) => `${i.path.join(".") || "event"}: ${i.message}`)
|
|
89
|
+
.join("; ");
|
|
90
|
+
throw new Error(reason);
|
|
91
|
+
}
|
|
92
|
+
events.push(parsed.data);
|
|
93
|
+
if (parsed.data.seq > highSeq)
|
|
94
|
+
highSeq = parsed.data.seq;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
corrupt.push({
|
|
98
|
+
line: index + 1,
|
|
99
|
+
reason: error instanceof Error ? error.message : "unparseable",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return { events, corrupt, highSeq };
|
|
104
|
+
}
|
|
105
|
+
/** Read events after a cursor (sequence number). */
|
|
106
|
+
export async function readAfterCursor(projectRoot, cursor) {
|
|
107
|
+
const log = await readCoordLog(projectRoot);
|
|
108
|
+
return {
|
|
109
|
+
events: log.events.filter((e) => e.seq > cursor),
|
|
110
|
+
highSeq: log.highSeq,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Read events for a specific agent (addressed to them, or broadcast). */
|
|
114
|
+
export async function readForAgent(projectRoot, agent, cursor = -1) {
|
|
115
|
+
const { events, highSeq } = await readAfterCursor(projectRoot, cursor);
|
|
116
|
+
return {
|
|
117
|
+
events: events.filter((e) => e.to === agent || e.to === "*" || e.from === agent),
|
|
118
|
+
highSeq,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const MAX_PAYLOAD_BYTES = 128 * 1_024;
|
|
122
|
+
function normalizeClaimPath(value) {
|
|
123
|
+
const portable = value.trim().replaceAll("\\", "/");
|
|
124
|
+
if (isAbsolute(portable) || /^[A-Za-z]:\//.test(portable)) {
|
|
125
|
+
throw new Error(`Ownership paths must be project-relative: ${value}`);
|
|
126
|
+
}
|
|
127
|
+
const normalized = posix.normalize(portable).replace(/^\.\//, "");
|
|
128
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
129
|
+
throw new Error(`Ownership paths cannot escape the project: ${value}`);
|
|
130
|
+
}
|
|
131
|
+
return normalized.replace(/\/$/, "") || ".";
|
|
132
|
+
}
|
|
133
|
+
function normalizedOptions(options) {
|
|
134
|
+
const redacted = redactCoordinationInput(options);
|
|
135
|
+
const payloadResult = validatePayload(redacted.type, redacted.payload);
|
|
136
|
+
if (!payloadResult.success) {
|
|
137
|
+
throw new Error(`Invalid payload for '${redacted.type}': ${payloadResult.error}`);
|
|
138
|
+
}
|
|
139
|
+
let payload = payloadResult.data;
|
|
140
|
+
if (redacted.type === "ownership" && payload) {
|
|
141
|
+
payload = {
|
|
142
|
+
...payload,
|
|
143
|
+
paths: payload.paths.map(normalizeClaimPath),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (payload !== undefined) {
|
|
147
|
+
let serialized;
|
|
148
|
+
try {
|
|
149
|
+
serialized = JSON.stringify(payload);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error("Invalid coordination payload: payload must be JSON");
|
|
153
|
+
}
|
|
154
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_PAYLOAD_BYTES) {
|
|
155
|
+
throw new Error(`Invalid coordination payload: exceeds ${MAX_PAYLOAD_BYTES} bytes`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { ...redacted, ...(payload === undefined ? {} : { payload }) };
|
|
159
|
+
}
|
|
160
|
+
async function appendLocked(projectRoot, options, existingLog) {
|
|
161
|
+
await assertCoordinationEnabled(projectRoot);
|
|
162
|
+
const checked = normalizedOptions(options);
|
|
163
|
+
const log = existingLog ?? (await readCoordLog(projectRoot));
|
|
164
|
+
if (checked.type === "ack") {
|
|
165
|
+
const eventSeq = checked.payload.eventSeq;
|
|
166
|
+
if (eventSeq > log.highSeq) {
|
|
167
|
+
throw new CoordinationConflictError("acknowledgement_conflict", `Cannot acknowledge seq ${eventSeq}; current watermark is ${log.highSeq}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (checked.type === "contract") {
|
|
171
|
+
const candidate = checked.payload;
|
|
172
|
+
const currentRevision = log.events.reduce((highest, event) => {
|
|
173
|
+
if (event.type !== "contract" || !event.payload)
|
|
174
|
+
return highest;
|
|
175
|
+
const contract = event.payload;
|
|
176
|
+
return contract.name === candidate.name
|
|
177
|
+
? Math.max(highest, contract.revision)
|
|
178
|
+
: highest;
|
|
179
|
+
}, 0);
|
|
180
|
+
if (candidate.revision <= currentRevision) {
|
|
181
|
+
throw new CoordinationConflictError("contract_revision_conflict", `Contract '${candidate.name}' revision ${candidate.revision} is stale; current revision is ${currentRevision}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const event = {
|
|
185
|
+
id: randomUUID().slice(0, 8),
|
|
186
|
+
seq: log.highSeq + 1,
|
|
187
|
+
type: checked.type,
|
|
188
|
+
from: checked.from,
|
|
189
|
+
to: checked.to,
|
|
190
|
+
description: checked.description,
|
|
191
|
+
...(checked.context ? { context: checked.context } : {}),
|
|
192
|
+
...(checked.resolves ? { resolves: checked.resolves } : {}),
|
|
193
|
+
...(checked.payload ? { payload: checked.payload } : {}),
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
};
|
|
196
|
+
const parsed = coordinationEventSchema.safeParse(event);
|
|
197
|
+
if (!parsed.success) {
|
|
198
|
+
const reason = parsed.error.issues
|
|
199
|
+
.map((issue) => `${issue.path.join(".") || "event"}: ${issue.message}`)
|
|
200
|
+
.join("; ");
|
|
201
|
+
throw new Error(`Invalid coordination event: ${reason}`);
|
|
202
|
+
}
|
|
203
|
+
await writeFile(coordLogPath(projectRoot), `${JSON.stringify(parsed.data)}\n`, {
|
|
204
|
+
flag: "a",
|
|
205
|
+
mode: 0o600,
|
|
206
|
+
});
|
|
207
|
+
await chmod(coordLogPath(projectRoot), 0o600);
|
|
208
|
+
return parsed.data;
|
|
209
|
+
}
|
|
210
|
+
export async function emit(projectRoot, options) {
|
|
211
|
+
const dir = coordDir(projectRoot);
|
|
212
|
+
await mkdir(dir, { recursive: true });
|
|
213
|
+
return withCoordinationLock(dir, () => appendLocked(projectRoot, options));
|
|
214
|
+
}
|
|
215
|
+
/** Get the current ownership map from the log. Later claims override earlier. */
|
|
216
|
+
export async function getOwnership(projectRoot) {
|
|
217
|
+
const log = await readCoordLog(projectRoot);
|
|
218
|
+
return ownershipFromEvents(log.events);
|
|
219
|
+
}
|
|
220
|
+
function pathsOverlap(left, right) {
|
|
221
|
+
return (left === right ||
|
|
222
|
+
left === "." ||
|
|
223
|
+
right === "." ||
|
|
224
|
+
left.startsWith(`${right}/`) ||
|
|
225
|
+
right.startsWith(`${left}/`));
|
|
226
|
+
}
|
|
227
|
+
function ownershipFromEvents(events) {
|
|
228
|
+
const ownership = new Map();
|
|
229
|
+
for (const event of events) {
|
|
230
|
+
if (event.type !== "ownership" || !event.payload)
|
|
231
|
+
continue;
|
|
232
|
+
const payload = event.payload;
|
|
233
|
+
const paths = payload.paths.map(normalizeClaimPath);
|
|
234
|
+
for (const path of paths) {
|
|
235
|
+
if (payload.mode === "release") {
|
|
236
|
+
const existing = ownership.get(path);
|
|
237
|
+
if (existing?.agent === event.from)
|
|
238
|
+
ownership.delete(path);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
ownership.set(path, {
|
|
242
|
+
agent: event.from,
|
|
243
|
+
paths,
|
|
244
|
+
mode: payload.mode,
|
|
245
|
+
eventId: event.id,
|
|
246
|
+
seq: event.seq,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return ownership;
|
|
251
|
+
}
|
|
252
|
+
/** Check if claiming paths would conflict with existing ownership. */
|
|
253
|
+
export async function checkOwnershipConflicts(projectRoot, agent, paths, mode) {
|
|
254
|
+
const ownership = await getOwnership(projectRoot);
|
|
255
|
+
const conflicts = [];
|
|
256
|
+
for (const requestedPath of paths.map(normalizeClaimPath)) {
|
|
257
|
+
for (const [ownedPath, existing] of ownership) {
|
|
258
|
+
if (!pathsOverlap(requestedPath, ownedPath))
|
|
259
|
+
continue;
|
|
260
|
+
if (existing.agent === agent)
|
|
261
|
+
continue;
|
|
262
|
+
if (existing.mode === "shared" && mode === "shared")
|
|
263
|
+
continue;
|
|
264
|
+
conflicts.push({
|
|
265
|
+
path: requestedPath,
|
|
266
|
+
currentOwner: existing.agent,
|
|
267
|
+
currentMode: existing.mode,
|
|
268
|
+
requestedBy: agent,
|
|
269
|
+
requestedMode: mode,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return conflicts;
|
|
274
|
+
}
|
|
275
|
+
export class OwnershipConflictError extends CoordinationConflictError {
|
|
276
|
+
conflicts;
|
|
277
|
+
constructor(conflicts) {
|
|
278
|
+
super("ownership_conflict", formatConflicts(conflicts));
|
|
279
|
+
this.conflicts = conflicts;
|
|
280
|
+
this.name = "OwnershipConflictError";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Check and persist ownership as one atomic mutation. */
|
|
284
|
+
export async function claimOwnership(projectRoot, options) {
|
|
285
|
+
const dir = coordDir(projectRoot);
|
|
286
|
+
await mkdir(dir, { recursive: true });
|
|
287
|
+
return withCoordinationLock(dir, async () => {
|
|
288
|
+
const log = await readCoordLog(projectRoot);
|
|
289
|
+
const ownership = ownershipFromEvents(log.events);
|
|
290
|
+
const conflicts = [];
|
|
291
|
+
for (const requestedPath of options.paths.map(normalizeClaimPath)) {
|
|
292
|
+
for (const [ownedPath, existing] of ownership) {
|
|
293
|
+
if (!pathsOverlap(requestedPath, ownedPath))
|
|
294
|
+
continue;
|
|
295
|
+
if (existing.agent === options.agent)
|
|
296
|
+
continue;
|
|
297
|
+
if (existing.mode === "shared" && options.mode === "shared")
|
|
298
|
+
continue;
|
|
299
|
+
conflicts.push({
|
|
300
|
+
path: requestedPath,
|
|
301
|
+
currentOwner: existing.agent,
|
|
302
|
+
currentMode: existing.mode,
|
|
303
|
+
requestedBy: options.agent,
|
|
304
|
+
requestedMode: options.mode,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (conflicts.length > 0)
|
|
309
|
+
throw new OwnershipConflictError(conflicts);
|
|
310
|
+
return appendLocked(projectRoot, {
|
|
311
|
+
from: options.agent,
|
|
312
|
+
to: "*",
|
|
313
|
+
type: "ownership",
|
|
314
|
+
description: `${options.agent} claims: ${options.paths.join(", ")}`,
|
|
315
|
+
payload: {
|
|
316
|
+
paths: options.paths,
|
|
317
|
+
mode: options.mode,
|
|
318
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
319
|
+
},
|
|
320
|
+
}, log);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** Release exact paths currently owned by the requesting agent. */
|
|
324
|
+
export async function releaseOwnership(projectRoot, options) {
|
|
325
|
+
const dir = coordDir(projectRoot);
|
|
326
|
+
await mkdir(dir, { recursive: true });
|
|
327
|
+
return withCoordinationLock(dir, async () => {
|
|
328
|
+
const log = await readCoordLog(projectRoot);
|
|
329
|
+
const ownership = ownershipFromEvents(log.events);
|
|
330
|
+
const paths = options.paths.map(normalizeClaimPath);
|
|
331
|
+
for (const path of paths) {
|
|
332
|
+
const existing = ownership.get(path);
|
|
333
|
+
if (existing && existing.agent !== options.agent) {
|
|
334
|
+
throw new CoordinationConflictError("ownership_release_conflict", `Cannot release '${path}'; it is owned by ${existing.agent}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return appendLocked(projectRoot, {
|
|
338
|
+
from: options.agent,
|
|
339
|
+
to: "*",
|
|
340
|
+
type: "ownership",
|
|
341
|
+
description: `${options.agent} releases: ${paths.join(", ")}`,
|
|
342
|
+
payload: { paths, mode: "release" },
|
|
343
|
+
}, log);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
function contractsFromEvents(events) {
|
|
347
|
+
const contracts = new Map();
|
|
348
|
+
for (const event of events) {
|
|
349
|
+
if (event.type !== "contract" || !event.payload)
|
|
350
|
+
continue;
|
|
351
|
+
const payload = event.payload;
|
|
352
|
+
const existing = contracts.get(payload.name);
|
|
353
|
+
if (!existing || payload.revision > existing.revision) {
|
|
354
|
+
contracts.set(payload.name, {
|
|
355
|
+
name: payload.name,
|
|
356
|
+
revision: payload.revision,
|
|
357
|
+
body: payload.body,
|
|
358
|
+
format: payload.format,
|
|
359
|
+
publisher: event.from,
|
|
360
|
+
eventId: event.id,
|
|
361
|
+
seq: event.seq,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return contracts;
|
|
366
|
+
}
|
|
367
|
+
/** Allocate and append a contract revision as one atomic mutation. */
|
|
368
|
+
export async function publishContract(projectRoot, options) {
|
|
369
|
+
const dir = coordDir(projectRoot);
|
|
370
|
+
await mkdir(dir, { recursive: true });
|
|
371
|
+
return withCoordinationLock(dir, async () => {
|
|
372
|
+
const log = await readCoordLog(projectRoot);
|
|
373
|
+
const currentRevision = log.events.reduce((highest, event) => {
|
|
374
|
+
if (event.type !== "contract" || !event.payload)
|
|
375
|
+
return highest;
|
|
376
|
+
const contract = event.payload;
|
|
377
|
+
return contract.name === options.name
|
|
378
|
+
? Math.max(highest, contract.revision)
|
|
379
|
+
: highest;
|
|
380
|
+
}, 0);
|
|
381
|
+
const revision = options.revision ?? currentRevision + 1;
|
|
382
|
+
return appendLocked(projectRoot, {
|
|
383
|
+
from: options.from,
|
|
384
|
+
to: "*",
|
|
385
|
+
type: "contract",
|
|
386
|
+
description: `Contract '${options.name}' rev${revision}`,
|
|
387
|
+
payload: {
|
|
388
|
+
name: options.name,
|
|
389
|
+
revision,
|
|
390
|
+
body: options.body,
|
|
391
|
+
...(options.format ? { format: options.format } : {}),
|
|
392
|
+
},
|
|
393
|
+
}, log);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** Get the latest revision of each named contract. */
|
|
397
|
+
export async function getContracts(projectRoot) {
|
|
398
|
+
const log = await readCoordLog(projectRoot);
|
|
399
|
+
return contractsFromEvents(log.events);
|
|
400
|
+
}
|
|
401
|
+
function ackStateFromEvents(events) {
|
|
402
|
+
const cursors = new Map();
|
|
403
|
+
for (const event of events) {
|
|
404
|
+
if (event.type !== "ack" || !event.payload)
|
|
405
|
+
continue;
|
|
406
|
+
const payload = event.payload;
|
|
407
|
+
const current = cursors.get(event.from) ?? -1;
|
|
408
|
+
if (payload.eventSeq > current)
|
|
409
|
+
cursors.set(event.from, payload.eventSeq);
|
|
410
|
+
}
|
|
411
|
+
const agents = new Set(events.map((event) => event.from));
|
|
412
|
+
const unacked = events.filter((event) => {
|
|
413
|
+
if (event.type === "ack")
|
|
414
|
+
return false;
|
|
415
|
+
for (const agent of agents) {
|
|
416
|
+
if (agent === event.from)
|
|
417
|
+
continue;
|
|
418
|
+
const cursor = cursors.get(agent) ?? -1;
|
|
419
|
+
if (cursor >= event.seq)
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
return { cursors, unacked };
|
|
425
|
+
}
|
|
426
|
+
export async function getAckState(projectRoot) {
|
|
427
|
+
const log = await readCoordLog(projectRoot);
|
|
428
|
+
return ackStateFromEvents(log.events);
|
|
429
|
+
}
|
|
430
|
+
const SNAPSHOT_LIMITS = {
|
|
431
|
+
pendingTasks: 50,
|
|
432
|
+
activeContracts: 50,
|
|
433
|
+
ownership: 500,
|
|
434
|
+
recentDecisions: 10,
|
|
435
|
+
unackedForAgent: 100,
|
|
436
|
+
};
|
|
437
|
+
function boundedTail(values, limit) {
|
|
438
|
+
return values.slice(Math.max(0, values.length - limit));
|
|
439
|
+
}
|
|
440
|
+
export async function snapshot(projectRoot, agent) {
|
|
441
|
+
const log = await readCoordLog(projectRoot);
|
|
442
|
+
const contracts = [...contractsFromEvents(log.events).values()].sort((left, right) => left.seq - right.seq);
|
|
443
|
+
const ownership = [...ownershipFromEvents(log.events).values()].sort((left, right) => left.seq - right.seq);
|
|
444
|
+
const ackState = ackStateFromEvents(log.events);
|
|
445
|
+
// Pending tasks (not yet resolved)
|
|
446
|
+
const terminalTypes = new Set(["done", "error", "cancel"]);
|
|
447
|
+
const resolvedIds = new Set(log.events
|
|
448
|
+
.filter((e) => terminalTypes.has(e.type))
|
|
449
|
+
.flatMap((e) => {
|
|
450
|
+
if (e.resolves)
|
|
451
|
+
return [e.resolves];
|
|
452
|
+
const match = e.context?.match(/Resolves (\w+)/);
|
|
453
|
+
return match ? [match[1]] : [];
|
|
454
|
+
}));
|
|
455
|
+
const allPendingTasks = log.events.filter((e) => e.type === "task" && !resolvedIds.has(e.id));
|
|
456
|
+
// Recent decisions (last 10)
|
|
457
|
+
const decisions = log.events.filter((e) => e.type === "decision");
|
|
458
|
+
const recentDecisions = boundedTail(decisions, SNAPSHOT_LIMITS.recentDecisions);
|
|
459
|
+
// Events this agent hasn't acked
|
|
460
|
+
const agentCursor = ackState.cursors.get(agent) ?? -1;
|
|
461
|
+
const allUnackedForAgent = log.events.filter((e) => e.seq > agentCursor && e.from !== agent && e.type !== "ack");
|
|
462
|
+
const pendingTasks = boundedTail(allPendingTasks, SNAPSHOT_LIMITS.pendingTasks);
|
|
463
|
+
const activeContracts = boundedTail(contracts, SNAPSHOT_LIMITS.activeContracts);
|
|
464
|
+
const boundedOwnership = boundedTail(ownership, SNAPSHOT_LIMITS.ownership);
|
|
465
|
+
const unackedForAgent = boundedTail(allUnackedForAgent, SNAPSHOT_LIMITS.unackedForAgent);
|
|
466
|
+
return {
|
|
467
|
+
highSeq: log.highSeq,
|
|
468
|
+
pendingTasks,
|
|
469
|
+
activeContracts,
|
|
470
|
+
ownership: boundedOwnership,
|
|
471
|
+
recentDecisions,
|
|
472
|
+
unackedForAgent,
|
|
473
|
+
truncated: {
|
|
474
|
+
pendingTasks: allPendingTasks.length - pendingTasks.length,
|
|
475
|
+
activeContracts: contracts.length - activeContracts.length,
|
|
476
|
+
ownership: ownership.length - boundedOwnership.length,
|
|
477
|
+
recentDecisions: decisions.length - recentDecisions.length,
|
|
478
|
+
unackedForAgent: allUnackedForAgent.length - unackedForAgent.length,
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Formatting
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
export function formatSnapshot(snap) {
|
|
486
|
+
const lines = [`Coordination snapshot (seq ${snap.highSeq})`, ""];
|
|
487
|
+
if (snap.pendingTasks.length) {
|
|
488
|
+
lines.push(`Pending tasks (${snap.pendingTasks.length}):`);
|
|
489
|
+
for (const t of snap.pendingTasks) {
|
|
490
|
+
lines.push(` [${t.id}] ${t.from} → ${t.to}: ${t.description}`);
|
|
491
|
+
}
|
|
492
|
+
lines.push("");
|
|
493
|
+
}
|
|
494
|
+
if (snap.activeContracts.length) {
|
|
495
|
+
lines.push(`Active contracts (${snap.activeContracts.length}):`);
|
|
496
|
+
for (const c of snap.activeContracts) {
|
|
497
|
+
lines.push(` ${c.name} rev${c.revision} by ${c.publisher}${c.format ? ` (${c.format})` : ""}`);
|
|
498
|
+
}
|
|
499
|
+
lines.push("");
|
|
500
|
+
}
|
|
501
|
+
if (snap.ownership.length) {
|
|
502
|
+
lines.push(`File ownership (${snap.ownership.length} paths):`);
|
|
503
|
+
const byAgent = new Map();
|
|
504
|
+
for (const claim of snap.ownership) {
|
|
505
|
+
const existing = byAgent.get(claim.agent) ?? [];
|
|
506
|
+
existing.push(...claim.paths);
|
|
507
|
+
byAgent.set(claim.agent, existing);
|
|
508
|
+
}
|
|
509
|
+
for (const [agent, paths] of byAgent) {
|
|
510
|
+
lines.push(` ${agent}: ${paths.join(", ")}`);
|
|
511
|
+
}
|
|
512
|
+
lines.push("");
|
|
513
|
+
}
|
|
514
|
+
if (snap.unackedForAgent.length) {
|
|
515
|
+
lines.push(`Unacknowledged events (${snap.unackedForAgent.length}):`);
|
|
516
|
+
for (const e of snap.unackedForAgent.slice(-20)) {
|
|
517
|
+
lines.push(` seq=${e.seq} [${e.type}] ${e.from}: ${e.description}`);
|
|
518
|
+
}
|
|
519
|
+
if (snap.unackedForAgent.length > 20) {
|
|
520
|
+
lines.push(` ... and ${snap.unackedForAgent.length - 20} more`);
|
|
521
|
+
}
|
|
522
|
+
lines.push("");
|
|
523
|
+
}
|
|
524
|
+
if (snap.recentDecisions.length) {
|
|
525
|
+
lines.push(`Recent decisions (${snap.recentDecisions.length}):`);
|
|
526
|
+
for (const d of snap.recentDecisions) {
|
|
527
|
+
const title = d.payload?.title ?? d.description;
|
|
528
|
+
lines.push(` [${d.id}] ${d.from}: ${title}`);
|
|
529
|
+
}
|
|
530
|
+
lines.push("");
|
|
531
|
+
}
|
|
532
|
+
if (!snap.pendingTasks.length &&
|
|
533
|
+
!snap.activeContracts.length &&
|
|
534
|
+
!snap.ownership.length &&
|
|
535
|
+
!snap.unackedForAgent.length) {
|
|
536
|
+
lines.push("No active coordination state.");
|
|
537
|
+
}
|
|
538
|
+
const omitted = Object.values(snap.truncated).reduce((total, count) => total + count, 0);
|
|
539
|
+
if (omitted > 0) {
|
|
540
|
+
lines.push("", `Snapshot is bounded; ${omitted} older item(s) omitted. Use subscribe/replay for history.`);
|
|
541
|
+
}
|
|
542
|
+
return lines.join("\n");
|
|
543
|
+
}
|
|
544
|
+
export function formatConflicts(conflicts) {
|
|
545
|
+
if (!conflicts.length)
|
|
546
|
+
return "No ownership conflicts.";
|
|
547
|
+
const lines = [`Ownership conflicts (${conflicts.length}):`];
|
|
548
|
+
for (const c of conflicts) {
|
|
549
|
+
lines.push(` ${c.path}: owned by ${c.currentOwner} (${c.currentMode}), requested by ${c.requestedBy} (${c.requestedMode})`);
|
|
550
|
+
}
|
|
551
|
+
return lines.join("\n");
|
|
552
|
+
}
|