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.
- package/CHANGELOG.md +97 -0
- package/README.md +45 -50
- package/catalog/discovered.json +29156 -26656
- package/dist/src/commands/catalog-workflows.js +227 -9
- package/dist/src/commands/coordinate.js +148 -4
- package/dist/src/commands/coordination-discussions.js +71 -9
- package/dist/src/core/catalog/safety.js +46 -2
- package/dist/src/core/coordination/adapters/claude-code.js +15 -7
- package/dist/src/core/coordination/adapters/codex.js +29 -2
- package/dist/src/core/coordination/auto-contract.js +457 -0
- package/dist/src/core/coordination/coordinator.js +5 -4
- package/dist/src/core/coordination/daemon.js +6 -3
- package/dist/src/core/coordination/discussion-pipeline.js +313 -0
- package/dist/src/core/coordination/discussion.js +22 -2
- package/dist/src/core/coordination/git-ownership.js +217 -0
- package/dist/src/core/coordination/lock.js +34 -4
- package/dist/src/core/coordination/quick-start.js +200 -0
- package/dist/src/core/coordination/retention.js +67 -5
- package/dist/src/core/delegation/handoff-bundle.js +253 -0
- package/dist/src/core/delegation/handoff-templates.js +222 -0
- package/dist/src/core/delegation/handoff-verification.js +117 -0
- package/dist/src/core/delegation/handoff.js +218 -26
- package/dist/src/core/install/catalog-install.js +8 -2
- package/dist/src/core/install/snapshot.js +49 -6
- package/dist/src/core/install/source.js +8 -6
- package/dist/src/core/install/update.js +55 -1
- package/docs/DISCOVERED.md +249 -251
- package/docs/FEATURE_TEST_MATRIX.md +26 -11
- package/docs/LIVE_COLLABORATION.md +49 -0
- package/docs/REFERENCE.md +100 -0
- package/docs/USER_TEST_GUIDE.md +75 -2
- package/docs/evidence/coordination-provider-check-2026-09-05.md +33 -0
- package/docs/specs/HANDOFF_CONTEXT_BUNDLES.md +139 -0
- package/docs/specs/HANDOFF_VERIFICATION.md +83 -0
- package/docs/superpowers/plans/2026-09-04-handoff-context-bundles.md +109 -0
- package/docs/superpowers/plans/2026-09-04-handoff-verification.md +56 -0
- package/docs/superpowers/plans/2026-09-05-pre-release-hardening.md +175 -0
- package/docs/superpowers/plans/2026-09-05-public-readiness.md +20 -0
- package/package.json +3 -2
- package/skills/loadout-handoff/SKILL.md +68 -15
- 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
|
|
13
|
-
const
|
|
14
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:${
|
|
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);
|