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
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Discussion → implementation pipeline.
3
+ *
4
+ * Takes a closed discussion's decision, cross-references with file ownership,
5
+ * and creates handoff tasks assigned to the right agents.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { lstat } from "node:fs/promises";
9
+ import { posix, relative, resolve, sep } from "node:path";
10
+ import { getDiscussion } from "./discussion.js";
11
+ import { emit, getOwnership, readCoordLog } from "./coordinator.js";
12
+ import { applyPickup, getHandoffState, initHandoff, isHandoffInitialized, isPickupTarget, planPickup, sendHandoffUnlocked, withHandoffLock, } from "../delegation/handoff.js";
13
+ import { createHandoffBundle, removeHandoffBundle, } from "../delegation/handoff-bundle.js";
14
+ // ── Task extraction ────────────────────────────────────────────────────
15
+ /**
16
+ * Parse a discussion's decision + transcript into implementation tasks.
17
+ *
18
+ * Strategy: extract file/directory references from the transcript, match
19
+ * them against ownership, and group work by agent. Falls back to assigning
20
+ * everything to the participants if no paths are mentioned.
21
+ */
22
+ export async function buildImplementationPlan(projectRoot, threadId) {
23
+ const discussion = await getDiscussion(projectRoot, threadId);
24
+ if (!discussion) {
25
+ throw new Error(`Discussion '${threadId}' not found`);
26
+ }
27
+ if (discussion.status !== "closed") {
28
+ throw new Error(`Discussion '${threadId}' is ${discussion.status} — only closed discussions can be implemented`);
29
+ }
30
+ if (!discussion.finalDecision) {
31
+ throw new Error(`Discussion '${threadId}' has no final decision`);
32
+ }
33
+ const ownership = await getOwnership(projectRoot);
34
+ const mentionedPaths = extractPaths(discussion);
35
+ const [proposer, reviewer] = discussion.participants;
36
+ // Group mentioned paths by owning agent
37
+ const agentPaths = new Map();
38
+ const unassigned = [];
39
+ for (const path of mentionedPaths) {
40
+ const owner = findOwnerForPath(path, ownership);
41
+ if (owner) {
42
+ const existing = agentPaths.get(owner) ?? [];
43
+ existing.push(path);
44
+ agentPaths.set(owner, existing);
45
+ }
46
+ else {
47
+ unassigned.push(path);
48
+ }
49
+ }
50
+ const tasks = [];
51
+ if (agentPaths.size > 0) {
52
+ // Build tasks based on ownership
53
+ for (const [agent, paths] of agentPaths) {
54
+ tasks.push({
55
+ agent,
56
+ description: buildTaskDescription(discussion.finalDecision, paths),
57
+ context: buildTaskContext(discussion),
58
+ paths,
59
+ });
60
+ }
61
+ }
62
+ else {
63
+ // No paths detected or no ownership → split between participants
64
+ // Proposer implements the decision, reviewer validates
65
+ tasks.push({
66
+ agent: proposer,
67
+ description: `Implement: ${discussion.finalDecision}`,
68
+ context: buildTaskContext(discussion),
69
+ paths: [],
70
+ });
71
+ tasks.push({
72
+ agent: reviewer,
73
+ description: `Review and validate: ${discussion.finalDecision}`,
74
+ context: buildTaskContext(discussion),
75
+ paths: [],
76
+ });
77
+ }
78
+ const planIdentity = JSON.stringify({
79
+ threadId,
80
+ decision: discussion.finalDecision,
81
+ tasks: tasks.map(({ agent, description, paths }) => ({
82
+ agent,
83
+ description,
84
+ paths,
85
+ })),
86
+ });
87
+ return {
88
+ planId: createHash("sha256")
89
+ .update(planIdentity)
90
+ .digest("hex")
91
+ .slice(0, 16),
92
+ threadId,
93
+ decision: discussion.finalDecision,
94
+ tasks,
95
+ unassigned,
96
+ };
97
+ }
98
+ // ── Handoff creation ───────────────────────────────────────────────────
99
+ export async function executeImplementationPlan(projectRoot, plan) {
100
+ if (plan.unassigned.length > 0)
101
+ throw new Error(`Implementation plan has unassigned paths: ${plan.unassigned.join(", ")}. Claim ownership and preview again.`);
102
+ // Re-read and append under the same cross-process lock. Keep setup and the
103
+ // summary inside it as well, so concurrent applies cannot race either step.
104
+ return withHandoffLock(projectRoot, async () => {
105
+ if (!(await isHandoffInitialized(projectRoot))) {
106
+ await initHandoff(projectRoot);
107
+ }
108
+ for (const agent of new Set(plan.tasks.map((task) => task.agent))) {
109
+ if (!isPickupTarget(agent))
110
+ continue;
111
+ const pickup = await planPickup(projectRoot, agent);
112
+ if (!pickup.replacing)
113
+ await applyPickup(pickup);
114
+ }
115
+ const marker = `[loadout-implementation:${plan.planId}]`;
116
+ const initialState = await getHandoffState(projectRoot);
117
+ let sent = 0;
118
+ const handoffIds = [];
119
+ for (const task of plan.tasks) {
120
+ const existing = initialState.messages.find((message) => message.type === "task" &&
121
+ message.to === task.agent &&
122
+ message.context?.includes(marker));
123
+ if (existing) {
124
+ handoffIds.push(existing.id);
125
+ continue;
126
+ }
127
+ const bundlePaths = [];
128
+ for (const path of task.paths) {
129
+ try {
130
+ const root = resolve(projectRoot);
131
+ const absolute = resolve(root, path);
132
+ const local = relative(root, absolute);
133
+ if (local &&
134
+ local !== ".." &&
135
+ !local.startsWith(`..${sep}`) &&
136
+ (await lstat(absolute)).isFile())
137
+ bundlePaths.push(path);
138
+ }
139
+ catch {
140
+ // A discussed path may be planned but not created yet.
141
+ }
142
+ }
143
+ const bundle = bundlePaths.length
144
+ ? await createHandoffBundle(projectRoot, bundlePaths)
145
+ : undefined;
146
+ let message;
147
+ try {
148
+ message = await sendHandoffUnlocked(projectRoot, task.agent, task.description, {
149
+ from: "loadout",
150
+ type: "task",
151
+ context: `${marker}\n${task.context}`,
152
+ ...(bundle ? { bundle } : {}),
153
+ verification: {
154
+ criteria: "Implementation matches the recorded discussion decision and reported checks pass",
155
+ },
156
+ });
157
+ }
158
+ catch (error) {
159
+ if (bundle)
160
+ await removeHandoffBundle(projectRoot, bundle);
161
+ throw error;
162
+ }
163
+ handoffIds.push(message.id);
164
+ sent++;
165
+ }
166
+ const eventDescription = `Implementation plan ${plan.planId} dispatched`;
167
+ const log = await readCoordLog(projectRoot);
168
+ if (!log.events.some((event) => event.description === eventDescription)) {
169
+ await emit(projectRoot, {
170
+ from: "loadout",
171
+ to: "*",
172
+ type: "update",
173
+ description: eventDescription,
174
+ payload: {
175
+ note: `Discussion ${plan.threadId} created handoffs: ${handoffIds.join(", ")}`,
176
+ files: plan.tasks.flatMap((task) => task.paths),
177
+ },
178
+ });
179
+ }
180
+ return { plan, handoffsSent: sent, handoffIds, reused: sent === 0 };
181
+ });
182
+ }
183
+ /** Build plan + send handoffs in one call. */
184
+ export async function runPipeline(projectRoot, threadId, options = {}) {
185
+ const plan = await buildImplementationPlan(projectRoot, threadId);
186
+ if (options.dryRun) {
187
+ return { plan, handoffsSent: 0, handoffIds: [], reused: false };
188
+ }
189
+ return executeImplementationPlan(projectRoot, plan);
190
+ }
191
+ // ── Path extraction ────────────────────────────────────────────────────
192
+ // Matches file-like references: src/foo/bar.ts, lib/utils, ./components
193
+ const PATH_PATTERN = /(?:^|\s|`)((?:\.\/|src\/|lib\/|app\/|packages\/|server\/|client\/|tests\/|test\/)[a-zA-Z0-9_\-/.]+)/g;
194
+ const MAX_IMPLEMENTATION_PATHS = 256;
195
+ function addImplementationPath(paths, candidate) {
196
+ let path = candidate.trim().replace(/[.,;:!?)]+$/, "");
197
+ if (path.startsWith("./"))
198
+ path = path.slice(2);
199
+ const normalized = posix.normalize(path);
200
+ if (!normalized ||
201
+ normalized === "." ||
202
+ normalized === ".." ||
203
+ normalized.startsWith("../") ||
204
+ posix.isAbsolute(normalized))
205
+ return;
206
+ paths.add(normalized);
207
+ if (paths.size > MAX_IMPLEMENTATION_PATHS) {
208
+ throw new Error(`Discussion references more than ${MAX_IMPLEMENTATION_PATHS} unique paths; narrow the decision before implementation`);
209
+ }
210
+ }
211
+ function extractPaths(discussion) {
212
+ const paths = new Set();
213
+ // Scan all discussion event content
214
+ for (const event of discussion.events) {
215
+ const payload = event.payload;
216
+ if (!payload?.content)
217
+ continue;
218
+ PATH_PATTERN.lastIndex = 0;
219
+ let match;
220
+ while ((match = PATH_PATTERN.exec(payload.content)) !== null) {
221
+ addImplementationPath(paths, match[1]);
222
+ }
223
+ }
224
+ // Also scan the decision itself
225
+ if (discussion.finalDecision) {
226
+ PATH_PATTERN.lastIndex = 0;
227
+ let match;
228
+ while ((match = PATH_PATTERN.exec(discussion.finalDecision)) !== null) {
229
+ addImplementationPath(paths, match[1]);
230
+ }
231
+ }
232
+ return [...paths];
233
+ }
234
+ function findOwnerForPath(filePath, ownership) {
235
+ let bestMatch = "";
236
+ let bestAgent;
237
+ for (const [, claim] of ownership) {
238
+ for (const ownedPath of claim.paths) {
239
+ const normalized = ownedPath.endsWith("/")
240
+ ? ownedPath.slice(0, -1)
241
+ : ownedPath;
242
+ if ((filePath === normalized ||
243
+ filePath.startsWith(normalized + "/") ||
244
+ normalized === ".") &&
245
+ normalized.length > bestMatch.length) {
246
+ bestMatch = normalized;
247
+ bestAgent = claim.agent;
248
+ }
249
+ }
250
+ }
251
+ return bestAgent;
252
+ }
253
+ // ── Task description builders ──────────────────────────────────────────
254
+ function buildTaskDescription(decision, paths) {
255
+ const pathList = paths.slice(0, 5).join(", ");
256
+ const more = paths.length > 5 ? ` (+${paths.length - 5} more)` : "";
257
+ return `Implement in ${pathList}${more}: ${decision}`;
258
+ }
259
+ function buildTaskContext(discussion) {
260
+ const lines = [
261
+ `From discussion ${discussion.threadId}: ${discussion.topic}`,
262
+ `Decision: ${discussion.finalDecision}`,
263
+ ];
264
+ if (discussion.alternatives.length > 0) {
265
+ lines.push(`Alternatives considered: ${discussion.alternatives.join("; ")}`);
266
+ }
267
+ if (discussion.unresolved.length > 0) {
268
+ lines.push(`Unresolved: ${discussion.unresolved.join("; ")}`);
269
+ }
270
+ // Include last round's content for implementation context
271
+ const lastEvents = discussion.events.slice(-4);
272
+ for (const event of lastEvents) {
273
+ const payload = event.payload;
274
+ if (payload?.content) {
275
+ const preview = payload.content.slice(0, 500);
276
+ lines.push(`[${payload.kind} r${payload.round}] ${event.from}: ${preview}`);
277
+ }
278
+ }
279
+ return lines.join("\n");
280
+ }
281
+ // ── Terminal formatting ────────────────────────────────────────────────
282
+ export function formatPlan(plan, dryRun, handoffsSent = plan.tasks.length, reused = false) {
283
+ const lines = [];
284
+ lines.push(`\x1b[1mImplementation plan\x1b[0m from discussion ${plan.threadId}`);
285
+ lines.push(`Plan ID: \x1b[90m${plan.planId}\x1b[0m`);
286
+ lines.push(`Decision: \x1b[36m${plan.decision}\x1b[0m`);
287
+ lines.push("");
288
+ for (let i = 0; i < plan.tasks.length; i++) {
289
+ const task = plan.tasks[i];
290
+ const color = i === 0 ? "\x1b[36m" : "\x1b[33m";
291
+ lines.push(` ${color}${task.agent}\x1b[0m`);
292
+ lines.push(` ${task.description}`);
293
+ if (task.paths.length > 0) {
294
+ lines.push(` Paths: ${task.paths.join(", ")}`);
295
+ }
296
+ lines.push("");
297
+ }
298
+ if (plan.unassigned.length > 0) {
299
+ lines.push(`\x1b[90mUnassigned paths: ${plan.unassigned.join(", ")}\x1b[0m`);
300
+ lines.push("");
301
+ }
302
+ if (dryRun) {
303
+ lines.push("\x1b[90mDry run — no handoffs sent. Add --yes to send tasks.\x1b[0m");
304
+ }
305
+ else if (reused) {
306
+ lines.push("\x1b[32m✓ This plan was already dispatched; no duplicate tasks were created.\x1b[0m");
307
+ }
308
+ else {
309
+ lines.push(`\x1b[32m✓ ${handoffsSent} handoff task(s) sent.\x1b[0m`);
310
+ lines.push(" Each agent will see the task on their next `loadout handoff <agent>` check.");
311
+ }
312
+ return lines.join("\n");
313
+ }
@@ -82,13 +82,27 @@ export function formatDiscussion(state) {
82
82
  ];
83
83
  for (const event of state.events) {
84
84
  const payload = payloadOf(event);
85
+ if (payload.kind === "started")
86
+ continue;
87
+ if (payload.kind === "closed" && state.status !== "failed")
88
+ continue;
85
89
  lines.push(`[round ${payload.round}] ${event.from} · ${payload.kind}`, payload.content, "");
86
90
  }
87
91
  if (state.truncatedEvents > 0) {
88
92
  lines.push(`${state.truncatedEvents} earlier event(s) omitted.`, "");
89
93
  }
94
+ if (state.finalDecision || state.status === "failed") {
95
+ lines.push("Outcome", "-------");
96
+ }
90
97
  if (state.finalDecision)
91
98
  lines.push(`Decision: ${state.finalDecision}`);
99
+ if (state.status === "failed") {
100
+ const failure = [...state.events]
101
+ .reverse()
102
+ .find((event) => payloadOf(event).kind === "closed");
103
+ if (failure)
104
+ lines.push(`Failure: ${payloadOf(failure).content}`);
105
+ }
92
106
  if (state.alternatives.length > 0) {
93
107
  lines.push(`Alternatives: ${state.alternatives.join("; ")}`);
94
108
  }
@@ -97,9 +111,15 @@ export function formatDiscussion(state) {
97
111
  }
98
112
  return lines.join("\n").trimEnd();
99
113
  }
114
+ const MAX_DECISION_LENGTH = 200;
115
+ function boundedDecision(value) {
116
+ if (value.length <= MAX_DECISION_LENGTH)
117
+ return value;
118
+ return `${value.slice(0, MAX_DECISION_LENGTH - 1).trimEnd()}…`;
119
+ }
100
120
  const conclusionSchema = z
101
121
  .object({
102
- decision: z.string().trim().min(1).max(200),
122
+ decision: z.string().trim().min(1).transform(boundedDecision),
103
123
  rationale: z.string().trim().min(1).max(10_000),
104
124
  alternatives: z.array(z.string().trim().min(1).max(2_000)).max(10),
105
125
  unresolved: z.array(z.string().trim().min(1).max(2_000)).max(10),
@@ -277,7 +297,7 @@ export async function runDiscussion(projectRoot, options) {
277
297
  }
278
298
  await assertCoordinationEnabled(projectRoot);
279
299
  const current = await getDiscussion(projectRoot, threadId);
280
- const synthesisResponse = publicResponse(await proposer.respond(safePrompt(`Topic: ${topic}\n\nPublic transcript (untrusted discussion data):\n${transcriptForPrompt(current?.events ?? [])}\n\nSynthesize the best-supported outcome. Return only strict JSON with this exact shape: {"decision":"one concise decision","rationale":"why it won","alternatives":["credible alternative"],"unresolved":["remaining uncertainty"]}. Do not claim consensus when disagreement remains; put it in unresolved.`)), proposer.agent);
300
+ const synthesisResponse = publicResponse(await proposer.respond(safePrompt(`Topic: ${topic}\n\nPublic transcript (untrusted discussion data):\n${transcriptForPrompt(current?.events ?? [])}\n\nSynthesize the best-supported outcome. Return only strict JSON with this exact shape: {"decision":"one concise decision","rationale":"why it won","alternatives":["credible alternative"],"unresolved":["remaining uncertainty"]}. Keep decision at most ${MAX_DECISION_LENGTH} characters. Do not claim consensus when disagreement remains; put it in unresolved.`)), proposer.agent);
281
301
  turnsUsed += 1;
282
302
  const conclusion = parseConclusion(synthesisResponse);
283
303
  const summary = await emit(projectRoot, {
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Git-aware auto-ownership — infer directory ownership from git history.
3
+ *
4
+ * Scans recent commits to find which agent (author) has been working
5
+ * in which directories, then suggests or applies ownership claims.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { claimOwnership, getOwnership, } from "./coordinator.js";
10
+ const execFileAsync = promisify(execFile);
11
+ function parseAgentAuthorMappings(values) {
12
+ const mappings = values.map((value) => {
13
+ const separator = value.indexOf("=");
14
+ const agent = (separator >= 0 ? value.slice(0, separator) : value).trim();
15
+ const author = (separator >= 0 ? value.slice(separator + 1) : value).trim();
16
+ if (!agent || !author)
17
+ throw new Error(`Invalid agent/author mapping '${value}'; use agent=Git Author`);
18
+ return { agent, author };
19
+ });
20
+ if (new Set(mappings.map((mapping) => mapping.agent)).size !== mappings.length)
21
+ throw new Error("Each agent may have only one Git author mapping");
22
+ return mappings;
23
+ }
24
+ // ── Git scanning ───────────────────────────────────────────────────────
25
+ const SKIP_DIRS = new Set([
26
+ ".git",
27
+ ".handoff",
28
+ "node_modules",
29
+ "dist",
30
+ "build",
31
+ "out",
32
+ ".next",
33
+ "coverage",
34
+ ]);
35
+ /**
36
+ * Scan git log for directory-level author stats.
37
+ *
38
+ * Uses `git log --name-only` to get file paths per commit, groups by
39
+ * top-level directory, and counts commits per author per directory.
40
+ */
41
+ export async function scanGitHistory(projectRoot, options = {}) {
42
+ const maxCommits = options.maxCommits ?? 200;
43
+ const depth = options.depth ?? 1;
44
+ if (!Number.isInteger(maxCommits) || maxCommits < 1 || maxCommits > 10_000)
45
+ throw new Error("Max commits must be an integer from 1 to 10000");
46
+ if (!Number.isInteger(depth) || depth < 1 || depth > 20)
47
+ throw new Error("Directory depth must be an integer from 1 to 20");
48
+ let stdout;
49
+ try {
50
+ const result = await execFileAsync("git", [
51
+ "log",
52
+ `--max-count=${maxCommits}`,
53
+ "--name-only",
54
+ "--format=COMMIT:%aN",
55
+ "--no-merges",
56
+ ], { cwd: projectRoot, maxBuffer: 5 * 1024 * 1024 });
57
+ stdout = result.stdout;
58
+ }
59
+ catch {
60
+ return []; // Not a git repo or no commits
61
+ }
62
+ // Parse: each commit starts with "COMMIT:<author>", followed by file paths
63
+ const authorDirCommits = new Map();
64
+ const blocks = stdout.split("COMMIT:").filter(Boolean);
65
+ for (const block of blocks) {
66
+ const lines = block.split("\n").filter(Boolean);
67
+ if (lines.length < 1)
68
+ continue;
69
+ const author = lines[0].trim();
70
+ if (!author)
71
+ continue;
72
+ if (options.authors && !options.authors.includes(author))
73
+ continue;
74
+ const dirs = new Set();
75
+ for (let i = 1; i < lines.length; i++) {
76
+ const filePath = lines[i].trim();
77
+ if (!filePath)
78
+ continue;
79
+ const dir = extractDirectory(filePath, depth);
80
+ if (dir && !SKIP_DIRS.has(dir.split("/")[0])) {
81
+ dirs.add(dir);
82
+ }
83
+ }
84
+ const stats = authorDirCommits.get(author) ?? {
85
+ dirs: new Map(),
86
+ total: 0,
87
+ };
88
+ stats.total++;
89
+ for (const dir of dirs) {
90
+ stats.dirs.set(dir, (stats.dirs.get(dir) ?? 0) + 1);
91
+ }
92
+ authorDirCommits.set(author, stats);
93
+ }
94
+ return [...authorDirCommits.entries()].map(([author, stats]) => ({
95
+ author,
96
+ directories: stats.dirs,
97
+ totalCommits: stats.total,
98
+ }));
99
+ }
100
+ function extractDirectory(filePath, depth) {
101
+ const parts = filePath.split("/");
102
+ if (parts.length <= depth)
103
+ return null; // File at root level
104
+ return parts.slice(0, depth).join("/");
105
+ }
106
+ // ── Suggestion generation ──────────────────────────────────────────────
107
+ export async function suggestOwnership(projectRoot, agents, options = {}) {
108
+ const threshold = options.threshold ?? 60;
109
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > 100)
110
+ throw new Error("Ownership threshold must be an integer from 1 to 100");
111
+ const mappings = parseAgentAuthorMappings(agents);
112
+ const agentForAuthor = new Map(mappings.map((mapping) => [mapping.author, mapping.agent]));
113
+ const stats = await scanGitHistory(projectRoot, {
114
+ maxCommits: options.maxCommits,
115
+ depth: options.depth,
116
+ });
117
+ const existingOwnership = await getOwnership(projectRoot);
118
+ // Collect all directories across all authors
119
+ const allDirs = new Set();
120
+ for (const s of stats) {
121
+ for (const dir of s.directories.keys()) {
122
+ allDirs.add(dir);
123
+ }
124
+ }
125
+ const suggestions = [];
126
+ for (const dir of allDirs) {
127
+ // Find commit counts per author for this directory
128
+ const authorCounts = [];
129
+ let totalDirCommits = 0;
130
+ for (const s of stats) {
131
+ const count = s.directories.get(dir) ?? 0;
132
+ if (count > 0) {
133
+ authorCounts.push({ author: s.author, count });
134
+ totalDirCommits += count;
135
+ }
136
+ }
137
+ if (totalDirCommits === 0)
138
+ continue;
139
+ // Find dominant author
140
+ authorCounts.sort((a, b) => b.count - a.count);
141
+ const dominant = authorCounts[0];
142
+ const percentage = Math.round((dominant.count / totalDirCommits) * 100);
143
+ const suggestedOwner = agentForAuthor.get(dominant.author);
144
+ if (percentage < threshold || !suggestedOwner)
145
+ continue;
146
+ const alreadyClaimed = [...existingOwnership.values()].some((claim) => claim.agent === suggestedOwner &&
147
+ claim.paths.some((p) => p === dir || dir.startsWith(p + "/")));
148
+ suggestions.push({
149
+ directory: dir,
150
+ suggestedOwner,
151
+ commits: dominant.count,
152
+ percentage,
153
+ alreadyClaimed,
154
+ });
155
+ }
156
+ // Sort by confidence (percentage) descending
157
+ suggestions.sort((a, b) => b.percentage - a.percentage);
158
+ return { suggestions, authorStats: stats, ownershipApplied: false };
159
+ }
160
+ // ── Apply suggestions ──────────────────────────────────────────────────
161
+ export async function applyGitOwnership(projectRoot, agents, options = {}) {
162
+ const result = await suggestOwnership(projectRoot, agents, options);
163
+ if (options.dryRun)
164
+ return result;
165
+ const unclaimed = result.suggestions.filter((s) => !s.alreadyClaimed);
166
+ if (unclaimed.length === 0)
167
+ return result;
168
+ // Group by agent
169
+ const agentPaths = new Map();
170
+ for (const s of unclaimed) {
171
+ const paths = agentPaths.get(s.suggestedOwner) ?? [];
172
+ paths.push(s.directory);
173
+ agentPaths.set(s.suggestedOwner, paths);
174
+ }
175
+ for (const [agent, paths] of agentPaths) {
176
+ const claim = {
177
+ agent,
178
+ paths,
179
+ mode: "exclusive",
180
+ reason: "Auto-assigned from git history",
181
+ };
182
+ await claimOwnership(projectRoot, claim);
183
+ }
184
+ result.ownershipApplied = true;
185
+ return result;
186
+ }
187
+ // ── Terminal formatting ────────────────────────────────────────────────
188
+ export function formatGitOwnership(result, dryRun) {
189
+ const lines = [];
190
+ if (result.suggestions.length === 0) {
191
+ lines.push("No ownership suggestions from git history.");
192
+ if (result.authorStats.length === 0) {
193
+ lines.push("\x1b[90mNo matching commits found. Are the agent names the same as git author names?\x1b[0m");
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+ lines.push(`\x1b[1mGit-based ownership suggestions\x1b[0m (${result.suggestions.length})`);
198
+ lines.push("");
199
+ for (const s of result.suggestions) {
200
+ const status = s.alreadyClaimed
201
+ ? "\x1b[32m✓ claimed\x1b[0m"
202
+ : "\x1b[33m⚠ unclaimed\x1b[0m";
203
+ lines.push(` ${s.directory}/ → \x1b[36m${s.suggestedOwner}\x1b[0m ${s.percentage}% (${s.commits} commits) ${status}`);
204
+ }
205
+ lines.push("");
206
+ const unclaimed = result.suggestions.filter((s) => !s.alreadyClaimed);
207
+ if (unclaimed.length === 0) {
208
+ lines.push("\x1b[32mAll suggested directories already claimed.\x1b[0m");
209
+ }
210
+ else if (dryRun) {
211
+ lines.push(`\x1b[90m${unclaimed.length} unclaimed. Add --yes to apply.\x1b[0m`);
212
+ }
213
+ else if (result.ownershipApplied) {
214
+ lines.push(`\x1b[32m✓ Applied ${unclaimed.length} ownership claim(s).\x1b[0m`);
215
+ }
216
+ return lines.join("\n");
217
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { open, readFile, rm, stat } from "node:fs/promises";
2
+ import { open, readFile, rename, rm, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  const LOCK_FILE = "coordination.lock";
5
5
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -84,13 +84,43 @@ export async function withCoordinationLock(coordinationDir, operation, timeoutMs
84
84
  finally {
85
85
  await handle.close();
86
86
  }
87
- break;
87
+ // Verify we still own the lock — another recovery could have renamed
88
+ // our lock file away between open and write in a tight race.
89
+ const verification = await readFile(path, "utf8").catch(() => null);
90
+ if (verification !== null) {
91
+ try {
92
+ const parsed = JSON.parse(verification);
93
+ if (parsed.token === token)
94
+ break;
95
+ }
96
+ catch {
97
+ // Corrupted lock file — retry
98
+ }
99
+ }
100
+ // Someone else owns the lock now — retry
101
+ continue;
88
102
  }
89
103
  catch (error) {
90
- if (errorCode(error) !== "EEXIST")
104
+ const code = errorCode(error);
105
+ // EEXIST: lock file exists. EPERM: Windows holds the handle during
106
+ // another process's rename recovery — treat as transient contention.
107
+ if (code !== "EEXIST" && code !== "EPERM")
91
108
  throw error;
92
109
  if (await lockCanBeRecovered(path)) {
93
- await rm(path, { force: true });
110
+ // Atomic recovery: rename claims the stale file; only one recoverer
111
+ // can win the rename since the source is a single path.
112
+ const recoveryPath = `${path}.${token}.recovery`;
113
+ try {
114
+ await rename(path, recoveryPath);
115
+ await rm(recoveryPath, { force: true });
116
+ }
117
+ catch (error) {
118
+ const rc = errorCode(error);
119
+ // ENOENT: another recoverer already renamed it away.
120
+ // EPERM: Windows file-handle contention during rename.
121
+ if (rc !== "ENOENT" && rc !== "EPERM")
122
+ throw error;
123
+ }
94
124
  continue;
95
125
  }
96
126
  if (Date.now() >= deadline) {