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.
Files changed (98) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +149 -309
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +29122 -25325
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +12 -4
  7. package/dist/src/commands/agents.js +3 -1
  8. package/dist/src/commands/catalog-candidate.js +150 -0
  9. package/dist/src/commands/catalog-workflows.js +365 -0
  10. package/dist/src/commands/catalog.js +9 -354
  11. package/dist/src/commands/coordinate.js +730 -0
  12. package/dist/src/commands/coordination-discussions.js +218 -0
  13. package/dist/src/commands/coordination-sessions.js +197 -0
  14. package/dist/src/commands/inventory.js +4 -2
  15. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  16. package/dist/src/core/catalog/catalog.js +1 -0
  17. package/dist/src/core/catalog/registry.js +5 -19
  18. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  19. package/dist/src/core/coordination/adapters/codex.js +160 -0
  20. package/dist/src/core/coordination/adapters/types.js +64 -0
  21. package/dist/src/core/coordination/auth.js +59 -0
  22. package/dist/src/core/coordination/auto-contract.js +457 -0
  23. package/dist/src/core/coordination/bridge-lease.js +79 -0
  24. package/dist/src/core/coordination/conflict-preview.js +167 -0
  25. package/dist/src/core/coordination/contract-diff.js +106 -0
  26. package/dist/src/core/coordination/coordinator.js +552 -0
  27. package/dist/src/core/coordination/crash-recovery.js +169 -0
  28. package/dist/src/core/coordination/daemon.js +670 -0
  29. package/dist/src/core/coordination/discussion-pipeline.js +313 -0
  30. package/dist/src/core/coordination/discussion.js +340 -0
  31. package/dist/src/core/coordination/events.js +161 -0
  32. package/dist/src/core/coordination/git-ownership.js +217 -0
  33. package/dist/src/core/coordination/http-api.js +118 -0
  34. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  35. package/dist/src/core/coordination/lock.js +140 -0
  36. package/dist/src/core/coordination/mcp-server.js +424 -0
  37. package/dist/src/core/coordination/quick-start.js +200 -0
  38. package/dist/src/core/coordination/redaction.js +85 -0
  39. package/dist/src/core/coordination/replay.js +188 -0
  40. package/dist/src/core/coordination/retention.js +180 -0
  41. package/dist/src/core/coordination/runtime.js +21 -0
  42. package/dist/src/core/coordination/session-manager.js +366 -0
  43. package/dist/src/core/coordination/watcher.js +128 -0
  44. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  45. package/dist/src/core/delegation/handoff-bundle.js +253 -0
  46. package/dist/src/core/delegation/handoff-templates.js +222 -0
  47. package/dist/src/core/delegation/handoff-verification.js +117 -0
  48. package/dist/src/core/delegation/handoff.js +537 -0
  49. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  50. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  51. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  52. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  53. package/dist/src/core/discovery/community.js +12 -4
  54. package/dist/src/core/discovery/github-discovery.js +6 -2
  55. package/dist/src/core/discovery/private-discovery.js +6 -2
  56. package/dist/src/core/install/reconcile.js +1 -1
  57. package/dist/src/core/install/snapshot.js +24 -5
  58. package/dist/src/core/install/uninstall.js +39 -3
  59. package/dist/src/core/reporting/cli-guide.js +9 -5
  60. package/dist/src/core/reporting/completion.js +43 -38
  61. package/dist/src/core/reporting/doctor.js +1 -1
  62. package/dist/src/core/runtime/bounded-json.js +65 -0
  63. package/dist/src/core/runtime/github.js +30 -16
  64. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  65. package/dist/src/core/workspace/active-policy.js +1 -1
  66. package/docs/DEMO_SCRIPT.md +19 -23
  67. package/docs/DISCOVERED.md +250 -251
  68. package/docs/FEATURE_TEST_MATRIX.md +30 -15
  69. package/docs/LIVE_COLLABORATION.md +283 -0
  70. package/docs/REFERENCE.md +263 -0
  71. package/docs/RELEASE_REVIEW.md +1 -1
  72. package/docs/TESTING.md +17 -1
  73. package/docs/USER_TEST_GUIDE.md +211 -3
  74. package/docs/assets/loadout-discover-activate.webp +0 -0
  75. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  76. package/docs/assets/loadout-social-preview.png +0 -0
  77. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  78. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  79. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  80. package/docs/evidence/coordination-provider-check-2026-09-05.md +33 -0
  81. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  82. package/docs/specs/HANDOFF_CONTEXT_BUNDLES.md +139 -0
  83. package/docs/specs/HANDOFF_VERIFICATION.md +83 -0
  84. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  85. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  86. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  87. package/docs/superpowers/plans/2026-09-04-handoff-context-bundles.md +109 -0
  88. package/docs/superpowers/plans/2026-09-04-handoff-verification.md +56 -0
  89. package/docs/superpowers/plans/2026-09-05-pre-release-hardening.md +175 -0
  90. package/docs/superpowers/plans/2026-09-05-public-readiness.md +20 -0
  91. package/package.json +15 -5
  92. package/skills/loadout-handoff/SKILL.md +291 -0
  93. package/dist/src/core/routing/handoff.js +0 -314
  94. package/dist/src/core/routing/policy.js +0 -147
  95. package/dist/src/core/routing/route.js +0 -411
  96. package/docs/assets/loadout-workflow.png +0 -0
  97. package/skills/loadout-router/SKILL.md +0 -85
  98. /package/dist/src/core/{routing → agents}/credentials.js +0 -0
