loadout-ai 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +45 -50
  3. package/catalog/discovered.json +29156 -26656
  4. package/dist/src/commands/catalog-workflows.js +227 -9
  5. package/dist/src/commands/coordinate.js +148 -4
  6. package/dist/src/commands/coordination-discussions.js +71 -9
  7. package/dist/src/core/catalog/safety.js +46 -2
  8. package/dist/src/core/coordination/adapters/claude-code.js +15 -7
  9. package/dist/src/core/coordination/adapters/codex.js +29 -2
  10. package/dist/src/core/coordination/auto-contract.js +457 -0
  11. package/dist/src/core/coordination/coordinator.js +5 -4
  12. package/dist/src/core/coordination/daemon.js +6 -3
  13. package/dist/src/core/coordination/discussion-pipeline.js +313 -0
  14. package/dist/src/core/coordination/discussion.js +22 -2
  15. package/dist/src/core/coordination/git-ownership.js +217 -0
  16. package/dist/src/core/coordination/lock.js +34 -4
  17. package/dist/src/core/coordination/quick-start.js +200 -0
  18. package/dist/src/core/coordination/retention.js +67 -5
  19. package/dist/src/core/delegation/handoff-bundle.js +253 -0
  20. package/dist/src/core/delegation/handoff-templates.js +222 -0
  21. package/dist/src/core/delegation/handoff-verification.js +117 -0
  22. package/dist/src/core/delegation/handoff.js +218 -26
  23. package/dist/src/core/install/catalog-install.js +8 -2
  24. package/dist/src/core/install/snapshot.js +49 -6
  25. package/dist/src/core/install/source.js +8 -6
  26. package/dist/src/core/install/update.js +55 -1
  27. package/docs/DISCOVERED.md +249 -251
  28. package/docs/FEATURE_TEST_MATRIX.md +26 -11
  29. package/docs/LIVE_COLLABORATION.md +49 -0
  30. package/docs/REFERENCE.md +100 -0
  31. package/docs/USER_TEST_GUIDE.md +75 -2
  32. package/docs/evidence/coordination-provider-check-2026-09-05.md +33 -0
  33. package/docs/specs/HANDOFF_CONTEXT_BUNDLES.md +139 -0
  34. package/docs/specs/HANDOFF_VERIFICATION.md +83 -0
  35. package/docs/superpowers/plans/2026-09-04-handoff-context-bundles.md +109 -0
  36. package/docs/superpowers/plans/2026-09-04-handoff-verification.md +56 -0
  37. package/docs/superpowers/plans/2026-09-05-pre-release-hardening.md +175 -0
  38. package/docs/superpowers/plans/2026-09-05-public-readiness.md +20 -0
  39. package/package.json +3 -2
  40. package/skills/loadout-handoff/SKILL.md +68 -15
  41. package/docs/DEMO_SCRIPT.md +0 -152
@@ -5,14 +5,20 @@
5
5
  * turn; the CLI does not provide mid-turn injection or global session listing.
6
6
  */
7
7
  import { execFile } from "node:child_process";
8
- import { promisify } from "node:util";
9
- const exec = promisify(execFile);
10
8
  const PROVIDER = "claude-code";
11
9
  const CLI = "claude";
