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,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 };
|