loadout-ai 0.8.0 → 0.9.1
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 +136 -0
- package/README.md +149 -309
- package/SECURITY.md +21 -1
- package/catalog/discovered.json +29122 -25325
- package/catalog/packages.json +4 -4
- package/dist/src/cli.js +12 -4
- package/dist/src/commands/agents.js +3 -1
- package/dist/src/commands/catalog-candidate.js +150 -0
- package/dist/src/commands/catalog-workflows.js +365 -0
- package/dist/src/commands/catalog.js +9 -354
- package/dist/src/commands/coordinate.js +730 -0
- package/dist/src/commands/coordination-discussions.js +218 -0
- package/dist/src/commands/coordination-sessions.js +197 -0
- package/dist/src/commands/inventory.js +4 -2
- package/dist/src/core/{routing → agents}/model-config.js +7 -2
- package/dist/src/core/catalog/catalog.js +1 -0
- package/dist/src/core/catalog/registry.js +5 -19
- package/dist/src/core/coordination/adapters/claude-code.js +154 -0
- package/dist/src/core/coordination/adapters/codex.js +160 -0
- package/dist/src/core/coordination/adapters/types.js +64 -0
- package/dist/src/core/coordination/auth.js +59 -0
- package/dist/src/core/coordination/auto-contract.js +457 -0
- package/dist/src/core/coordination/bridge-lease.js +79 -0
- package/dist/src/core/coordination/conflict-preview.js +167 -0
- package/dist/src/core/coordination/contract-diff.js +106 -0
- package/dist/src/core/coordination/coordinator.js +552 -0
- package/dist/src/core/coordination/crash-recovery.js +169 -0
- package/dist/src/core/coordination/daemon.js +670 -0
- package/dist/src/core/coordination/discussion-pipeline.js +313 -0
- package/dist/src/core/coordination/discussion.js +340 -0
- package/dist/src/core/coordination/events.js +161 -0
- package/dist/src/core/coordination/git-ownership.js +217 -0
- package/dist/src/core/coordination/http-api.js +118 -0
- package/dist/src/core/coordination/interrupt-policy.js +89 -0
- package/dist/src/core/coordination/lock.js +140 -0
- package/dist/src/core/coordination/mcp-server.js +424 -0
- package/dist/src/core/coordination/quick-start.js +200 -0
- package/dist/src/core/coordination/redaction.js +85 -0
- package/dist/src/core/coordination/replay.js +188 -0
- package/dist/src/core/coordination/retention.js +180 -0
- package/dist/src/core/coordination/runtime.js +21 -0
- package/dist/src/core/coordination/session-manager.js +366 -0
- package/dist/src/core/coordination/watcher.js +128 -0
- package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
- package/dist/src/core/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 +537 -0
- package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
- package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence.js +3 -214
- package/dist/src/core/discovery/community.js +12 -4
- package/dist/src/core/discovery/github-discovery.js +6 -2
- package/dist/src/core/discovery/private-discovery.js +6 -2
- package/dist/src/core/install/reconcile.js +1 -1
- package/dist/src/core/install/snapshot.js +24 -5
- package/dist/src/core/install/uninstall.js +39 -3
- package/dist/src/core/reporting/cli-guide.js +9 -5
- package/dist/src/core/reporting/completion.js +43 -38
- package/dist/src/core/reporting/doctor.js +1 -1
- package/dist/src/core/runtime/bounded-json.js +65 -0
- package/dist/src/core/runtime/github.js +30 -16
- package/dist/src/core/runtime/mcp-recipes.js +1 -1
- package/dist/src/core/workspace/active-policy.js +1 -1
- package/docs/DEMO_SCRIPT.md +19 -23
- package/docs/DISCOVERED.md +250 -251
- package/docs/FEATURE_TEST_MATRIX.md +30 -15
- package/docs/LIVE_COLLABORATION.md +283 -0
- package/docs/REFERENCE.md +263 -0
- package/docs/RELEASE_REVIEW.md +1 -1
- package/docs/TESTING.md +17 -1
- package/docs/USER_TEST_GUIDE.md +211 -3
- package/docs/assets/loadout-discover-activate.webp +0 -0
- package/docs/assets/loadout-handoff-coordinate.webp +0 -0
- package/docs/assets/loadout-social-preview.png +0 -0
- package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
- package/docs/decisions/002-local-daemon-authentication.md +32 -0
- package/docs/decisions/003-bounded-agent-discussions.md +91 -0
- package/docs/evidence/coordination-provider-check-2026-09-05.md +33 -0
- package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
- package/docs/specs/HANDOFF_CONTEXT_BUNDLES.md +139 -0
- package/docs/specs/HANDOFF_VERIFICATION.md +83 -0
- package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
- package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
- package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
- package/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 +15 -5
- package/skills/loadout-handoff/SKILL.md +291 -0
- package/dist/src/core/routing/handoff.js +0 -314
- package/dist/src/core/routing/policy.js +0 -147
- package/dist/src/core/routing/route.js +0 -411
- package/docs/assets/loadout-workflow.png +0 -0
- package/skills/loadout-router/SKILL.md +0 -85
- /package/dist/src/core/{routing → agents}/credentials.js +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, readFile, realpath, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { writeFileAtomically } from "../install/atomic-file.js";
|
|
7
|
+
import { redactString } from "../coordination/redaction.js";
|
|
8
|
+
export const HANDOFF_BUNDLE_MAX_FILES = 20;
|
|
9
|
+
export const HANDOFF_BUNDLE_MAX_FILE_BYTES = 32 * 1024;
|
|
10
|
+
export const HANDOFF_BUNDLE_MAX_TOTAL_BYTES = 50 * 1024;
|
|
11
|
+
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
12
|
+
const handoffBundleFileSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
path: z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.min(1)
|
|
18
|
+
.refine((path) => !path.startsWith("/") &&
|
|
19
|
+
!path.split("/").includes("..") &&
|
|
20
|
+
![".git", ".handoff"].includes(path.split("/")[0] ?? ""), "must be a safe project-relative source path"),
|
|
21
|
+
sourceBytes: z.number().int().nonnegative(),
|
|
22
|
+
storedBytes: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.nonnegative()
|
|
26
|
+
.max(HANDOFF_BUNDLE_MAX_FILE_BYTES),
|
|
27
|
+
sourceSha256: sha256Schema,
|
|
28
|
+
isTruncated: z.boolean(),
|
|
29
|
+
content: z.string(),
|
|
30
|
+
})
|
|
31
|
+
.strict();
|
|
32
|
+
const handoffBundleSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
schemaVersion: z.literal(1),
|
|
35
|
+
createdAt: z.iso.datetime({ offset: true }),
|
|
36
|
+
files: z
|
|
37
|
+
.array(handoffBundleFileSchema)
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(HANDOFF_BUNDLE_MAX_FILES),
|
|
40
|
+
})
|
|
41
|
+
.strict()
|
|
42
|
+
.superRefine((bundle, context) => {
|
|
43
|
+
let total = 0;
|
|
44
|
+
bundle.files.forEach((file, index) => {
|
|
45
|
+
const actual = Buffer.byteLength(file.content);
|
|
46
|
+
if (actual !== file.storedBytes) {
|
|
47
|
+
context.addIssue({
|
|
48
|
+
code: "custom",
|
|
49
|
+
path: ["files", index, "storedBytes"],
|
|
50
|
+
message: "does not match the UTF-8 content byte length",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
total += actual;
|
|
54
|
+
});
|
|
55
|
+
if (total > HANDOFF_BUNDLE_MAX_TOTAL_BYTES) {
|
|
56
|
+
context.addIssue({
|
|
57
|
+
code: "custom",
|
|
58
|
+
path: ["files"],
|
|
59
|
+
message: `stored content exceeds ${HANDOFF_BUNDLE_MAX_TOTAL_BYTES} bytes`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
export const handoffBundleReferenceSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
schemaVersion: z.literal(1),
|
|
66
|
+
path: z.string().regex(/^\.handoff\/bundles\/[a-f0-9-]+\.json$/),
|
|
67
|
+
fileCount: z.number().int().min(1).max(HANDOFF_BUNDLE_MAX_FILES),
|
|
68
|
+
storedBytes: z
|
|
69
|
+
.number()
|
|
70
|
+
.int()
|
|
71
|
+
.nonnegative()
|
|
72
|
+
.max(HANDOFF_BUNDLE_MAX_TOTAL_BYTES),
|
|
73
|
+
isTruncated: z.boolean(),
|
|
74
|
+
})
|
|
75
|
+
.strict();
|
|
76
|
+
function errorCode(error) {
|
|
77
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
78
|
+
? String(error.code)
|
|
79
|
+
: undefined;
|
|
80
|
+
}
|
|
81
|
+
async function assertNoSymlinkComponents(root, relativePath, allowMissing) {
|
|
82
|
+
let current = root;
|
|
83
|
+
let finalInfo;
|
|
84
|
+
for (const segment of relativePath.split("/")) {
|
|
85
|
+
current = join(current, segment);
|
|
86
|
+
try {
|
|
87
|
+
finalInfo = await lstat(current);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (allowMissing && errorCode(error) === "ENOENT")
|
|
91
|
+
return undefined;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
if (finalInfo.isSymbolicLink())
|
|
95
|
+
throw new Error(`Bundle path cannot use a symlink: ${relativePath}`);
|
|
96
|
+
}
|
|
97
|
+
return finalInfo;
|
|
98
|
+
}
|
|
99
|
+
async function readBoundedTextFile(absolute, path, maximumBytes) {
|
|
100
|
+
const hash = createHash("sha256");
|
|
101
|
+
const utf8Validator = new TextDecoder("utf-8", { fatal: true });
|
|
102
|
+
const captured = [];
|
|
103
|
+
let capturedBytes = 0;
|
|
104
|
+
let sourceBytes = 0;
|
|
105
|
+
try {
|
|
106
|
+
for await (const value of createReadStream(absolute)) {
|
|
107
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
108
|
+
sourceBytes += chunk.byteLength;
|
|
109
|
+
hash.update(chunk);
|
|
110
|
+
utf8Validator.decode(chunk, { stream: true });
|
|
111
|
+
if (chunk.includes(0))
|
|
112
|
+
throw new Error(`Bundle path appears to be binary: ${path}`);
|
|
113
|
+
if (capturedBytes < maximumBytes) {
|
|
114
|
+
const remaining = maximumBytes - capturedBytes;
|
|
115
|
+
const part = chunk.subarray(0, remaining);
|
|
116
|
+
captured.push(part);
|
|
117
|
+
capturedBytes += part.byteLength;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
utf8Validator.decode();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof Error && /appears to be binary/.test(error.message))
|
|
124
|
+
throw error;
|
|
125
|
+
if (error instanceof TypeError)
|
|
126
|
+
throw new Error(`Bundle path appears to be binary: ${path}`);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
const prefix = Buffer.concat(captured);
|
|
130
|
+
let decoded = "";
|
|
131
|
+
let trim = 0;
|
|
132
|
+
for (; trim <= Math.min(3, prefix.byteLength); trim += 1) {
|
|
133
|
+
try {
|
|
134
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(prefix.subarray(0, prefix.byteLength - trim));
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// A bounded prefix can end in the middle of one UTF-8 code point.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (trim > Math.min(3, prefix.byteLength))
|
|
142
|
+
throw new Error(`Bundle path appears to be binary: ${path}`);
|
|
143
|
+
const redacted = redactString(decoded);
|
|
144
|
+
const redactedBytes = Buffer.from(redacted);
|
|
145
|
+
let content = redacted;
|
|
146
|
+
if (redactedBytes.byteLength > maximumBytes) {
|
|
147
|
+
for (let remove = 0; remove <= 3; remove += 1) {
|
|
148
|
+
try {
|
|
149
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(redactedBytes.subarray(0, maximumBytes - remove));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Keep trimming until the byte cap lands on a UTF-8 boundary.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const storedBytes = Buffer.byteLength(content);
|
|
158
|
+
return {
|
|
159
|
+
sourceBytes,
|
|
160
|
+
sourceSha256: hash.digest("hex"),
|
|
161
|
+
content,
|
|
162
|
+
storedBytes,
|
|
163
|
+
isTruncated: sourceBytes > capturedBytes ||
|
|
164
|
+
trim > 0 ||
|
|
165
|
+
redactedBytes.byteLength > maximumBytes,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function projectRelativePath(projectRoot, requestedPath) {
|
|
169
|
+
if (!requestedPath.trim())
|
|
170
|
+
throw new Error("Bundle path cannot be empty");
|
|
171
|
+
if (isAbsolute(requestedPath))
|
|
172
|
+
throw new Error(`Bundle path must be project-relative: ${requestedPath}`);
|
|
173
|
+
const root = resolve(projectRoot);
|
|
174
|
+
const absolute = resolve(root, requestedPath);
|
|
175
|
+
const local = relative(root, absolute);
|
|
176
|
+
if (!local || local === ".." || local.startsWith(`..${sep}`))
|
|
177
|
+
throw new Error(`Bundle path must stay inside the project: ${requestedPath}`);
|
|
178
|
+
const normalized = local.split(sep).join(posix.sep);
|
|
179
|
+
const firstSegment = normalized.split(posix.sep)[0];
|
|
180
|
+
if (firstSegment === ".git" || firstSegment === ".handoff")
|
|
181
|
+
throw new Error(`Bundle path cannot read Loadout or Git internal state: ${requestedPath}`);
|
|
182
|
+
return normalized;
|
|
183
|
+
}
|
|
184
|
+
export async function createHandoffBundle(projectRoot, requestedPaths) {
|
|
185
|
+
if (!requestedPaths.length)
|
|
186
|
+
throw new Error("Bundle requires at least one file");
|
|
187
|
+
if (requestedPaths.length > HANDOFF_BUNDLE_MAX_FILES)
|
|
188
|
+
throw new Error(`Bundle accepts at most ${HANDOFF_BUNDLE_MAX_FILES} files`);
|
|
189
|
+
const files = [];
|
|
190
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
191
|
+
let totalStoredBytes = 0;
|
|
192
|
+
for (const requestedPath of requestedPaths) {
|
|
193
|
+
const path = projectRelativePath(projectRoot, requestedPath);
|
|
194
|
+
const absolute = resolve(canonicalRoot, path);
|
|
195
|
+
const info = await assertNoSymlinkComponents(canonicalRoot, path, false);
|
|
196
|
+
if (!info)
|
|
197
|
+
throw new Error(`Bundle path does not exist: ${path}`);
|
|
198
|
+
if (!info.isFile())
|
|
199
|
+
throw new Error(`Bundle path is not a file: ${path}`);
|
|
200
|
+
const remaining = HANDOFF_BUNDLE_MAX_TOTAL_BYTES - totalStoredBytes;
|
|
201
|
+
const snapshot = await readBoundedTextFile(absolute, path, Math.min(HANDOFF_BUNDLE_MAX_FILE_BYTES, remaining));
|
|
202
|
+
files.push({
|
|
203
|
+
path,
|
|
204
|
+
...snapshot,
|
|
205
|
+
});
|
|
206
|
+
totalStoredBytes += snapshot.storedBytes;
|
|
207
|
+
}
|
|
208
|
+
const bundle = {
|
|
209
|
+
schemaVersion: 1,
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
files,
|
|
212
|
+
};
|
|
213
|
+
const id = randomUUID();
|
|
214
|
+
const path = `.handoff/bundles/${id}.json`;
|
|
215
|
+
await assertNoSymlinkComponents(canonicalRoot, ".handoff/bundles", true);
|
|
216
|
+
await writeFileAtomically(resolve(projectRoot, path), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
path,
|
|
220
|
+
fileCount: files.length,
|
|
221
|
+
storedBytes: totalStoredBytes,
|
|
222
|
+
isTruncated: files.some((file) => file.isTruncated),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export async function readHandoffBundle(projectRoot, reference) {
|
|
226
|
+
const validReference = handoffBundleReferenceSchema.parse(reference);
|
|
227
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
228
|
+
await assertNoSymlinkComponents(canonicalRoot, validReference.path, false);
|
|
229
|
+
const raw = await readFile(resolve(canonicalRoot, validReference.path), "utf8");
|
|
230
|
+
let bundle;
|
|
231
|
+
try {
|
|
232
|
+
bundle = handoffBundleSchema.parse(JSON.parse(raw));
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
const reason = error instanceof Error ? error.message : "unparseable JSON";
|
|
236
|
+
throw new Error(`Invalid handoff bundle '${validReference.path}': ${reason}`);
|
|
237
|
+
}
|
|
238
|
+
const storedBytes = bundle.files.reduce((total, file) => total + file.storedBytes, 0);
|
|
239
|
+
const isTruncated = bundle.files.some((file) => file.isTruncated);
|
|
240
|
+
if (validReference.fileCount !== bundle.files.length ||
|
|
241
|
+
validReference.storedBytes !== storedBytes ||
|
|
242
|
+
validReference.isTruncated !== isTruncated) {
|
|
243
|
+
throw new Error(`Handoff bundle reference does not match '${validReference.path}'`);
|
|
244
|
+
}
|
|
245
|
+
return bundle;
|
|
246
|
+
}
|
|
247
|
+
/** Remove only a schema-valid bundle path, used to roll back a failed send. */
|
|
248
|
+
export async function removeHandoffBundle(projectRoot, reference) {
|
|
249
|
+
const validReference = handoffBundleReferenceSchema.parse(reference);
|
|
250
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
251
|
+
await assertNoSymlinkComponents(canonicalRoot, validReference.path, true);
|
|
252
|
+
await rm(resolve(canonicalRoot, validReference.path), { force: true });
|
|
253
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff templates — reusable task presets for common handoff patterns.
|
|
3
|
+
*
|
|
4
|
+
* Templates live in `.handoff/templates/` as JSON files. Each defines
|
|
5
|
+
* default fields (verification, context, bundle globs) so agents can
|
|
6
|
+
* hand off tasks with a single `--template <name>` flag.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
const templateNameSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.min(1)
|
|
15
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, "template name must be kebab-case");
|
|
16
|
+
const templateSchema = z.object({
|
|
17
|
+
name: templateNameSchema,
|
|
18
|
+
description: z.string().trim().min(1),
|
|
19
|
+
defaultAgent: z.string().trim().min(1).optional(),
|
|
20
|
+
taskTemplate: z.string().trim().min(1).optional(),
|
|
21
|
+
context: z.string().optional(),
|
|
22
|
+
bundleGlobs: z.array(z.string().trim().min(1)).optional(),
|
|
23
|
+
verifyCriteria: z.string().trim().min(1).optional(),
|
|
24
|
+
verifyCommand: z
|
|
25
|
+
.object({
|
|
26
|
+
executable: z.string().trim().min(1),
|
|
27
|
+
args: z.array(z.string()),
|
|
28
|
+
timeoutMs: z.number().int().min(1000).max(900_000).optional(),
|
|
29
|
+
})
|
|
30
|
+
.optional(),
|
|
31
|
+
});
|
|
32
|
+
// ── Built-in templates ─────────────────────────────────────────────────
|
|
33
|
+
export const BUILTIN_TEMPLATES = [
|
|
34
|
+
{
|
|
35
|
+
name: "write-tests",
|
|
36
|
+
description: "Write tests for specified files",
|
|
37
|
+
taskTemplate: "Write tests for {{files}}",
|
|
38
|
+
verifyCriteria: "All new tests pass",
|
|
39
|
+
verifyCommand: {
|
|
40
|
+
executable: "npm",
|
|
41
|
+
args: ["test"],
|
|
42
|
+
timeoutMs: 120_000,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "review-code",
|
|
47
|
+
description: "Review code changes for bugs and style",
|
|
48
|
+
taskTemplate: "Review the changes in {{files}} for correctness and style",
|
|
49
|
+
verifyCriteria: "Review comments posted or no issues found",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "fix-bug",
|
|
53
|
+
description: "Fix a reported bug with verification",
|
|
54
|
+
taskTemplate: "Fix: {{description}}",
|
|
55
|
+
verifyCriteria: "Bug no longer reproduces and existing tests pass",
|
|
56
|
+
verifyCommand: {
|
|
57
|
+
executable: "npm",
|
|
58
|
+
args: ["test"],
|
|
59
|
+
timeoutMs: 120_000,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "implement-feature",
|
|
64
|
+
description: "Implement a feature from spec",
|
|
65
|
+
taskTemplate: "Implement: {{description}}",
|
|
66
|
+
context: "Follow existing patterns in the codebase",
|
|
67
|
+
verifyCriteria: "Feature works and tests pass",
|
|
68
|
+
verifyCommand: {
|
|
69
|
+
executable: "npm",
|
|
70
|
+
args: ["test"],
|
|
71
|
+
timeoutMs: 120_000,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "refactor",
|
|
76
|
+
description: "Refactor code for clarity without behavior changes",
|
|
77
|
+
taskTemplate: "Refactor {{files}} for clarity",
|
|
78
|
+
verifyCriteria: "All existing tests still pass, no behavior changes",
|
|
79
|
+
verifyCommand: {
|
|
80
|
+
executable: "npm",
|
|
81
|
+
args: ["test"],
|
|
82
|
+
timeoutMs: 120_000,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
// ── File I/O ───────────────────────────────────────────────────────────
|
|
87
|
+
const TEMPLATES_DIR = ".handoff/templates";
|
|
88
|
+
function templatesDir(projectRoot) {
|
|
89
|
+
return join(projectRoot, TEMPLATES_DIR);
|
|
90
|
+
}
|
|
91
|
+
function templatePath(projectRoot, name) {
|
|
92
|
+
const safeName = templateNameSchema.parse(name);
|
|
93
|
+
return join(templatesDir(projectRoot), `${safeName}.json`);
|
|
94
|
+
}
|
|
95
|
+
export async function ensureTemplatesDir(projectRoot) {
|
|
96
|
+
await mkdir(templatesDir(projectRoot), { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
export async function saveTemplate(projectRoot, template) {
|
|
99
|
+
const parsed = templateSchema.parse(template);
|
|
100
|
+
await ensureTemplatesDir(projectRoot);
|
|
101
|
+
await writeFile(templatePath(projectRoot, parsed.name), JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
102
|
+
}
|
|
103
|
+
export async function deleteTemplate(projectRoot, name) {
|
|
104
|
+
const path = templatePath(projectRoot, name);
|
|
105
|
+
try {
|
|
106
|
+
await unlink(path);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function loadTemplate(projectRoot, name) {
|
|
114
|
+
// Check custom templates first
|
|
115
|
+
try {
|
|
116
|
+
const raw = await readFile(templatePath(projectRoot, name), "utf8");
|
|
117
|
+
return templateSchema.parse(JSON.parse(raw));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Fall through to builtins
|
|
121
|
+
}
|
|
122
|
+
return BUILTIN_TEMPLATES.find((t) => t.name === name) ?? null;
|
|
123
|
+
}
|
|
124
|
+
export async function listTemplates(projectRoot) {
|
|
125
|
+
const custom = [];
|
|
126
|
+
try {
|
|
127
|
+
const files = await readdir(templatesDir(projectRoot));
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (!file.endsWith(".json"))
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
const raw = await readFile(join(templatesDir(projectRoot), file), "utf8");
|
|
133
|
+
custom.push(templateSchema.parse(JSON.parse(raw)));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Skip malformed templates
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// No templates dir yet
|
|
142
|
+
}
|
|
143
|
+
// Filter builtins that have been overridden
|
|
144
|
+
const customNames = new Set(custom.map((t) => t.name));
|
|
145
|
+
const builtin = BUILTIN_TEMPLATES.filter((t) => !customNames.has(t.name));
|
|
146
|
+
return { custom, builtin };
|
|
147
|
+
}
|
|
148
|
+
export function applyTemplate(template, options = {}) {
|
|
149
|
+
const vars = options.vars ?? {};
|
|
150
|
+
// Resolve task description
|
|
151
|
+
let description = options.task ?? template.taskTemplate ?? template.name;
|
|
152
|
+
const resolvedVars = options.input
|
|
153
|
+
? {
|
|
154
|
+
files: options.input,
|
|
155
|
+
description: options.input,
|
|
156
|
+
task: options.input,
|
|
157
|
+
...vars,
|
|
158
|
+
}
|
|
159
|
+
: vars;
|
|
160
|
+
for (const [key, value] of Object.entries(resolvedVars)) {
|
|
161
|
+
description = description.replaceAll(`{{${key}}}`, value);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
agent: options.agent ?? template.defaultAgent,
|
|
165
|
+
description,
|
|
166
|
+
context: template.context,
|
|
167
|
+
bundleGlobs: template.bundleGlobs,
|
|
168
|
+
verifyCriteria: template.verifyCriteria,
|
|
169
|
+
verifyCommand: template.verifyCommand
|
|
170
|
+
? {
|
|
171
|
+
executable: template.verifyCommand.executable,
|
|
172
|
+
args: template.verifyCommand.args,
|
|
173
|
+
timeoutMs: template.verifyCommand.timeoutMs ?? 120_000,
|
|
174
|
+
}
|
|
175
|
+
: undefined,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// ── Formatting ─────────────────────────────────────────────────────────
|
|
179
|
+
export function formatTemplateList(templates) {
|
|
180
|
+
const lines = [];
|
|
181
|
+
if (templates.custom.length === 0 && templates.builtin.length === 0) {
|
|
182
|
+
return "No templates available.";
|
|
183
|
+
}
|
|
184
|
+
if (templates.custom.length) {
|
|
185
|
+
lines.push(`\x1b[1mCustom templates\x1b[0m (${templates.custom.length})`);
|
|
186
|
+
for (const t of templates.custom) {
|
|
187
|
+
lines.push(` \x1b[36m${t.name}\x1b[0m — ${t.description}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (templates.builtin.length) {
|
|
191
|
+
if (templates.custom.length)
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push(`\x1b[1mBuilt-in templates\x1b[0m (${templates.builtin.length})`);
|
|
194
|
+
for (const t of templates.builtin) {
|
|
195
|
+
lines.push(` \x1b[90m${t.name}\x1b[0m — ${t.description}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push("\x1b[90mUse: loadout handoff <agent> --template <name> [task...]\x1b[0m");
|
|
200
|
+
lines.push("\x1b[90mCreate: loadout template create <name> --description '...'\x1b[0m");
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
export function formatTemplateDetail(template) {
|
|
204
|
+
const lines = [];
|
|
205
|
+
lines.push(`\x1b[1m${template.name}\x1b[0m — ${template.description}`);
|
|
206
|
+
if (template.defaultAgent)
|
|
207
|
+
lines.push(` Agent: ${template.defaultAgent}`);
|
|
208
|
+
if (template.taskTemplate)
|
|
209
|
+
lines.push(` Task: ${template.taskTemplate}`);
|
|
210
|
+
if (template.context)
|
|
211
|
+
lines.push(` Context: ${template.context}`);
|
|
212
|
+
if (template.bundleGlobs)
|
|
213
|
+
lines.push(` Bundle: ${template.bundleGlobs.join(", ")}`);
|
|
214
|
+
if (template.verifyCriteria)
|
|
215
|
+
lines.push(` Verify: ${template.verifyCriteria}`);
|
|
216
|
+
if (template.verifyCommand) {
|
|
217
|
+
lines.push(` Command: ${template.verifyCommand.executable} ${template.verifyCommand.args.join(" ")}`);
|
|
218
|
+
if (template.verifyCommand.timeoutMs)
|
|
219
|
+
lines.push(` Timeout: ${template.verifyCommand.timeoutMs / 1000}s`);
|
|
220
|
+
}
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getHandoffState, sendHandoffUnlocked, withHandoffLock, HANDOFF_VERIFICATION_MAX_OUTPUT_BYTES, } from "./handoff.js";
|
|
2
|
+
import { redactString } from "../coordination/redaction.js";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export const defaultHandoffVerificationRunner = async (projectRoot, command) => {
|
|
7
|
+
const started = Date.now();
|
|
8
|
+
try {
|
|
9
|
+
const result = await execFileAsync(command.executable, command.args, {
|
|
10
|
+
cwd: projectRoot,
|
|
11
|
+
timeout: command.timeoutMs,
|
|
12
|
+
maxBuffer: 1024 * 1024,
|
|
13
|
+
windowsHide: true,
|
|
14
|
+
shell: false,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
stdout: result.stdout,
|
|
19
|
+
stderr: result.stderr,
|
|
20
|
+
exitCode: 0,
|
|
21
|
+
durationMs: Math.min(Date.now() - started, 900_000),
|
|
22
|
+
timedOut: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const failure = error;
|
|
27
|
+
return {
|
|
28
|
+
stdout: failure.stdout ?? "",
|
|
29
|
+
stderr: failure.stderr ?? (error instanceof Error ? error.message : ""),
|
|
30
|
+
exitCode: typeof failure.code === "number" ? failure.code : 1,
|
|
31
|
+
durationMs: Math.min(Date.now() - started, 900_000),
|
|
32
|
+
timedOut: failure.killed === true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function boundedOutput(value) {
|
|
37
|
+
const redacted = redactString(value);
|
|
38
|
+
const bytes = Buffer.from(redacted);
|
|
39
|
+
if (bytes.byteLength <= HANDOFF_VERIFICATION_MAX_OUTPUT_BYTES)
|
|
40
|
+
return { value: redacted, isTruncated: false };
|
|
41
|
+
for (let remove = 0; remove <= 3; remove += 1) {
|
|
42
|
+
try {
|
|
43
|
+
return {
|
|
44
|
+
value: new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, HANDOFF_VERIFICATION_MAX_OUTPUT_BYTES - remove)),
|
|
45
|
+
isTruncated: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// The byte cap can land inside one UTF-8 code point.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { value: "", isTruncated: true };
|
|
53
|
+
}
|
|
54
|
+
export async function completeHandoff(projectRoot, messageId, options = {}) {
|
|
55
|
+
const original = await withHandoffLock(projectRoot, async () => {
|
|
56
|
+
const state = await getHandoffState(projectRoot);
|
|
57
|
+
const task = state.messages.find((message) => message.id === messageId);
|
|
58
|
+
if (!task)
|
|
59
|
+
throw new Error(`Message '${messageId}' not found`);
|
|
60
|
+
if (task.type !== "task")
|
|
61
|
+
throw new Error(`Message '${messageId}' is not a task`);
|
|
62
|
+
if (state.done.some((message) => message.id === messageId))
|
|
63
|
+
throw new Error(`Message '${messageId}' is already settled`);
|
|
64
|
+
return task;
|
|
65
|
+
});
|
|
66
|
+
if (original.verification &&
|
|
67
|
+
!original.verification.command &&
|
|
68
|
+
!options.manualEvidence?.trim())
|
|
69
|
+
throw new Error(`Task '${messageId}' requires manual evidence for: ${original.verification.criteria}`);
|
|
70
|
+
if (original.verification?.command && !options.approveCommand)
|
|
71
|
+
throw new Error(`Task '${messageId}' requires explicit approval to run its stored verification command`);
|
|
72
|
+
let completed = true;
|
|
73
|
+
let description = `Completed: ${original.description}`;
|
|
74
|
+
let evidence;
|
|
75
|
+
if (original.verification?.command) {
|
|
76
|
+
const command = original.verification.command;
|
|
77
|
+
const result = await (options.runner ?? defaultHandoffVerificationRunner)(projectRoot, command);
|
|
78
|
+
const passed = result.exitCode === 0 && !result.timedOut;
|
|
79
|
+
const stdout = boundedOutput(result.stdout);
|
|
80
|
+
const stderr = boundedOutput(result.stderr);
|
|
81
|
+
completed = passed;
|
|
82
|
+
description = passed
|
|
83
|
+
? `Completed: ${original.description}`
|
|
84
|
+
: `Verification failed: ${original.verification.criteria}`;
|
|
85
|
+
evidence = {
|
|
86
|
+
mode: "command",
|
|
87
|
+
status: passed ? "passed" : "failed",
|
|
88
|
+
command: [command.executable, ...command.args],
|
|
89
|
+
exitCode: result.exitCode,
|
|
90
|
+
durationMs: result.durationMs,
|
|
91
|
+
stdout: stdout.value,
|
|
92
|
+
stderr: stderr.value,
|
|
93
|
+
timedOut: result.timedOut,
|
|
94
|
+
isTruncated: stdout.isTruncated || stderr.isTruncated,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
else if (original.verification) {
|
|
98
|
+
evidence = {
|
|
99
|
+
mode: "manual",
|
|
100
|
+
status: "passed",
|
|
101
|
+
summary: options.manualEvidence.trim(),
|
|
102
|
+
isTruncated: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return withHandoffLock(projectRoot, async () => {
|
|
106
|
+
const current = await getHandoffState(projectRoot);
|
|
107
|
+
if (current.done.some((message) => message.id === messageId))
|
|
108
|
+
throw new Error(`Message '${messageId}' is already settled`);
|
|
109
|
+
const message = await sendHandoffUnlocked(projectRoot, original.from, description, {
|
|
110
|
+
from: original.to,
|
|
111
|
+
type: completed ? "done" : "status",
|
|
112
|
+
resolves: messageId,
|
|
113
|
+
...(evidence ? { evidence } : {}),
|
|
114
|
+
});
|
|
115
|
+
return { completed, message };
|
|
116
|
+
});
|
|
117
|
+
}
|