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,200 @@
1
+ /**
2
+ * One-command coordination setup.
3
+ *
4
+ * `loadout coord start --agents claude-code,codex` detects the project
5
+ * structure, assigns file ownership, and prints a ready-to-go status —
6
+ * replacing the 6-command manual setup.
7
+ */
8
+ import { readdir } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { claimOwnership, getOwnership, snapshot, } from "./coordinator.js";
11
+ /** Well-known directory groupings. Order matters — first match wins. */
12
+ const SPLIT_PATTERNS = {
13
+ "backend/frontend": {
14
+ label: "backend / frontend",
15
+ groups: [
16
+ /^(server|backend|api|src\/api|src\/server|src\/backend|app\/api|packages\/api|packages\/server|packages\/backend|lib|services)/,
17
+ /^(client|frontend|web|app|src\/app|src\/web|src\/client|src\/frontend|src\/components|src\/pages|src\/views|packages\/web|packages\/client|packages\/frontend|components|pages|views|ui)/,
18
+ ],
19
+ },
20
+ "core/tests": {
21
+ label: "core / tests",
22
+ groups: [
23
+ /^(src|lib|app|packages)/,
24
+ /^(tests|test|__tests__|spec|specs|e2e|cypress)/,
25
+ ],
26
+ },
27
+ };
28
+ async function listTopDirs(projectRoot) {
29
+ const entries = await readdir(projectRoot, { withFileTypes: true });
30
+ const dirs = [];
31
+ for (const entry of entries) {
32
+ if (!entry.isDirectory())
33
+ continue;
34
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
35
+ continue;
36
+ if (["dist", "build", "out", "coverage", "test-results", ".next"].includes(entry.name))
37
+ continue;
38
+ dirs.push(entry.name);
39
+ }
40
+ // Also check src/ subdirectories since many projects keep everything under src/
41
+ try {
42
+ const srcEntries = await readdir(join(projectRoot, "src"), {
43
+ withFileTypes: true,
44
+ });
45
+ const nested = [];
46
+ for (const entry of srcEntries) {
47
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
48
+ nested.push(`src/${entry.name}`);
49
+ }
50
+ }
51
+ if (nested.length > 0) {
52
+ const srcIndex = dirs.indexOf("src");
53
+ if (srcIndex >= 0)
54
+ dirs.splice(srcIndex, 1);
55
+ dirs.push(...nested);
56
+ }
57
+ }
58
+ catch {
59
+ // No src/ directory — that's fine.
60
+ }
61
+ return dirs;
62
+ }
63
+ function collapsePaths(paths) {
64
+ const sorted = [...new Set(paths)].sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
65
+ return sorted.filter((path, index) => !sorted
66
+ .slice(0, index)
67
+ .some((parent) => path === parent || path.startsWith(`${parent}/`)));
68
+ }
69
+ export async function detectSplit(projectRoot, agents, preferredSplit) {
70
+ if (agents.some((agent) => !agent.trim()) || agents[0] === agents[1])
71
+ throw new Error("Coordination requires two distinct, non-empty agents");
72
+ if (preferredSplit && !SPLIT_PATTERNS[preferredSplit])
73
+ throw new Error(`Unknown split strategy '${preferredSplit}'`);
74
+ const dirs = await listTopDirs(projectRoot);
75
+ // Try preferred split first, then all patterns
76
+ const order = preferredSplit
77
+ ? [
78
+ preferredSplit,
79
+ ...Object.keys(SPLIT_PATTERNS).filter((k) => k !== preferredSplit),
80
+ ]
81
+ : Object.keys(SPLIT_PATTERNS);
82
+ for (const key of order) {
83
+ const pattern = SPLIT_PATTERNS[key];
84
+ if (!pattern)
85
+ continue;
86
+ const groupA = dirs.filter((d) => pattern.groups[0].test(d));
87
+ const groupB = dirs.filter((d) => pattern.groups[1].test(d));
88
+ if (groupA.length > 0 && groupB.length > 0) {
89
+ const assigned = new Set([...groupA, ...groupB]);
90
+ return {
91
+ assignments: new Map([
92
+ [agents[0], collapsePaths(groupA)],
93
+ [agents[1], collapsePaths(groupB)],
94
+ ]),
95
+ unassigned: dirs.filter((d) => !assigned.has(d)),
96
+ strategy: pattern.label,
97
+ };
98
+ }
99
+ }
100
+ // Fallback: even split by directory count
101
+ const sorted = [...dirs].sort();
102
+ const mid = Math.ceil(sorted.length / 2);
103
+ return {
104
+ assignments: new Map([
105
+ [agents[0], collapsePaths(sorted.slice(0, mid))],
106
+ [agents[1], collapsePaths(sorted.slice(mid))],
107
+ ]),
108
+ unassigned: [],
109
+ strategy: "even split (no recognized pattern)",
110
+ };
111
+ }
112
+ export async function quickStart(projectRoot, agents, options = {}) {
113
+ const split = await detectSplit(projectRoot, agents, options.split);
114
+ // Check existing ownership
115
+ const existing = await getOwnership(projectRoot);
116
+ const existingOwnership = existing.size > 0;
117
+ let ownershipClaimed = false;
118
+ if (!options.dryRun && !existingOwnership) {
119
+ // Claim ownership for each agent
120
+ for (const [agent, paths] of split.assignments) {
121
+ if (paths.length === 0)
122
+ continue;
123
+ const claimOptions = {
124
+ agent,
125
+ paths,
126
+ mode: "exclusive",
127
+ reason: `Auto-assigned by coord start (${split.strategy})`,
128
+ };
129
+ await claimOwnership(projectRoot, claimOptions);
130
+ }
131
+ ownershipClaimed = true;
132
+ }
133
+ // Get snapshot summary
134
+ const snap = await snapshot(projectRoot, agents[1]);
135
+ const lines = [];
136
+ if (snap.activeContracts.length > 0) {
137
+ lines.push(`${snap.activeContracts.length} contract(s): ${snap.activeContracts.map((c) => `${c.name} rev${c.revision}`).join(", ")}`);
138
+ }
139
+ if (snap.ownership.length > 0) {
140
+ lines.push(`${snap.ownership.length} owned path(s)`);
141
+ }
142
+ if (snap.unackedForAgent.length > 0) {
143
+ lines.push(`${snap.unackedForAgent.length} unacknowledged event(s)`);
144
+ }
145
+ return {
146
+ split,
147
+ ownershipClaimed,
148
+ existingOwnership,
149
+ snapshotSummary: lines.join(" · ") || "empty — ready for first events",
150
+ };
151
+ }
152
+ // ── Terminal formatting ─────────────────────────────────────────────
153
+ export function formatQuickStart(result) {
154
+ const lines = [];
155
+ lines.push("\x1b[1m╔══════════════════════════════════════════╗\x1b[0m");
156
+ lines.push("\x1b[1m║ COORDINATION READY ║\x1b[0m");
157
+ lines.push("\x1b[1m╚══════════════════════════════════════════╝\x1b[0m");
158
+ lines.push("");
159
+ lines.push(` Strategy: \x1b[36m${result.split.strategy}\x1b[0m`);
160
+ lines.push("");
161
+ for (const [agent, paths] of result.split.assignments) {
162
+ const color = agent.includes("claude") ? "\x1b[36m" : "\x1b[33m";
163
+ lines.push(` ${color}${agent}\x1b[0m`);
164
+ for (const p of paths) {
165
+ lines.push(` \x1b[90m└─\x1b[0m ${p}/`);
166
+ }
167
+ lines.push("");
168
+ }
169
+ if (result.split.unassigned.length > 0) {
170
+ lines.push(" \x1b[90mUnassigned (shared):\x1b[0m");
171
+ for (const p of result.split.unassigned) {
172
+ lines.push(` \x1b[90m└─\x1b[0m ${p}/`);
173
+ }
174
+ lines.push("");
175
+ }
176
+ if (result.existingOwnership) {
177
+ lines.push(" \x1b[33m⚠ Existing ownership detected — skipped auto-assignment.\x1b[0m");
178
+ lines.push(" Run \x1b[90mloadout coord status\x1b[0m to see current ownership.");
179
+ }
180
+ else if (result.ownershipClaimed) {
181
+ lines.push(" \x1b[32m✓ Ownership claimed for both agents.\x1b[0m");
182
+ }
183
+ else {
184
+ lines.push(" \x1b[90mDry run — no ownership claimed. Add --yes to apply.\x1b[0m");
185
+ }
186
+ lines.push("");
187
+ lines.push("\x1b[90m─────────────────────────────────────────────\x1b[0m");
188
+ lines.push("");
189
+ lines.push(" \x1b[1mWhat each agent should do now:\x1b[0m");
190
+ lines.push("");
191
+ const agentList = [...result.split.assignments.keys()];
192
+ lines.push(` 1. Open \x1b[36m${agentList[0]}\x1b[0m → it checks its snapshot and starts working`);
193
+ lines.push(` 2. Open \x1b[33m${agentList[1]}\x1b[0m → it sees the ownership split and builds against contracts`);
194
+ lines.push(" 3. When either creates/changes a shared interface → it publishes a contract");
195
+ lines.push(" 4. The other agent sees the contract on its next snapshot check");
196
+ lines.push("");
197
+ lines.push(" The \x1b[90mloadout-handoff\x1b[0m skill handles steps 1-4 automatically.");
198
+ lines.push(" Just tell each agent what to build — it runs the coord commands for you.");
199
+ return lines.join("\n");
200
+ }
@@ -9,6 +9,8 @@ import { readFile, writeFile, rename, stat, mkdir } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
10
  import { assertCoordinationEnabled, readCoordLog } from "./coordinator.js";
