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,161 @@
1
+ import { z } from "zod";
2
+ // ---------------------------------------------------------------------------
3
+ // Event type enum — superset of handoff message types
4
+ // ---------------------------------------------------------------------------
5
+ export const COORDINATION_EVENT_TYPES = [
6
+ // Original handoff types
7
+ "task",
8
+ "handoff",
9
+ "question",
10
+ "done",
11
+ "status",
12
+ "error",
13
+ "cancel",
14
+ // Live collaboration types (Phase 1)
15
+ "contract",
16
+ "ownership",
17
+ "decision",
18
+ "update",
19
+ "ack",
20
+ "discussion",
21
+ ];
22
+ // ---------------------------------------------------------------------------
23
+ // Typed payloads for collaboration events
24
+ // ---------------------------------------------------------------------------
25
+ export const contractPayloadSchema = z.object({
26
+ /** A stable name for this contract, e.g. "auth-api" or "db-schema". */
27
+ name: z.string().trim().min(1).max(200),
28
+ /** Monotonically increasing version for this contract name. */
29
+ revision: z.number().int().positive(),
30
+ /** The contract body — OpenAPI fragment, TypeScript types, SQL, etc. */
31
+ body: z.string().max(100_000),
32
+ /** MIME-ish hint: "typescript", "openapi-yaml", "sql", "json-schema". */
33
+ format: z.string().trim().min(1).max(50).optional(),
34
+ });
35
+ export const ownershipPayloadSchema = z.object({
36
+ /** File or directory paths this agent claims. Relative to project root. */
37
+ paths: z.array(z.string().trim().min(1).max(1_024)).min(1).max(256),
38
+ /** "exclusive" blocks other agents; "shared" allows concurrent reads. */
39
+ mode: z.enum(["exclusive", "shared", "release"]),
40
+ /** Optional reason for the claim. */
41
+ reason: z.string().max(500).optional(),
42
+ });
43
+ export const decisionPayloadSchema = z.object({
44
+ /** Short title: "Use Zod for validation", "REST over GraphQL". */
45
+ title: z.string().trim().min(1).max(200),
46
+ /** The rationale. */
47
+ rationale: z.string().max(10_000),
48
+ /** ID of the decision this supersedes, if any. */
49
+ supersedes: z.string().trim().min(1).max(128).optional(),
50
+ /** Discussion thread that produced this decision, when applicable. */
51
+ discussionId: z.string().trim().min(1).max(128).optional(),
52
+ });
53
+ export const updatePayloadSchema = z.object({
54
+ /** Files touched since last update. */
55
+ files: z.array(z.string().max(1_024)).max(256).optional(),
56
+ /** Commands run and their outcomes. */
57
+ commands: z
58
+ .array(z.object({
59
+ command: z.string().max(500),
60
+ exitCode: z.number().int().optional(),
61
+ summary: z.string().max(1000).optional(),
62
+ }))
63
+ .max(20)
64
+ .optional(),
65
+ /** Free-form progress note. */
66
+ note: z.string().max(5000).optional(),
67
+ /** Blockers preventing further progress. */
68
+ blockers: z.array(z.string().max(500)).max(10).optional(),
69
+ /** What this agent plans to do next. */
70
+ next: z.string().max(1000).optional(),
71
+ });
72
+ export const ackPayloadSchema = z.object({
73
+ /** Sequence number of the event being acknowledged. */
74
+ eventSeq: z.number().int().nonnegative(),
75
+ /** Optional note about what the agent did with this event. */
76
+ note: z.string().max(1000).optional(),
77
+ });
78
+ export const discussionPayloadSchema = z
79
+ .object({
80
+ /** Stable ID shared by every event in one bounded design discussion. */
81
+ threadId: z.string().trim().min(1).max(128),
82
+ /** The semantic place of this statement in the discussion. */
83
+ kind: z.enum([
84
+ "started",
85
+ "proposal",
86
+ "critique",
87
+ "revision",
88
+ "summary",
89
+ "closed",
90
+ ]),
91
+ /** Zero for lifecycle events, otherwise the user-visible round number. */
92
+ round: z.number().int().min(0).max(8),
93
+ /** The participant function represented by this event. */
94
+ role: z.enum(["system", "proposer", "reviewer"]),
95
+ /** Explicit public output. Hidden provider reasoning is never requested. */
96
+ content: z.string().trim().min(1).max(16_384),
97
+ /** Present on the opening event so state can be reconstructed from JSONL. */
98
+ participants: z
99
+ .tuple([
100
+ z.string().trim().min(1).max(128),
101
+ z.string().trim().min(1).max(128),
102
+ ])
103
+ .optional(),
104
+ /** Event ID this statement directly answers. */
105
+ replyTo: z.string().trim().min(1).max(128).optional(),
106
+ /** Alternatives preserved by the final lifecycle event. */
107
+ alternatives: z
108
+ .array(z.string().trim().min(1).max(2_000))
109
+ .max(10)
110
+ .optional(),
111
+ /** Disagreements or evidence gaps the final decision does not conceal. */
112
+ unresolved: z.array(z.string().trim().min(1).max(2_000)).max(10).optional(),
113
+ /** Set on the terminal event so failures cannot be mistaken for decisions. */
114
+ outcome: z.enum(["decided", "failed"]).optional(),
115
+ })
116
+ .strict();
117
+ // ---------------------------------------------------------------------------
118
+ // Coordination event — extends HandoffMessage with seq + typed payload
119
+ // ---------------------------------------------------------------------------
120
+ export const coordinationEventSchema = z.object({
121
+ id: z.string().trim().min(1).max(128),
122
+ seq: z.number().int().nonnegative(),
123
+ type: z.enum(COORDINATION_EVENT_TYPES),
124
+ from: z.string().trim().min(1).max(128),
125
+ to: z.string().trim().min(1).max(128),
126
+ description: z.string().trim().min(1).max(8_192),
127
+ context: z.string().max(65_536).optional(),
128
+ timestamp: z.iso.datetime({ offset: true }),
129
+ resolves: z.string().trim().min(1).max(128).optional(),
130
+ /** Typed payload — shape depends on `type`. */
131
+ payload: z.record(z.string(), z.unknown()).optional(),
132
+ });
133
+ /** Validate the typed payload matches the event type. */
134
+ export function validatePayload(type, payload) {
135
+ const schemas = {
136
+ contract: contractPayloadSchema,
137
+ ownership: ownershipPayloadSchema,
138
+ decision: decisionPayloadSchema,
139
+ update: updatePayloadSchema,
140
+ ack: ackPayloadSchema,
141
+ discussion: discussionPayloadSchema,
142
+ };
143
+ const schema = schemas[type];
144
+ if (!schema) {
145
+ // Original handoff types don't require typed payloads
146
+ return { success: true, data: payload };
147
+ }
148
+ if (!payload) {
149
+ return { success: false, error: `Event type '${type}' requires a payload` };
150
+ }
151
+ const result = schema.safeParse(payload);
152
+ if (!result.success) {
153
+ return {
154
+ success: false,
155
+ error: result.error.issues
156
+ .map((i) => `${i.path.join(".") || "payload"}: ${i.message}`)
157
+ .join("; "),
158
+ };
159
+ }
160
+ return { success: true, data: result.data };
161
+ }
@@ -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
+ }
@@ -0,0 +1,118 @@
1
+ import { isLoopbackHostname } from "./auth.js";
2
+ export const MAX_BODY_BYTES = 1024 * 1024;
3
+ export class HttpError extends Error {
4
+ status;
5
+ code;
6
+ constructor(status, code, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.name = "HttpError";
11
+ }
12
+ }
13
+ function parseAuthority(value) {
14
+ try {
15
+ return new URL(`http://${value}`);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function validateRequestSource(req) {
22
+ const host = req.headers.host;
23
+ const hostUrl = host ? parseAuthority(host) : null;
24
+ if (!hostUrl || !isLoopbackHostname(hostUrl.hostname)) {
25
+ throw new HttpError(403, "forbidden_host", "Host is not loopback");
26
+ }
27
+ const origin = req.headers.origin;
28
+ if (!origin)
29
+ return;
30
+ let originUrl;
31
+ try {
32
+ originUrl = new URL(origin);
33
+ }
34
+ catch {
35
+ throw new HttpError(403, "forbidden_origin", "Origin is not same-origin");
36
+ }
37
+ if (!isLoopbackHostname(originUrl.hostname) ||
38
+ originUrl.host.toLowerCase() !== hostUrl.host.toLowerCase() ||
39
+ originUrl.protocol !== "http:" ||
40
+ originUrl.origin !== origin) {
41
+ throw new HttpError(403, "forbidden_origin", "Origin is not same-origin");
42
+ }
43
+ }
44
+ function matchingOrigin(req) {
45
+ const host = req.headers.host;
46
+ const origin = req.headers.origin;
47
+ const hostUrl = host ? parseAuthority(host) : null;
48
+ if (!hostUrl || !origin)
49
+ return undefined;
50
+ try {
51
+ const originUrl = new URL(origin);
52
+ return originUrl.protocol === "http:" &&
53
+ isLoopbackHostname(originUrl.hostname) &&
54
+ originUrl.host.toLowerCase() === hostUrl.host.toLowerCase() &&
55
+ originUrl.origin === origin
56
+ ? origin
57
+ : undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ export function setResponseHeaders(req, res) {
64
+ res.setHeader("Cache-Control", "no-store");
65
+ res.setHeader("X-Content-Type-Options", "nosniff");
66
+ const origin = matchingOrigin(req);
67
+ if (origin) {
68
+ res.setHeader("Access-Control-Allow-Origin", origin);
69
+ res.setHeader("Vary", "Origin");
70
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
71
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
72
+ }
73
+ }
74
+ export function sendJson(req, res, data, status = 200) {
75
+ setResponseHeaders(req, res);
76
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
77
+ res.end(JSON.stringify(data));
78
+ }
79
+ export function sendError(req, res, code, message, status) {
80
+ sendJson(req, res, { error: { code, message } }, status);
81
+ }
82
+ export function setEventStreamHeaders(req, res) {
83
+ setResponseHeaders(req, res);
84
+ res.writeHead(200, {
85
+ "Content-Type": "text/event-stream",
86
+ "Cache-Control": "no-cache, no-store",
87
+ Connection: "keep-alive",
88
+ "X-Accel-Buffering": "no",
89
+ });
90
+ }
91
+ export async function readJsonBody(req) {
92
+ const contentLength = Number(req.headers["content-length"]);
93
+ if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
94
+ throw new HttpError(413, "payload_too_large", "Request body too large");
95
+ }
96
+ const chunks = [];
97
+ let size = 0;
98
+ let tooLarge = false;
99
+ for await (const chunk of req) {
100
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
101
+ size += buffer.length;
102
+ if (size > MAX_BODY_BYTES) {
103
+ tooLarge = true;
104
+ }
105
+ else if (!tooLarge) {
106
+ chunks.push(buffer);
107
+ }
108
+ }
109
+ if (tooLarge) {
110
+ throw new HttpError(413, "payload_too_large", "Request body too large");
111
+ }
112
+ try {
113
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
114
+ }
115
+ catch {
116
+ throw new HttpError(400, "invalid_json", "Request body must be valid JSON");
117
+ }
118
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Interrupt policy for coordination events.
3
+ *
4
+ * Determines whether an incoming event should interrupt an active agent
5
+ * session immediately, queue for the next safe boundary, or be silently
6
+ * recorded for later consumption.
7
+ *
8
+ * Policies are configurable per project via .handoff/policy.json.
9
+ */
10
+ import { readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { z } from "zod";
13
+ const DEFAULT_POLICY = {
14
+ defaultLevel: "boundary",
15
+ rules: [
16
+ // Ownership conflicts should interrupt immediately
17
+ { type: "ownership", from: "*", level: "immediate" },
18
+ // Contract changes interrupt at boundary
19
+ { type: "contract", from: "*", level: "boundary" },
20
+ // Decisions interrupt at boundary
21
+ { type: "decision", from: "*", level: "boundary" },
22
+ // Public design-room turns route sequentially at safe boundaries
23
+ { type: "discussion", from: "*", level: "boundary" },
24
+ // Progress updates are passive
25
+ { type: "update", from: "*", level: "passive" },
26
+ // Acks are always passive
27
+ { type: "ack", from: "*", level: "passive" },
28
+ // Tasks should interrupt at boundary
29
+ { type: "task", from: "*", level: "boundary" },
30
+ // Errors interrupt immediately
31
+ { type: "error", from: "*", level: "immediate" },
32
+ // Done events at boundary
33
+ { type: "done", from: "*", level: "boundary" },
34
+ ],
35
+ };
36
+ const policySchema = z
37
+ .object({
38
+ defaultLevel: z.enum(["immediate", "boundary", "passive"]).optional(),
39
+ rules: z
40
+ .array(z
41
+ .object({
42
+ type: z.string().trim().min(1).max(128),
43
+ from: z.string().trim().min(1).max(128),
44
+ level: z.enum(["immediate", "boundary", "passive"]),
45
+ })
46
+ .strict())
47
+ .max(100)
48
+ .optional(),
49
+ })
50
+ .strict();
51
+ export function evaluatePolicy(event, policy = DEFAULT_POLICY) {
52
+ for (const rule of policy.rules) {
53
+ const typeMatch = rule.type === "*" || rule.type === event.type;
54
+ const fromMatch = rule.from === "*" || rule.from === event.from;
55
+ if (typeMatch && fromMatch) {
56
+ return rule.level;
57
+ }
58
+ }
59
+ return policy.defaultLevel;
60
+ }
61
+ export function categorizeEvents(events, policy = DEFAULT_POLICY) {
62
+ const result = {
63
+ immediate: [],
64
+ boundary: [],
65
+ passive: [],
66
+ };
67
+ for (const event of events) {
68
+ const level = evaluatePolicy(event, policy);
69
+ result[level].push(event);
70
+ }
71
+ return result;
72
+ }
73
+ export async function loadPolicy(projectRoot) {
74
+ try {
75
+ const raw = await readFile(join(projectRoot, ".handoff", "policy.json"), "utf-8");
76
+ const parsed = policySchema.safeParse(JSON.parse(raw));
77
+ if (!parsed.success)
78
+ return DEFAULT_POLICY;
79
+ const custom = parsed.data;
80
+ return {
81
+ defaultLevel: custom.defaultLevel ?? DEFAULT_POLICY.defaultLevel,
82
+ rules: custom.rules ?? DEFAULT_POLICY.rules,
83
+ };
84
+ }
85
+ catch {
86
+ return DEFAULT_POLICY;
87
+ }
88
+ }
89
+ export { DEFAULT_POLICY };