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
|
@@ -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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|