@@ -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
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Redact sensitive data from coordination event payloads.
3
+ *
4
+ * Applied on emit, before storage. Patterns match common secret formats
5
+ * (API keys, tokens, passwords, GitHub PATs, Stripe keys, JWTs, etc.).
6
+ */
7
+ const REDACTION_MARKER = "[REDACTED]";
8
+ const SENSITIVE_KEY = /^(?:api[_-]?key|access[_-]?token|auth(?:orization)?|bearer|client[_-]?secret|secret|token|password|passwd|pwd|credentials?)$/i;
9
+ const SECRET_PATTERNS = [
10
+ // Key-value patterns: api_key=..., secret: "...", password='...'
11
+ /(?:api[_-]?key|apikey|secret|token|password|passwd|pwd|credentials?)\s*[:=]\s*['"]?([^\s'"]{12,})['"]?/gi,
12
+ // Stripe keys: sk_live_..., pk_test_..., rk_live_...
13
+ /(?:sk|pk|rk)[-_](?:live|test)[-_][a-zA-Z0-9]{10,}/g,
14
+ // GitHub PATs
15
+ /ghp_[a-zA-Z0-9]{36}/g,
16
+ /github_pat_[a-zA-Z0-9_]{22,}/g,
17
+ // OpenAI keys
18
+ /sk-[a-zA-Z0-9]{20,}/g,
19
+ // Anthropic keys
20
+ /sk-ant-[a-zA-Z0-9_-]{20,}/g,
21
+ // AWS keys
22
+ /AKIA[0-9A-Z]{16}/g,
23
+ // Bearer tokens
24
+ /Bearer\s+[a-zA-Z0-9._\-/+=]{20,}/gi,
25
+ // JWTs (three base64 segments separated by dots)
26
+ /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g,
27
+ // Generic long hex/base64 strings that look like secrets (40+ chars)
28
+ /(?:^|["'\s=:])([a-f0-9]{40,})(?:["'\s]|$)/gm,
29
+ // .env style: ENV_VAR=value on its own line
30
+ /^[A-Z][A-Z0-9_]{2,}_(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)\s*=\s*(.+)$/gm,
31
+ ];
32
+ export function redactString(input) {
33
+ let result = input;
34
+ for (const pattern of SECRET_PATTERNS) {
35
+ // Reset lastIndex for stateful regexes
36
+ pattern.lastIndex = 0;
37
+ result = result.replace(pattern, (match) => {
38
+ // Keep the key part, redact the value part
39
+ const eqIndex = match.search(/[:=]\s*/);
40
+ if (eqIndex !== -1) {
41
+ return match.slice(0, eqIndex + 1) + " " + REDACTION_MARKER;
42
+ }
43
+ return REDACTION_MARKER;
44
+ });
45
+ }
46
+ return result;
47
+ }
48
+ export function redactPayload(payload) {
49
+ const result = {};
50
+ for (const [key, value] of Object.entries(payload)) {
51
+ if (SENSITIVE_KEY.test(key) && typeof value === "string") {
52
+ result[key] = REDACTION_MARKER;
53
+ }
54
+ else if (typeof value === "string") {
55
+ result[key] = redactString(value);
56
+ }
57
+ else if (value !== null &&
58
+ typeof value === "object" &&
59
+ !Array.isArray(value)) {
60
+ result[key] = redactPayload(value);
61
+ }
62
+ else if (Array.isArray(value)) {
63
+ result[key] = value.map((item) => typeof item === "string"
64
+ ? redactString(item)
65
+ : item !== null && typeof item === "object"
66
+ ? redactPayload(item)
67
+ : item);
68
+ }
69
+ else {
70
+ result[key] = value;
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+ export function redactDescription(description) {
76
+ return redactString(description);
77
+ }
78
+ export function redactCoordinationInput(input) {
79
+ return {
80
+ ...input,
81
+ description: redactString(input.description),
82
+ ...(input.context ? { context: redactString(input.context) } : {}),
83
+ ...(input.payload ? { payload: redactPayload(input.payload) } : {}),
84
+ };
85
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Coordination replay — play back the full coordination timeline as a story.
3
+ *
4
+ * `loadout coord replay` reads the JSONL log and presents it as a formatted
5
+ * narrative timeline with timestamps, agent actions, and state transitions.
6
+ */
7
+ import { readCoordLog, getContracts, getOwnership } from "./coordinator.js";
8
+ const TYPE_EMOJI = {
9
+ task: "📋",
10
+ handoff: "🤝",
11
+ question: "❓",
12
+ done: "✅",
13
+ status: "📊",
14
+ error: "❌",
15
+ cancel: "🚫",
16
+ contract: "📜",
17
+ ownership: "🔒",
18
+ decision: "⚖️",
19
+ update: "📝",
20
+ ack: "👍",
21
+ discussion: "💬",
22
+ };
23
+ function eventHeadline(event) {
24
+ const payload = event.payload;
25
+ switch (event.type) {
26
+ case "contract": {
27
+ const name = payload?.name ?? "unknown";
28
+ const rev = payload?.revision ?? "?";
29
+ return `Published contract "${name}" rev${rev}`;
30
+ }
31
+ case "ownership": {
32
+ const paths = payload?.paths ?? [];
33
+ const mode = payload?.mode ?? "exclusive";
34
+ return `Claimed ${mode} ownership of ${paths.length} path(s): ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? "..." : ""}`;
35
+ }
36
+ case "decision": {
37
+ const title = payload?.title ?? event.description;
38
+ return `Decision: ${title}`;
39
+ }
40
+ case "update": {
41
+ const note = payload?.note ?? event.description;
42
+ const files = payload?.files;
43
+ const filePart = files ? ` (${files.length} files)` : "";
44
+ return `${note}${filePart}`;
45
+ }
46
+ case "ack": {
47
+ const ackSeq = payload?.eventSeq ?? "?";
48
+ return `Acknowledged through seq ${ackSeq}`;
49
+ }
50
+ case "discussion": {
51
+ const threadId = payload?.threadId ?? "unknown";
52
+ const round = payload?.round ?? "?";
53
+ const kind = payload?.kind ?? "message";
54
+ return `Discussion ${threadId} · round ${round} · ${kind}`;
55
+ }
56
+ case "task":
57
+ return `Task: ${event.description}`;
58
+ case "handoff":
59
+ return `Handoff to ${event.to}: ${event.description}`;
60
+ case "done":
61
+ return `Completed: ${event.description}`;
62
+ case "error":
63
+ return `Error: ${event.description}`;
64
+ default:
65
+ return event.description;
66
+ }
67
+ }
68
+ function eventDetail(event) {
69
+ if (event.type !== "discussion" || !event.payload)
70
+ return event.context;
71
+ const payload = event.payload;
72
+ if (!payload.content)
73
+ return event.context;
74
+ return `${payload.content}${payload.replyTo ? ` (reply to ${payload.replyTo})` : ""}`;
75
+ }
76
+ function formatDuration(ms) {
77
+ if (ms < 1000)
78
+ return `${ms}ms`;
79
+ const seconds = Math.floor(ms / 1000);
80
+ if (seconds < 60)
81
+ return `${seconds}s`;
82
+ const minutes = Math.floor(seconds / 60);
83
+ if (minutes < 60)
84
+ return `${minutes}m ${seconds % 60}s`;
85
+ const hours = Math.floor(minutes / 60);
86
+ return `${hours}h ${minutes % 60}m`;
87
+ }
88
+ function relativeTimestamp(eventTime, startTime) {
89
+ const ms = eventTime.getTime() - startTime.getTime();
90
+ return `+${formatDuration(ms)}`;
91
+ }
92
+ /** Max events to include in a replay to prevent memory issues. */
93
+ const MAX_REPLAY_EVENTS = 2000;
94
+ /**
95
+ * Build the replay timeline from the coordination log.
96
+ * Bounded to the most recent MAX_REPLAY_EVENTS entries.
97
+ */
98
+ export async function buildReplay(projectRoot) {
99
+ const log = await readCoordLog(projectRoot);
100
+ const contracts = await getContracts(projectRoot);
101
+ const ownership = await getOwnership(projectRoot);
102
+ if (log.events.length === 0) {
103
+ return {
104
+ entries: [],
105
+ agents: [],
106
+ startTime: "",
107
+ endTime: "",
108
+ duration: "0s",
109
+ contractCount: 0,
110
+ ownershipCount: 0,
111
+ totalEvents: 0,
112
+ };
113
+ }
114
+ // Bound to recent events
115
+ const bounded = log.events.length > MAX_REPLAY_EVENTS
116
+ ? log.events.slice(-MAX_REPLAY_EVENTS)
117
+ : log.events;
118
+ const startTime = new Date(bounded[0].timestamp);
119
+ const endTime = new Date(bounded[bounded.length - 1].timestamp);
120
+ const agents = [...new Set(bounded.map((e) => e.from))];
121
+ const entries = bounded.map((event) => ({
122
+ seq: event.seq,
123
+ timestamp: event.timestamp,
124
+ agent: event.from,
125
+ type: event.type,
126
+ emoji: TYPE_EMOJI[event.type] ?? "•",
127
+ headline: eventHeadline(event),
128
+ detail: eventDetail(event),
129
+ relativeTime: relativeTimestamp(new Date(event.timestamp), startTime),
130
+ }));
131
+ return {
132
+ entries,
133
+ agents,
134
+ startTime: bounded[0].timestamp,
135
+ endTime: bounded[bounded.length - 1].timestamp,
136
+ duration: formatDuration(endTime.getTime() - startTime.getTime()),
137
+ contractCount: contracts.size,
138
+ ownershipCount: ownership.size,
139
+ totalEvents: log.events.length,
140
+ };
141
+ }
142
+ /**
143
+ * Format replay timeline for terminal display.
144
+ */
145
+ export function formatReplay(timeline) {
146
+ if (timeline.entries.length === 0) {
147
+ return "No coordination events to replay.";
148
+ }
149
+ const lines = [];
150
+ // Header
151
+ lines.push("\x1b[1m╔══════════════════════════════════════════╗\x1b[0m");
152
+ lines.push("\x1b[1m║ COORDINATION REPLAY ║\x1b[0m");
153
+ lines.push("\x1b[1m╚══════════════════════════════════════════╝\x1b[0m");
154
+ lines.push("");
155
+ lines.push(` Agents: ${timeline.agents.map((a) => `\x1b[36m${a}\x1b[0m`).join(", ")}`);
156
+ lines.push(` Duration: ${timeline.duration}`);
157
+ lines.push(` Events: ${timeline.totalEvents} · Contracts: ${timeline.contractCount} · Ownership claims: ${timeline.ownershipCount}`);
158
+ lines.push("");
159
+ lines.push("\x1b[90m─────────────────────────────────────────────\x1b[0m");
160
+ lines.push("");
161
+ // Assign colors to agents
162
+ const agentColors = ["\x1b[36m", "\x1b[33m", "\x1b[35m", "\x1b[34m"];
163
+ const colorMap = new Map();
164
+ timeline.agents.forEach((a, i) => {
165
+ colorMap.set(a, agentColors[i % agentColors.length]);
166
+ });
167
+ // Timeline entries
168
+ let lastAgent = "";
169
+ for (const entry of timeline.entries) {
170
+ const color = colorMap.get(entry.agent) ?? "\x1b[37m";
171
+ // Agent separator
172
+ if (entry.agent !== lastAgent) {
173
+ if (lastAgent)
174
+ lines.push("");
175
+ lastAgent = entry.agent;
176
+ }
177
+ const timeStr = `\x1b[90m${entry.relativeTime.padStart(8)}\x1b[0m`;
178
+ const agentStr = `${color}${entry.agent.padEnd(12)}\x1b[0m`;
179
+ lines.push(` ${timeStr} ${entry.emoji} ${agentStr} ${entry.headline}`);
180
+ if (entry.detail) {
181
+ lines.push(` \x1b[90m└─ ${entry.detail}\x1b[0m`);
182
+ }
183
+ }
184
+ lines.push("");
185
+ lines.push("\x1b[90m─────────────────────────────────────────────\x1b[0m");
186
+ lines.push(` \x1b[32m✓\x1b[0m Replay complete · ${timeline.totalEvents} events`);
187
+ return lines.join("\n");
188
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Retention and compaction for the coordination log.
3
+ *
4
+ * The JSONL audit log is never deleted. Compaction rewrites the working log
5
+ * with a summary event followed by recent events, keeping the active set
6
+ * bounded.
7
+ */
8
+ import { readFile, writeFile, rename, stat, mkdir } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { assertCoordinationEnabled, readCoordLog } from "./coordinator.js";
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"]);
14
+ const COORD_DIR = ".handoff";
15
+ const COORD_LOG = "coordination.jsonl";
16
+ export const DEFAULT_RETENTION = {
17
+ maxEvents: 10000,
18
+ maxAgeDays: 30,
19
+ };
20
+ export async function compact(projectRoot, config = DEFAULT_RETENTION) {
21
+ const dir = join(projectRoot, COORD_DIR);
22
+ await mkdir(dir, { recursive: true });
23
+ return withCoordinationLock(dir, async () => {
24
+ await assertCoordinationEnabled(projectRoot);
25
+ const log = await readCoordLog(projectRoot);
26
+ if (!Number.isSafeInteger(config.maxEvents) || config.maxEvents < 1) {
27
+ throw new Error("maxEvents must be a positive integer");
28
+ }
29
+ if (!Number.isFinite(config.maxAgeDays) || config.maxAgeDays < 0) {
30
+ throw new Error("maxAgeDays must be a non-negative number");
31
+ }
32
+ const cutoffDate = new Date();
33
+ cutoffDate.setDate(cutoffDate.getDate() - config.maxAgeDays);
34
+ const cutoffIso = cutoffDate.toISOString();
35
+ const firstFresh = log.events.findIndex((event) => event.timestamp >= cutoffIso);
36
+ const ageStart = firstFresh === -1 ? log.events.length : firstFresh;
37
+ const countStart = Math.max(0, log.events.length - config.maxEvents);
38
+ const keepStart = Math.max(ageStart, countStart);
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));
45
+ if (remove.length === 0) {
46
+ return {
47
+ before: log.events.length,
48
+ after: log.events.length,
49
+ removed: 0,
50
+ compacted: false,
51
+ };
52
+ }
53
+ // Build summary of what was compacted
54
+ const agents = new Set();
55
+ const types = new Map();
56
+ for (const e of remove) {
57
+ agents.add(e.from);
58
+ types.set(e.type, (types.get(e.type) ?? 0) + 1);
59
+ }
60
+ const summaryPayload = {
61
+ compactedCount: remove.length,
62
+ compactedRange: {
63
+ from: remove[0].seq,
64
+ to: remove[remove.length - 1].seq,
65
+ },
66
+ agents: [...agents],
67
+ typeCounts: Object.fromEntries(types),
68
+ compactedAt: new Date().toISOString(),
69
+ };
70
+ const logPath = join(projectRoot, COORD_DIR, COORD_LOG);
71
+ // Archival is the durability boundary: never rewrite if it fails.
72
+ const archivePath = `${logPath}.${Date.now()}.archive`;
73
+ const raw = await readFile(logPath, "utf-8");
74
+ await writeFile(archivePath, raw, {
75
+ encoding: "utf-8",
76
+ flag: "wx",
77
+ mode: 0o600,
78
+ });
79
+ // Write compacted log: summary event + retained events
80
+ const summaryLine = JSON.stringify({
81
+ id: `compaction-${Date.now()}`,
82
+ seq: remove[remove.length - 1].seq,
83
+ type: "status",
84
+ from: "loadout",
85
+ to: "*",
86
+ description: `Compacted ${remove.length} events (seq ${summaryPayload.compactedRange.from}–${summaryPayload.compactedRange.to})`,
87
+ timestamp: new Date().toISOString(),
88
+ payload: summaryPayload,
89
+ });
90
+ const lines = [
91
+ summaryLine,
92
+ ...finalRetained.map((e) => JSON.stringify(e)),
93
+ ].join("\n");
94
+ // Atomic write: write to temp, rename over
95
+ const tmpPath = `${logPath}.tmp`;
96
+ await writeFile(tmpPath, lines + "\n", {
97
+ encoding: "utf-8",
98
+ mode: 0o600,
99
+ });
100
+ await rename(tmpPath, logPath);
101
+ // Log corrupt lines if any were found
102
+ if (log.corrupt.length > 0) {
103
+ const corruptPath = `${logPath}.${Date.now()}.corrupt`;
104
+ const corruptData = log.corrupt.map((c) => JSON.stringify(c)).join("\n");
105
+ await writeFile(corruptPath, corruptData + "\n", {
106
+ encoding: "utf-8",
107
+ mode: 0o600,
108
+ }).catch(() => { });
109
+ }
110
+ return {
111
+ before: log.events.length,
112
+ after: finalRetained.length + 1, // +1 for summary
113
+ removed: remove.length,
114
+ compacted: true,
115
+ };
116
+ });
117
+ }
118
+ export async function logSize(projectRoot) {
119
+ const logPath = join(projectRoot, COORD_DIR, COORD_LOG);
120
+ try {
121
+ const s = await stat(logPath);
122
+ const log = await readCoordLog(projectRoot);
123
+ return { events: log.events.length, bytes: s.size };
124
+ }
125
+ catch (error) {
126
+ if (typeof error === "object" &&
127
+ error !== null &&
128
+ "code" in error &&
129
+ error.code === "ENOENT") {
130
+ return { events: 0, bytes: 0 };
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+ /**
136
+ * Extract the latest state-bearing events from the removable set that are not
137
+ * already represented in the kept set. Ownership, contracts, and decisions
138
+ * define current system state — losing them during compaction would silently
139
+ * drop ownership claims, contract revisions, or active decisions.
140
+ */
141
+ function extractStateCheckpoints(removable, kept) {
142
+ const keptKeys = new Set();
143
+ for (const e of kept) {
144
+ const key = stateKey(e);
145
+ if (key)
146
+ keptKeys.add(key);
147
+ }
148
+ // Walk removable in reverse to find the *latest* event per state key.
149
+ const seen = new Set();
150
+ const checkpoints = [];
151
+ for (let i = removable.length - 1; i >= 0; i--) {
152
+ const e = removable[i];
153
+ const key = stateKey(e);
154
+ if (!key)
155
+ continue;
156
+ if (keptKeys.has(key) || seen.has(key))
157
+ continue;
158
+ seen.add(key);
159
+ checkpoints.push(e);
160
+ }
161
+ // Return in original chronological order.
162
+ return checkpoints.reverse();
163
+ }
164
+ function stateKey(event) {
165
+ if (!STATE_BEARING_TYPES.has(event.type) || !event.payload)
166
+ return undefined;
167
+ const p = event.payload;
168
+ switch (event.type) {
169
+ case "ownership":
170
+ // Key by agent + sorted paths — an ownership event replaces the previous
171
+ // one for the same agent/path combination.
172
+ return `ownership:${event.from}:${(p.paths ?? []).sort().join(",")}`;
173
+ case "contract":
174
+ return `contract:${p.name}`;
175
+ case "decision":
176
+ return `decision:${p.title}`;
177
+ default:
178
+ return undefined;
179
+ }
180
+ }
@@ -0,0 +1,21 @@
1
+ /** Composition boundary for optional provider-driven coordination. */
2
+ import { Codex } from "@openai/codex-sdk";
3
+ import { ClaudeCodeAdapter } from "./adapters/claude-code.js";
4
+ import { CodexAdapter } from "./adapters/codex.js";
5
+ /**
6
+ * Build adapters backed by the provider-supported interfaces shipped with
7
+ * Loadout: Claude Code print-mode CLI and the OpenAI Codex SDK.
8
+ */
9
+ export function createProviderAdapters() {
10
+ const codex = new Codex();
11
+ const codexDriver = {
12
+ runtimeVersion: "Codex SDK (bundled)",
13
+ startThread(options) {
14
+ return codex.startThread(options);
15
+ },
16
+ resumeThread(sessionId, options) {
17
+ return codex.resumeThread(sessionId, options);
18
+ },
19
+ };
20
+ return [new ClaudeCodeAdapter(), new CodexAdapter(codexDriver)];
21
+ }