12
- const defaultCommandDriver = async (command, args, options) => {
13
- const { stdout } = await exec(command, [...args], options);
14
- return { stdout: String(stdout) };
15
- };
10
+ export const runClaudeCommand = async (command, args, options) => new Promise((resolve, reject) => {
11
+ const child = execFile(command, [...args], { ...options, encoding: "utf8" }, (error, stdout) => {
12
+ if (error) {
13
+ reject(error);
14
+ return;
15
+ }
16
+ resolve({ stdout: String(stdout) });
17
+ });
18
+ // execFile opens a writable stdin pipe. Claude waits briefly for input
19
+ // unless the parent closes it, so end it as soon as the process starts.
20
+ child.stdin?.end();
21
+ });
16
22
  function parseSessionOutput(stdout) {
17
23
  let parsed;
18
24
  try {
@@ -40,7 +46,7 @@ export class ClaudeCodeAdapter {
40
46
  provider = PROVIDER;
41
47
  sessions = new Map();
42
48
  responses = new Map();
43
- constructor(runCommand = defaultCommandDriver) {
49
+ constructor(runCommand = runClaudeCommand) {
44
50
  this.runCommand = runCommand;
45
51
  }
46
52
  capabilities = {
@@ -73,6 +79,7 @@ export class ClaudeCodeAdapter {
73
79
  const { stdout } = await this.runCommand(CLI, args, {
74
80
  cwd: options.cwd,
75
81
  timeout: options.timeout ?? 30000,
82
+ ...(options.signal ? { signal: options.signal } : {}),
76
83
  });
77
84
  const output = parseSessionOutput(stdout);
78
85
  const sessionId = output.sessionId;
@@ -122,6 +129,7 @@ export class ClaudeCodeAdapter {
122
129
  const { stdout } = await this.runCommand(CLI, args, {
123
130
  cwd: session.cwd,
124
131
  timeout: options.timeout ?? 30000,
132
+ ...(options.signal ? { signal: options.signal } : {}),
125
133
  });
126
134
  const output = parseSessionOutput(stdout);
127
135
  if (output.response) {
@@ -17,6 +17,26 @@ function requireThreadId(thread) {
17
17
  }
18
18
  return id;
19
19
  }
20
+ async function runWithCancellation(thread, prompt, options) {
21
+ if (options.timeout === undefined) {
22
+ return thread.run(prompt, options.signal ? { signal: options.signal } : undefined);
23
+ }
24
+ const controller = new AbortController();
25
+ const onAbort = () => controller.abort(options.signal?.reason);
26
+ if (options.signal?.aborted)
27
+ onAbort();
28
+ else
29
+ options.signal?.addEventListener("abort", onAbort, { once: true });
30
+ const timer = setTimeout(() => controller.abort(new Error(`Codex provider turn timed out after ${options.timeout}ms`)), options.timeout);
31
+ timer.unref();
32
+ try {
33
+ return await thread.run(prompt, { signal: controller.signal });
34
+ }
35
+ finally {
36
+ clearTimeout(timer);
37
+ options.signal?.removeEventListener("abort", onAbort);
38
+ }
39
+ }
20
40
  function responseFromRun(result) {
21
41
  if (typeof result === "object" &&
22
42
  result !== null &&
@@ -60,10 +80,14 @@ export class CodexAdapter {
60
80
  if (options.resumeSessionId) {
61
81
  throw new Error("Use resume() to continue a Codex thread");
62
82
  }
83
+ options.signal?.throwIfAborted();
63
84
  const thread = this.driver.startThread({
64
85
  workingDirectory: options.cwd,
65
86
  });
66
- const result = await thread.run(options.prompt ?? "");
87
+ const result = await runWithCancellation(thread, options.prompt ?? "", {
88
+ ...(options.signal ? { signal: options.signal } : {}),
89
+ ...(options.timeout === undefined ? {} : { timeout: options.timeout }),
90
+ });
67
91
  const sessionId = requireThreadId(thread);
68
92
  const session = {
69
93
  sessionId,
@@ -109,7 +133,10 @@ export class CodexAdapter {
109
133
  return false;
110
134
  session.busy = true;
111
135
  try {
112
- const result = await thread.run(options.message);
136
+ const result = await runWithCancellation(thread, options.message, {
137
+ ...(options.signal ? { signal: options.signal } : {}),
138
+ ...(options.timeout === undefined ? {} : { timeout: options.timeout }),
139
+ });
113
140
  const response = responseFromRun(result);
114
141
  if (response)
115
142
  this.responses.set(session.sessionId, response);
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Auto-contract detection — scans for cross-boundary exports and suggests
3
+ * contracts when shared interfaces change.
4
+ *
5
+ * Uses regex-based scanning (no TS compiler dependency) to find exported
6
+ * symbols and cross-ownership imports. Runs as a pre-handoff check or
7
+ * standalone via `loadout coord detect`.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { join, relative, posix } from "node:path";
12
+ import { getOwnership, getContracts, } from "./coordinator.js";
13
+ // ── Export scanning ────────────────────────────────────────────────────
14
+ const EXPORT_PATTERNS = [
15
+ {
16
+ pattern: /^export\s+(?:declare\s+)?interface\s+(\w+)/,
17
+ kind: "interface",
18
+ },
19
+ {
20
+ pattern: /^export\s+(?:declare\s+)?type\s+(\w+)/,
21
+ kind: "type",
22
+ },
23
+ {
24
+ pattern: /^export\s+(?:declare\s+)?(?:async\s+)?function\s+(\w+)/,
25
+ kind: "function",
26
+ },
27
+ {
28
+ pattern: /^export\s+(?:declare\s+)?const\s+(\w+)/,
29
+ kind: "const",
30
+ },
31
+ {
32
+ pattern: /^export\s+(?:declare\s+)?let\s+(\w+)/,
33
+ kind: "const",
34
+ },
35
+ {
36
+ pattern: /^export\s+(?:declare\s+)?class\s+(\w+)/,
37
+ kind: "class",
38
+ },
39
+ {
40
+ pattern: /^export\s+(?:declare\s+)?enum\s+(\w+)/,
41
+ kind: "enum",
42
+ },
43
+ {
44
+ pattern: /^export\s+(?:declare\s+)?abstract\s+class\s+(\w+)/,
45
+ kind: "class",
46
+ },
47
+ ];
48
+ // Matches `export { Foo, Bar } from "./module"` and `export { Foo, Bar }`
49
+ const RE_EXPORT = /^export\s*\{([^}]+)\}(?:\s*from\s*["']([^"']+)["'])?/;
50
+ function scanExports(content, filePath) {
51
+ const symbols = [];
52
+ const lines = content.split("\n");
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const line = lines[i].trimStart();
55
+ if (!line.startsWith("export"))
56
+ continue;
57
+ // Named export patterns
58
+ for (const { pattern, kind } of EXPORT_PATTERNS) {
59
+ const match = line.match(pattern);
60
+ if (match) {
61
+ const declaration = exactDeclaration(line.trim(), kind);
62
+ symbols.push({
63
+ name: match[1],
64
+ kind,
65
+ file: filePath,
66
+ line: i + 1,
67
+ ...(declaration ? { declaration } : {}),
68
+ });
69
+ break;
70
+ }
71
+ }
72
+ // Re-exports: export { X, Y } from "..."
73
+ const reMatch = line.match(RE_EXPORT);
74
+ if (reMatch) {
75
+ const names = reMatch[1]
76
+ .split(",")
77
+ .map((s) => s
78
+ .trim()
79
+ .split(/\s+as\s+/)
80
+ .pop()
81
+ .trim())
82
+ .filter(Boolean);
83
+ for (const name of names) {
84
+ symbols.push({
85
+ name,
86
+ kind: "re-export",
87
+ file: filePath,
88
+ line: i + 1,
89
+ declaration: line.trim(),
90
+ });
91
+ }
92
+ }
93
+ }
94
+ return symbols;
95
+ }
96
+ function exactDeclaration(line, kind) {
97
+ if (kind === "function") {
98
+ const close = line.lastIndexOf(")");
99
+ const body = close >= 0 ? line.indexOf("{", close) : -1;
100
+ if (body > close)
101
+ return `${line.slice(0, body).trimEnd()};`;
102
+ return line.endsWith(";") ? line : undefined;
103
+ }
104
+ if (kind === "interface" || kind === "class" || kind === "enum") {
105
+ return line.includes("{") && line.includes("}") ? line : undefined;
106
+ }
107
+ if (kind === "type" || kind === "const") {
108
+ if (!line.includes("="))
109
+ return undefined;
110
+ // Reject incomplete multi-line declarations (opens a block without closing it).
111
+ const afterEq = line.slice(line.indexOf("=") + 1).trim();
112
+ if (afterEq.includes("{") && !afterEq.includes("}"))
113
+ return undefined;
114
+ if (afterEq.includes("(") && !afterEq.includes(")"))
115
+ return undefined;
116
+ if (afterEq.includes("[") && !afterEq.includes("]"))
117
+ return undefined;
118
+ return line;
119
+ }
120
+ return undefined;
121
+ }
122
+ // ── Import scanning ────────────────────────────────────────────────────
123
+ // Matches: import { X, Y } from "./path"
124
+ // import type { X } from "./path"
125
+ // import X from "./path"
126
+ const IMPORT_PATTERNS = [
127
+ // Named imports: import { A, B } from "..."
128
+ /import\s+(?:type\s+)?\{([^}]+)\}\s*from\s*["']([^"']+)["']/g,
129
+ // Default import: import X from "..."
130
+ /import\s+(\w+)\s+from\s*["']([^"']+)["']/g,
131
+ // Side-effect import is excluded (no symbols)
132
+ ];
133
+ function scanImports(content) {
134
+ const imports = [];
135
+ for (const pattern of IMPORT_PATTERNS) {
136
+ pattern.lastIndex = 0;
137
+ let match;
138
+ while ((match = pattern.exec(content)) !== null) {
139
+ const symbolPart = match[1];
140
+ const source = match[2];
141
+ // Skip node_modules / bare specifiers
142
+ if (!source.startsWith(".") && !source.startsWith("/"))
143
+ continue;
144
+ const symbols = symbolPart.includes(",") || symbolPart.includes("{")
145
+ ? symbolPart
146
+ .split(",")
147
+ .map((s) => s
148
+ .trim()
149
+ .replace(/^type\s+/, "")
150
+ .split(/\s+as\s+/)[0]
151
+ .trim())
152
+ .filter(Boolean)
153
+ : [symbolPart.trim()];
154
+ imports.push({ symbols, source });
155
+ }
156
+ }
157
+ return imports;
158
+ }
159
+ // ── File walking ───────────────────────────────────────────────────────
160
+ const SKIP_DIRS = new Set([
161
+ "node_modules",
162
+ ".git",
163
+ ".handoff",
164
+ "dist",
165
+ "build",
166
+ "out",
167
+ ".next",
168
+ "coverage",
169
+ "__pycache__",
170
+ ]);
171
+ const TS_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
172
+ const JS_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs"]);
173
+ const ALL_EXTENSIONS = new Set([...TS_EXTENSIONS, ...JS_EXTENSIONS]);
174
+ async function walkFiles(dir, rootDir, maxFiles) {
175
+ const files = [];
176
+ async function walk(current) {
177
+ if (files.length >= maxFiles)
178
+ return;
179
+ const entries = await readdir(current, { withFileTypes: true });
180
+ for (const entry of entries) {
181
+ if (files.length >= maxFiles)
182
+ return;
183
+ if (entry.name.startsWith(".") && entry.name !== ".")
184
+ continue;
185
+ if (SKIP_DIRS.has(entry.name))
186
+ continue;
187
+ const fullPath = join(current, entry.name);
188
+ if (entry.isDirectory()) {
189
+ await walk(fullPath);
190
+ }
191
+ else if (entry.isFile()) {
192
+ const ext = entry.name.slice(entry.name.lastIndexOf("."));
193
+ if (ALL_EXTENSIONS.has(ext)) {
194
+ files.push(normalizeProjectPath(relative(rootDir, fullPath)));
195
+ }
196
+ }
197
+ }
198
+ }
199
+ await walk(dir);
200
+ return files;
201
+ }
202
+ export function normalizeProjectPath(value) {
203
+ const normalized = posix.normalize(value.replaceAll("\\", "/"));
204
+ return normalized.startsWith("./") ? normalized.slice(2) : normalized;
205
+ }
206
+ function findOwner(filePath, ownership) {
207
+ // Find the most specific (longest) owned path that covers this file
208
+ let bestMatch = "";
209
+ let bestAgent;
210
+ const normalizedFile = normalizeProjectPath(filePath);
211
+ for (const [, claim] of ownership) {
212
+ for (const ownedPath of claim.paths) {
213
+ const claimPath = normalizeProjectPath(ownedPath);
214
+ const normalized = claimPath.endsWith("/")
215
+ ? claimPath.slice(0, -1)
216
+ : claimPath;
217
+ if ((normalizedFile === normalized ||
218
+ normalizedFile.startsWith(normalized + "/") ||
219
+ normalized === ".") &&
220
+ normalized.length > bestMatch.length) {
221
+ bestMatch = normalized;
222
+ bestAgent = claim.agent;
223
+ }
224
+ }
225
+ }
226
+ return bestAgent;
227
+ }
228
+ // ── Import resolution ──────────────────────────────────────────────────
229
+ function resolveImportPath(importSource, importerFile) {
230
+ const importerDir = posix.dirname(normalizeProjectPath(importerFile));
231
+ let resolved = posix.normalize(posix.join(importerDir, normalizeProjectPath(importSource)));
232
+ if (resolved === ".." || resolved.startsWith("../"))
233
+ return "";
234
+ // Strip .js extension (TS files import as .js)
235
+ resolved = resolved.replace(/\.js$/, "");
236
+ return resolved;
237
+ }
238
+ function matchSourceFile(resolvedImport, allFiles) {
239
+ // Try exact match, then with extensions
240
+ for (const ext of ["", ".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs"]) {
241
+ const candidate = resolvedImport + ext;
242
+ if (allFiles.includes(candidate))
243
+ return candidate;
244
+ }
245
+ // Try index file
246
+ for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
247
+ const candidate = resolvedImport + "/index" + ext;
248
+ if (allFiles.includes(candidate))
249
+ return candidate;
250
+ }
251
+ return undefined;
252
+ }
253
+ // ── Main detection ─────────────────────────────────────────────────────
254
+ /** Max files to scan to keep detection fast. */
255
+ const MAX_FILES = 5_000;
256
+ export async function detectContracts(projectRoot, options = {}) {
257
+ const ownership = await getOwnership(projectRoot);
258
+ const existingContracts = await getContracts(projectRoot);
259
+ // If no ownership is set up, can't detect cross-boundary imports
260
+ if (ownership.size === 0) {
261
+ return { crossBoundaryImports: [], candidates: [], filesScanned: 0 };
262
+ }
263
+ const maxFiles = options.maxFiles ?? MAX_FILES;
264
+ // Walk project files
265
+ const walkedFiles = options.scope
266
+ ? (await Promise.all(options.scope.map((dir) => {
267
+ const scope = normalizeProjectPath(dir);
268
+ if (!scope ||
269
+ posix.isAbsolute(scope) ||
270
+ scope === ".." ||
271
+ scope.startsWith("../"))
272
+ throw new Error(`Scope must stay inside the project: ${dir}`);
273
+ return walkFiles(join(projectRoot, ...scope.split("/")), projectRoot, maxFiles);
274
+ }))).flat()
275
+ : await walkFiles(projectRoot, projectRoot, maxFiles);
276
+ const allFiles = [...new Set(walkedFiles)].slice(0, maxFiles);
277
+ // Scan exports for every file
278
+ const exportsByFile = new Map();
279
+ for (const file of allFiles) {
280
+ try {
281
+ const content = await readFile(join(projectRoot, file), "utf8");
282
+ const exports = scanExports(content, file);
283
+ if (exports.length > 0) {
284
+ exportsByFile.set(file, exports);
285
+ }
286
+ }
287
+ catch {
288
+ // Skip unreadable files
289
+ }
290
+ }
291
+ // Scan imports and find cross-boundary references
292
+ const crossBoundaryImports = [];
293
+ for (const file of allFiles) {
294
+ const importerAgent = findOwner(file, ownership);
295
+ if (!importerAgent)
296
+ continue;
297
+ let content;
298
+ try {
299
+ content = await readFile(join(projectRoot, file), "utf8");
300
+ }
301
+ catch {
302
+ continue;
303
+ }
304
+ const imports = scanImports(content);
305
+ for (const imp of imports) {
306
+ const resolved = resolveImportPath(imp.source, file);
307
+ const sourceFile = matchSourceFile(resolved, allFiles);
308
+ if (!sourceFile)
309
+ continue;
310
+ const sourceAgent = findOwner(sourceFile, ownership);
311
+ if (!sourceAgent || sourceAgent === importerAgent)
312
+ continue;
313
+ crossBoundaryImports.push({
314
+ importer: file,
315
+ importerAgent,
316
+ source: sourceFile,
317
+ sourceAgent,
318
+ symbols: imp.symbols,
319
+ });
320
+ }
321
+ }
322
+ // Group by source file → contract candidate
323
+ const candidateMap = new Map();
324
+ for (const xbi of crossBoundaryImports) {
325
+ const existing = candidateMap.get(xbi.source);
326
+ if (existing) {
327
+ existing.consumers.add(xbi.importerAgent);
328
+ for (const s of xbi.symbols)
329
+ existing.symbols.add(s);
330
+ }
331
+ else {
332
+ candidateMap.set(xbi.source, {
333
+ sourceAgent: xbi.sourceAgent,
334
+ consumers: new Set([xbi.importerAgent]),
335
+ symbols: new Set(xbi.symbols),
336
+ });
337
+ }
338
+ }
339
+ // Build candidates
340
+ const candidates = [];
341
+ for (const [sourceFile, info] of candidateMap) {
342
+ const allExports = exportsByFile.get(sourceFile) ?? [];
343
+ const sharedSymbols = allExports.filter((e) => info.symbols.has(e.name));
344
+ // Generate contract name from file path
345
+ const name = contractNameFromPath(sourceFile);
346
+ // Check if contract already exists
347
+ const existingContract = existingContracts.get(name);
348
+ // Build suggested body from shared exports
349
+ const generated = buildContractBody(sourceFile, sharedSymbols);
350
+ const coverageState = !existingContract
351
+ ? "uncovered"
352
+ : existingContract.body === generated.body
353
+ ? "current"
354
+ : "stale";
355
+ candidates.push({
356
+ name,
357
+ sourceFile,
358
+ sourceAgent: info.sourceAgent,
359
+ consumers: [...info.consumers],
360
+ sharedSymbols,
361
+ existingContract,
362
+ suggestedBody: generated.body,
363
+ sourceHash: generated.sourceHash,
364
+ publishable: generated.publishable,
365
+ coverageState,
366
+ });
367
+ }
368
+ // Sort: uncovered (no existing contract) first, then by consumer count
369
+ candidates.sort((a, b) => {
370
+ if (!a.existingContract && b.existingContract)
371
+ return -1;
372
+ if (a.existingContract && !b.existingContract)
373
+ return 1;
374
+ return b.consumers.length - a.consumers.length;
375
+ });
376
+ return {
377
+ crossBoundaryImports,
378
+ candidates,
379
+ filesScanned: allFiles.length,
380
+ };
381
+ }
382
+ // ── Helpers ────────────────────────────────────────────────────────────
383
+ function contractNameFromPath(filePath) {
384
+ // src/api/types.ts → api-types
385
+ // src/core/coordination/events.ts → coordination-events
386
+ return filePath
387
+ .replace(/^src\//, "")
388
+ .replace(/\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/, "")
389
+ .replace(/\/index$/, "")
390
+ .replace(/\//g, "-");
391
+ }
392
+ function buildContractBody(sourceFile, symbols) {
393
+ if (symbols.length === 0) {
394
+ const manual = `// MANUAL: no exact typed exports detected for ${sourceFile}`;
395
+ return {
396
+ body: manual,
397
+ sourceHash: createHash("sha256").update("").digest("hex"),
398
+ publishable: false,
399
+ };
400
+ }
401
+ const declarations = symbols.map((symbol) => symbol.declaration ??
402
+ `// MANUAL: copy the complete ${symbol.kind} declaration for ${symbol.name}`);
403
+ const declarationBlock = declarations.join("\n");
404
+ const sourceHash = createHash("sha256")
405
+ .update(declarationBlock)
406
+ .digest("hex");
407
+ const lines = [
408
+ `// Auto-detected contract for ${sourceFile}`,
409
+ "// Snapshot only — the canonical source module remains authoritative.",
410
+ `// ${symbols.length} shared export(s)`,
411
+ `// loadout-source-sha256: sha256-${sourceHash}`,
412
+ "",
413
+ ...declarations,
414
+ ];
415
+ return {
416
+ body: lines.join("\n"),
417
+ sourceHash,
418
+ publishable: symbols.every((symbol) => !!symbol.declaration),
419
+ };
420
+ }
421
+ // ── Terminal formatting ────────────────────────────────────────────────
422
+ export function formatDetectionResult(result) {
423
+ const lines = [];
424
+ if (result.candidates.length === 0) {
425
+ lines.push("No cross-boundary exports detected.");
426
+ lines.push(`Scanned ${result.filesScanned} file(s).`);
427
+ if (result.crossBoundaryImports.length === 0) {
428
+ lines.push("\x1b[90mEither no ownership is set, or all imports stay within owned boundaries.\x1b[0m");
429
+ }
430
+ return lines.join("\n");
431
+ }
432
+ lines.push(`\x1b[1mAuto-detected ${result.candidates.length} contract candidate(s)\x1b[0m`);
433
+ lines.push(`\x1b[90m${result.filesScanned} files scanned · ${result.crossBoundaryImports.length} cross-boundary import(s)\x1b[0m`);
434
+ lines.push("");
435
+ for (const c of result.candidates) {
436
+ const status = c.coverageState === "current"
437
+ ? `\x1b[32m✓ current at rev${c.existingContract.revision}\x1b[0m`
438
+ : c.coverageState === "stale"
439
+ ? `\x1b[33m⚠ stale at rev${c.existingContract.revision}\x1b[0m`
440
+ : "\x1b[33m⚠ no contract\x1b[0m";
441
+ lines.push(` \x1b[1m${c.name}\x1b[0m ${status}`);
442
+ lines.push(` Source: \x1b[36m${c.sourceFile}\x1b[0m (owned by ${c.sourceAgent})`);
443
+ lines.push(` Consumers: ${c.consumers.join(", ")}`);
444
+ lines.push(` Shared: ${c.sharedSymbols.map((s) => s.name).join(", ") || "(barrel/re-exports)"}`);
445
+ if (!c.publishable)
446
+ lines.push(" \x1b[31mManual declaration required before publishing.\x1b[0m");
447
+ lines.push("");
448
+ }
449
+ const uncovered = result.candidates.filter((c) => c.coverageState !== "current");
450
+ if (uncovered.length > 0) {
451
+ lines.push(`\x1b[33m${uncovered.length} uncovered boundary(ies).\x1b[0m Publish with:`);
452
+ for (const c of uncovered) {
453
+ lines.push(` loadout coord contract ${c.name} --agent ${c.sourceAgent} --body "..." --format typescript`);
454
+ }
455
+ }
456
+ return lines.join("\n");
457
+ }
@@ -240,7 +240,7 @@ function ownershipFromEvents(events) {
240
240
  }
241
241
  ownership.set(path, {
242
242
  agent: event.from,
243
- paths,
243
+ paths: [path],
244
244
  mode: payload.mode,
245
245
  eventId: event.id,
246
246
  seq: event.seq,
@@ -502,12 +502,13 @@ export function formatSnapshot(snap) {
502
502
  lines.push(`File ownership (${snap.ownership.length} paths):`);
503
503
  const byAgent = new Map();
504
504
  for (const claim of snap.ownership) {
505
- const existing = byAgent.get(claim.agent) ?? [];
506
- existing.push(...claim.paths);
505
+ const existing = byAgent.get(claim.agent) ?? new Set();
506
+ for (const p of claim.paths)
507
+ existing.add(p);
507
508
  byAgent.set(claim.agent, existing);
508
509
  }
509
510
  for (const [agent, paths] of byAgent) {
510
- lines.push(` ${agent}: ${paths.join(", ")}`);
511
+ lines.push(` ${agent}: ${[...paths].join(", ")}`);
511
512
  }
512
513
  lines.push("");
513
514
  }
@@ -631,8 +631,11 @@ export async function startDaemon(projectRoot, port = 4510) {
631
631
  }
632
632
  });
633
633
  server.listen(port, "127.0.0.1", async () => {
634
+ // Resolve the actual port (differs from `port` when port === 0)
635
+ const addr = server.address();
636
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
634
637
  // Write PID file after successful bind
635
- await writePidFile(projectRoot, port);
638
+ await writePidFile(projectRoot, actualPort);
636
639
  // Clean up PID file on exit — only register once per process
637
640
  const onExit = () => removePidFile(projectRoot);
638
641
  const onTerm = async () => {
@@ -642,9 +645,9 @@ export async function startDaemon(projectRoot, port = 4510) {
642
645
  process.once("exit", onExit);
643
646
  process.once("SIGTERM", onTerm);
644
647
  resolve({
645
- port,
648
+ port: actualPort,
646
649
  token,
647
- dashboardUrl: `http://127.0.0.1:${port}/#token=${encodeURIComponent(token)}`,
650
+ dashboardUrl: `http://127.0.0.1:${actualPort}/#token=${encodeURIComponent(token)}`,
648
651
  close() {
649
652
  process.removeListener("exit", onExit);
650
653
  process.removeListener("SIGTERM", onTerm);