11
11
  import { withCoordinationLock } from "./lock.js";
12
+ /** Event types whose latest value defines current system state. */
13
+ const STATE_BEARING_TYPES = new Set(["ownership", "contract", "decision"]);
12
14
  const COORD_DIR = ".handoff";
13
15
  const COORD_LOG = "coordination.jsonl";
14
16
  export const DEFAULT_RETENTION = {
@@ -34,8 +36,12 @@ export async function compact(projectRoot, config = DEFAULT_RETENTION) {
34
36
  const ageStart = firstFresh === -1 ? log.events.length : firstFresh;
35
37
  const countStart = Math.max(0, log.events.length - config.maxEvents);
36
38
  const keepStart = Math.max(ageStart, countStart);
37
- const remove = log.events.slice(0, keepStart);
38
- const finalRetained = log.events.slice(keepStart);
39
+ const removable = log.events.slice(0, keepStart);
40
+ const keptByIndex = log.events.slice(keepStart);
41
+ // Preserve state-bearing events that would otherwise be lost.
42
+ const stateCheckpoints = extractStateCheckpoints(removable, keptByIndex);
43
+ const finalRetained = [...stateCheckpoints, ...keptByIndex];
44
+ const remove = removable.filter((e) => !stateCheckpoints.includes(e));
39
45
  if (remove.length === 0) {
40
46
  return {
41
47
  before: log.events.length,
@@ -82,9 +88,19 @@ export async function compact(projectRoot, config = DEFAULT_RETENTION) {
82
88
  payload: summaryPayload,
83
89
  });
84
90
  const lines = [
85
- summaryLine,
86
- ...finalRetained.map((e) => JSON.stringify(e)),
87
- ].join("\n");
91
+ ...stateCheckpoints.map((event) => ({
92
+ seq: event.seq,
93
+ line: JSON.stringify(event),
94
+ })),
95
+ { seq: remove[remove.length - 1].seq, line: summaryLine },
96
+ ...keptByIndex.map((event) => ({
97
+ seq: event.seq,
98
+ line: JSON.stringify(event),
99
+ })),
100
+ ]
101
+ .sort((left, right) => left.seq - right.seq)
102
+ .map(({ line }) => line)
103
+ .join("\n");
88
104
  // Atomic write: write to temp, rename over
89
105
  const tmpPath = `${logPath}.tmp`;
90
106
  await writeFile(tmpPath, lines + "\n", {
@@ -126,3 +142,49 @@ export async function logSize(projectRoot) {
126
142
  throw error;
127
143
  }
128
144
  }
145
+ /**
146
+ * Extract the latest state-bearing events from the removable set that are not
147
+ * already represented in the kept set. Ownership, contracts, and decisions
148
+ * define current system state — losing them during compaction would silently
149
+ * drop ownership claims, contract revisions, or active decisions.
150
+ */
151
+ function extractStateCheckpoints(removable, kept) {
152
+ const keptKeys = new Set();
153
+ for (const e of kept) {
154
+ const key = stateKey(e);
155
+ if (key)
156
+ keptKeys.add(key);
157
+ }
158
+ // Walk removable in reverse to find the *latest* event per state key.
159
+ const seen = new Set();
160
+ const checkpoints = [];
161
+ for (let i = removable.length - 1; i >= 0; i--) {
162
+ const e = removable[i];
163
+ const key = stateKey(e);
164
+ if (!key)
165
+ continue;
166
+ if (keptKeys.has(key) || seen.has(key))
167
+ continue;
168
+ seen.add(key);
169
+ checkpoints.push(e);
170
+ }
171
+ // Return in original chronological order.
172
+ return checkpoints.reverse();
173
+ }
174
+ function stateKey(event) {
175
+ if (!STATE_BEARING_TYPES.has(event.type) || !event.payload)
176
+ return undefined;
177
+ const p = event.payload;
178
+ switch (event.type) {
179
+ case "ownership":
180
+ // Key by agent + sorted paths — an ownership event replaces the previous
181
+ // one for the same agent/path combination.
182
+ return `ownership:${event.from}:${[...(p.paths ?? [])].sort().join(",")}`;
183
+ case "contract":
184
+ return `contract:${p.name}`;
185
+ case "decision":
186
+ return `decision:${p.title}`;
187
+ default:
188
+ return undefined;
189
+ }
190
+ }
@@ -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
+ }