@openfleet-tools/cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/core/hooks.json +46 -0
- package/core/plugin.json +9 -0
- package/core/skills/openfleet-contracts/SKILL.md +31 -0
- package/core/skills/openfleet-finalize/SKILL.md +53 -0
- package/core/skills/openfleet-grill/SKILL.md +48 -0
- package/core/skills/openfleet-orchestrate/SKILL.md +96 -0
- package/core/skills/openfleet-records/SKILL.md +73 -0
- package/core/skills/openfleet-spec/SKILL.md +65 -0
- package/core/templates/agents/openfleet-coordinator.agent.md +53 -0
- package/core/templates/agents/openfleet-implementer.agent.md +36 -0
- package/core/templates/agents/openfleet-planner.agent.md +56 -0
- package/core/templates/agents/openfleet-tester.agent.md +42 -0
- package/dist/attribution.d.ts +21 -0
- package/dist/attribution.js +72 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +134 -0
- package/dist/commands/extensions.d.ts +7 -0
- package/dist/commands/extensions.js +60 -0
- package/dist/commands/internal.d.ts +19 -0
- package/dist/commands/internal.js +117 -0
- package/dist/commands/output.d.ts +2 -0
- package/dist/commands/output.js +6 -0
- package/dist/commands/project.d.ts +23 -0
- package/dist/commands/project.js +207 -0
- package/dist/commands/workspace.d.ts +10 -0
- package/dist/commands/workspace.js +63 -0
- package/dist/core.d.ts +17 -0
- package/dist/core.js +139 -0
- package/dist/coreindex.d.ts +17 -0
- package/dist/coreindex.js +7 -0
- package/dist/events.d.ts +29 -0
- package/dist/events.js +115 -0
- package/dist/extensions.d.ts +71 -0
- package/dist/extensions.js +297 -0
- package/dist/fsutil.d.ts +12 -0
- package/dist/fsutil.js +70 -0
- package/dist/git.d.ts +27 -0
- package/dist/git.js +97 -0
- package/dist/hooks/handlers.d.ts +43 -0
- package/dist/hooks/handlers.js +363 -0
- package/dist/hooks/main.d.ts +2 -0
- package/dist/hooks/main.js +37 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/launcher.d.ts +24 -0
- package/dist/launcher.js +75 -0
- package/dist/lock.d.ts +6 -0
- package/dist/lock.js +45 -0
- package/dist/redact.d.ts +2 -0
- package/dist/redact.js +39 -0
- package/dist/repos.d.ts +28 -0
- package/dist/repos.js +10 -0
- package/dist/state.d.ts +68 -0
- package/dist/state.js +111 -0
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +74 -0
- package/package.json +47 -3
- package/README.md +0 -3
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { actorFor, bareAgentName, candidateProjects, locatePath, roleFromAgentName, roleOf } from "../attribution.js";
|
|
5
|
+
import { readCoreIndex } from "../coreindex.js";
|
|
6
|
+
import { appendEvent } from "../events.js";
|
|
7
|
+
import { filePatch, snapshotWorktree } from "../git.js";
|
|
8
|
+
import { readState, updateState } from "../state.js";
|
|
9
|
+
import { findWorkspace, projectPaths } from "../workspace.js";
|
|
10
|
+
/** Hooks are a no-op unless the session is inside (or launched for) an OpenFleet workspace. */
|
|
11
|
+
export function resolveContext(payload, env = process.env) {
|
|
12
|
+
const fromEnv = env.OPENFLEET_WORKSPACE ? findWorkspace(env.OPENFLEET_WORKSPACE) : undefined;
|
|
13
|
+
const ws = fromEnv ?? (payload.cwd ? findWorkspace(payload.cwd) : undefined);
|
|
14
|
+
if (!ws)
|
|
15
|
+
return undefined;
|
|
16
|
+
const project = env.OPENFLEET_PROJECT || undefined;
|
|
17
|
+
if (project && !fs.existsSync(projectPaths(ws, project).state))
|
|
18
|
+
return { ws };
|
|
19
|
+
return { ws, ...(project ? { project } : {}) };
|
|
20
|
+
}
|
|
21
|
+
function ts(payload) {
|
|
22
|
+
const t = payload.timestamp;
|
|
23
|
+
if (typeof t === "number")
|
|
24
|
+
return new Date(t).toISOString();
|
|
25
|
+
if (typeof t === "string" && !Number.isNaN(Date.parse(t)))
|
|
26
|
+
return new Date(t).toISOString();
|
|
27
|
+
return new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
/** Single active project: explicit, or the only non-finished project in the workspace. */
|
|
30
|
+
function activeProject(ctx) {
|
|
31
|
+
if (ctx.project)
|
|
32
|
+
return projectPaths(ctx.ws, ctx.project);
|
|
33
|
+
const live = candidateProjects(ctx.ws).filter((p) => {
|
|
34
|
+
try {
|
|
35
|
+
const s = readState(p).status;
|
|
36
|
+
return s !== "done" && s !== "cancelled";
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return live.length === 1 ? live[0] : undefined;
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------- session
|
|
45
|
+
export function onSessionStart(ctx, payload) {
|
|
46
|
+
const p = ctx.project ? projectPaths(ctx.ws, ctx.project) : undefined;
|
|
47
|
+
if (p) {
|
|
48
|
+
appendEvent(p, {
|
|
49
|
+
ts: ts(payload),
|
|
50
|
+
actor: "coordinator",
|
|
51
|
+
type: "session.started",
|
|
52
|
+
payload: { sessionId: payload.sessionId ?? null, source: payload.source ?? null },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const index = readCoreIndex(ctx.ws);
|
|
56
|
+
const lines = [
|
|
57
|
+
`OpenFleet workspace root: ${ctx.ws.root}`,
|
|
58
|
+
`OpenFleet data dir: ${ctx.ws.dir}`,
|
|
59
|
+
p ? `Active OpenFleet project: ${p.name} (${p.dir})` : "No OpenFleet project is active in this session.",
|
|
60
|
+
];
|
|
61
|
+
if (index) {
|
|
62
|
+
const slots = Object.entries(index.slots)
|
|
63
|
+
.filter(([, list]) => list.length)
|
|
64
|
+
.map(([slot, list]) => `${slot}: ${list.map((e) => e.agent).join(", ")}`);
|
|
65
|
+
lines.push(`Phase extensions — ${slots.length ? slots.join("; ") : "none"}.`);
|
|
66
|
+
}
|
|
67
|
+
return { additionalContext: lines.join("\n") };
|
|
68
|
+
}
|
|
69
|
+
export function onSessionEnd(ctx, payload) {
|
|
70
|
+
if (!ctx.project)
|
|
71
|
+
return {};
|
|
72
|
+
appendEvent(projectPaths(ctx.ws, ctx.project), {
|
|
73
|
+
ts: ts(payload),
|
|
74
|
+
actor: "coordinator",
|
|
75
|
+
type: "session.ended",
|
|
76
|
+
payload: { sessionId: payload.sessionId ?? null, reason: payload.reason ?? null },
|
|
77
|
+
});
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------- subagents
|
|
81
|
+
function extName(agentName) {
|
|
82
|
+
const bare = bareAgentName(agentName);
|
|
83
|
+
return bare?.startsWith("openfleet-ext-") ? bare.slice("openfleet-ext-".length) : undefined;
|
|
84
|
+
}
|
|
85
|
+
function pickPending(state, role) {
|
|
86
|
+
const ids = Object.entries(state.children)
|
|
87
|
+
.filter(([, c]) => c.pendingSpawn === role)
|
|
88
|
+
.map(([id]) => id);
|
|
89
|
+
return ids[0];
|
|
90
|
+
}
|
|
91
|
+
function childContext(p, childId, child, role) {
|
|
92
|
+
return [
|
|
93
|
+
`# OpenFleet assignment`,
|
|
94
|
+
`- project: ${p.name}`,
|
|
95
|
+
`- child: ${childId} (repo \`${child.repo}\`${child.package ? `, package \`${child.package}\`` : ""})`,
|
|
96
|
+
`- role: ${role}`,
|
|
97
|
+
child.worktree ? `- worktree (work ONLY here): ${child.worktree}` : `- worktree: not created yet`,
|
|
98
|
+
child.branch ? `- branch: ${child.branch}` : "",
|
|
99
|
+
`- spec: ${p.spec}`,
|
|
100
|
+
`- contracts: ${p.contracts}`,
|
|
101
|
+
child.plan ? `- approved plan: ${child.plan}` : "",
|
|
102
|
+
]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join("\n");
|
|
105
|
+
}
|
|
106
|
+
export function onSubagentStart(ctx, payload) {
|
|
107
|
+
const role = roleFromAgentName(payload.agentName);
|
|
108
|
+
const ext = extName(payload.agentName);
|
|
109
|
+
if (!role && !ext)
|
|
110
|
+
return {};
|
|
111
|
+
const p = activeProject(ctx);
|
|
112
|
+
if (!p)
|
|
113
|
+
return {};
|
|
114
|
+
if (ext) {
|
|
115
|
+
appendEvent(p, {
|
|
116
|
+
ts: ts(payload),
|
|
117
|
+
actor: `ext:${ext}`,
|
|
118
|
+
type: "agent.spawned",
|
|
119
|
+
payload: { agentName: payload.agentName ?? null },
|
|
120
|
+
});
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
if (role === "coordinator")
|
|
124
|
+
return {};
|
|
125
|
+
let childId;
|
|
126
|
+
const state = updateState(p, (s) => {
|
|
127
|
+
childId = pickPending(s, role);
|
|
128
|
+
if (!childId)
|
|
129
|
+
return;
|
|
130
|
+
const child = s.children[childId];
|
|
131
|
+
delete child.pendingSpawn;
|
|
132
|
+
child.role = role;
|
|
133
|
+
});
|
|
134
|
+
appendEvent(p, {
|
|
135
|
+
ts: ts(payload),
|
|
136
|
+
actor: actorFor(childId ?? "unknown", role),
|
|
137
|
+
type: "agent.spawned",
|
|
138
|
+
target: childId ?? null,
|
|
139
|
+
payload: { agentName: payload.agentName ?? null, agentDisplayName: payload.agentDisplayName ?? null },
|
|
140
|
+
});
|
|
141
|
+
if (!childId)
|
|
142
|
+
return {};
|
|
143
|
+
return { additionalContext: childContext(p, childId, state.children[childId], role) };
|
|
144
|
+
}
|
|
145
|
+
function snapshotChild(p, childId, child, actor, at) {
|
|
146
|
+
if (!child.worktree || !fs.existsSync(child.worktree))
|
|
147
|
+
return;
|
|
148
|
+
let snap;
|
|
149
|
+
try {
|
|
150
|
+
snap = snapshotWorktree(child.worktree, child.baseSha ?? "HEAD");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const hash = crypto.createHash("sha256").update(snap.patch).digest("hex");
|
|
156
|
+
if (hash === child.lastSnapshot)
|
|
157
|
+
return;
|
|
158
|
+
appendEvent(p, {
|
|
159
|
+
ts: at,
|
|
160
|
+
actor,
|
|
161
|
+
type: "worktree.snapshot",
|
|
162
|
+
target: childId,
|
|
163
|
+
payload: {
|
|
164
|
+
worktree: child.worktree,
|
|
165
|
+
base: child.baseSha ?? "HEAD",
|
|
166
|
+
files: snap.files,
|
|
167
|
+
totals: {
|
|
168
|
+
files: snap.files.length,
|
|
169
|
+
added: snap.files.reduce((n, f) => n + f.added, 0),
|
|
170
|
+
removed: snap.files.reduce((n, f) => n + f.removed, 0),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
patch: snap.patch,
|
|
174
|
+
});
|
|
175
|
+
updateState(p, (s) => {
|
|
176
|
+
const c = s.children[childId];
|
|
177
|
+
if (c)
|
|
178
|
+
c.lastSnapshot = hash;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
export function onSubagentStop(ctx, payload) {
|
|
182
|
+
const role = roleFromAgentName(payload.agentName);
|
|
183
|
+
const ext = extName(payload.agentName);
|
|
184
|
+
if (!role && !ext)
|
|
185
|
+
return {};
|
|
186
|
+
const p = activeProject(ctx);
|
|
187
|
+
if (!p)
|
|
188
|
+
return {};
|
|
189
|
+
const at = ts(payload);
|
|
190
|
+
if (ext) {
|
|
191
|
+
appendEvent(p, {
|
|
192
|
+
ts: at,
|
|
193
|
+
actor: `ext:${ext}`,
|
|
194
|
+
type: "agent.stopped",
|
|
195
|
+
payload: { agentId: payload.agentId ?? null, agentName: payload.agentName ?? null, response: payload.response ?? "" },
|
|
196
|
+
});
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
if (role === "coordinator")
|
|
200
|
+
return {};
|
|
201
|
+
const state = readState(p);
|
|
202
|
+
const entries = Object.entries(state.children);
|
|
203
|
+
let match = entries.find(([, c]) => payload.agentId && c.agentIds[role] === payload.agentId);
|
|
204
|
+
if (!match) {
|
|
205
|
+
const byRole = entries.filter(([, c]) => roleOf(c) === role && c.phase !== "done");
|
|
206
|
+
if (byRole.length === 1)
|
|
207
|
+
match = byRole[0];
|
|
208
|
+
}
|
|
209
|
+
const childId = match?.[0];
|
|
210
|
+
const actor = actorFor(childId ?? "unknown", role);
|
|
211
|
+
appendEvent(p, {
|
|
212
|
+
ts: at,
|
|
213
|
+
actor,
|
|
214
|
+
type: "agent.stopped",
|
|
215
|
+
target: childId ?? null,
|
|
216
|
+
payload: { agentId: payload.agentId ?? null, agentName: payload.agentName ?? null, response: payload.response ?? "" },
|
|
217
|
+
});
|
|
218
|
+
if (childId && payload.agentId && !match[1].agentIds[role]) {
|
|
219
|
+
updateState(p, (s) => {
|
|
220
|
+
const c = s.children[childId];
|
|
221
|
+
if (c)
|
|
222
|
+
c.agentIds[role] = payload.agentId;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// Ground truth: diff snapshot of the attributed worktree, or every worktree when unattributed.
|
|
226
|
+
const targets = childId ? [[childId, match[1]]] : entries;
|
|
227
|
+
for (const [id, child] of targets)
|
|
228
|
+
snapshotChild(p, id, child, childId ? actor : actorFor(id, roleOf(child)), at);
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
function countLines(s) {
|
|
232
|
+
if (typeof s !== "string" || s.length === 0)
|
|
233
|
+
return 0;
|
|
234
|
+
return s.split("\n").length - (s.endsWith("\n") ? 1 : 0);
|
|
235
|
+
}
|
|
236
|
+
function parseArgs(args) {
|
|
237
|
+
if (typeof args === "string") {
|
|
238
|
+
const t = args.trim();
|
|
239
|
+
if (t.startsWith("{")) {
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(t);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return args;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return args;
|
|
249
|
+
}
|
|
250
|
+
function parseApplyPatch(text) {
|
|
251
|
+
const out = [];
|
|
252
|
+
let cur;
|
|
253
|
+
for (const line of text.split("\n")) {
|
|
254
|
+
const m = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
|
|
255
|
+
if (m) {
|
|
256
|
+
cur = { path: m[2].trim(), kind: m[1] === "Add" ? "added" : m[1] === "Delete" ? "deleted" : "edited", added: 0, removed: 0 };
|
|
257
|
+
out.push(cur);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const move = /^\*\*\* Move to: (.+)$/.exec(line);
|
|
261
|
+
if (move && cur) {
|
|
262
|
+
out.push({ path: cur.path, kind: "deleted", added: 0, removed: 0 });
|
|
263
|
+
cur.path = move[1].trim();
|
|
264
|
+
cur.kind = "added";
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!cur || line.startsWith("***"))
|
|
268
|
+
continue;
|
|
269
|
+
if (line.startsWith("+"))
|
|
270
|
+
cur.added++;
|
|
271
|
+
else if (line.startsWith("-"))
|
|
272
|
+
cur.removed++;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/** Extract file changes from a postToolUse payload. Only file-mutating tools are recognised. */
|
|
277
|
+
export function fileChangesFromTool(toolName, rawArgs) {
|
|
278
|
+
const name = (toolName ?? "").toLowerCase();
|
|
279
|
+
const args = parseArgs(rawArgs);
|
|
280
|
+
const a = (typeof args === "object" && args !== null ? args : {});
|
|
281
|
+
const file = (a.path ?? a.file_path ?? a.filePath);
|
|
282
|
+
switch (name) {
|
|
283
|
+
case "create":
|
|
284
|
+
case "write":
|
|
285
|
+
return file ? [{ path: file, kind: "added", added: countLines(a.file_text ?? a.content), removed: 0 }] : [];
|
|
286
|
+
case "edit":
|
|
287
|
+
case "str_replace":
|
|
288
|
+
case "str_replace_editor":
|
|
289
|
+
case "multiedit":
|
|
290
|
+
case "multi_edit": {
|
|
291
|
+
if (!file)
|
|
292
|
+
return [];
|
|
293
|
+
if (a.command === "create")
|
|
294
|
+
return [{ path: file, kind: "added", added: countLines(a.file_text), removed: 0 }];
|
|
295
|
+
const edits = Array.isArray(a.edits) ? a.edits : [a];
|
|
296
|
+
let added = 0;
|
|
297
|
+
let removed = 0;
|
|
298
|
+
for (const e of edits) {
|
|
299
|
+
added += countLines(e.new_str ?? e.new_string ?? e.newString);
|
|
300
|
+
removed += countLines(e.old_str ?? e.old_string ?? e.oldString);
|
|
301
|
+
}
|
|
302
|
+
return [{ path: file, kind: "edited", added, removed }];
|
|
303
|
+
}
|
|
304
|
+
case "apply_patch": {
|
|
305
|
+
const text = typeof args === "string" ? args : (a.input ?? a.patch);
|
|
306
|
+
return typeof text === "string" ? parseApplyPatch(text) : [];
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
export function onPostToolUse(ctx, payload) {
|
|
313
|
+
const changes = fileChangesFromTool(payload.toolName, payload.toolArgs);
|
|
314
|
+
if (!changes.length)
|
|
315
|
+
return {};
|
|
316
|
+
const cwd = payload.cwd ?? ctx.ws.root;
|
|
317
|
+
for (const change of changes) {
|
|
318
|
+
const abs = path.resolve(cwd, change.path);
|
|
319
|
+
const loc = locatePath(ctx.ws, abs, ctx.project);
|
|
320
|
+
if (!loc)
|
|
321
|
+
continue;
|
|
322
|
+
const role = roleOf(loc.child);
|
|
323
|
+
let patch = "";
|
|
324
|
+
if (change.kind !== "deleted") {
|
|
325
|
+
try {
|
|
326
|
+
patch = filePatch(loc.child.worktree, loc.rel);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
patch = "";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
appendEvent(loc.project, {
|
|
333
|
+
ts: ts(payload),
|
|
334
|
+
actor: actorFor(loc.childId, role),
|
|
335
|
+
type: `file.${change.kind}`,
|
|
336
|
+
target: loc.childId,
|
|
337
|
+
payload: {
|
|
338
|
+
path: loc.rel.split(path.sep).join("/"),
|
|
339
|
+
kind: change.kind,
|
|
340
|
+
lines: { added: change.added, removed: change.removed },
|
|
341
|
+
tool: payload.toolName ?? null,
|
|
342
|
+
},
|
|
343
|
+
patch,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return {};
|
|
347
|
+
}
|
|
348
|
+
export const HANDLERS = {
|
|
349
|
+
sessionStart: onSessionStart,
|
|
350
|
+
sessionEnd: onSessionEnd,
|
|
351
|
+
subagentStart: onSubagentStart,
|
|
352
|
+
subagentStop: onSubagentStop,
|
|
353
|
+
postToolUse: onPostToolUse,
|
|
354
|
+
};
|
|
355
|
+
export function runHook(event, payload, env = process.env) {
|
|
356
|
+
const handler = HANDLERS[event];
|
|
357
|
+
if (!handler)
|
|
358
|
+
return {};
|
|
359
|
+
const ctx = resolveContext(payload, env);
|
|
360
|
+
if (!ctx)
|
|
361
|
+
return {};
|
|
362
|
+
return handler(ctx, payload);
|
|
363
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point invoked by core/hooks.json: `node lib/hooks/main.js <event>`.
|
|
3
|
+
// Never fails the Copilot session: errors are logged and the hook exits 0.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { resolveContext, runHook } from "./handlers.js";
|
|
7
|
+
function readStdin() {
|
|
8
|
+
try {
|
|
9
|
+
return fs.readFileSync(0, "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function main() {
|
|
16
|
+
const event = process.argv[2] ?? "";
|
|
17
|
+
let payload = {};
|
|
18
|
+
try {
|
|
19
|
+
const raw = readStdin();
|
|
20
|
+
payload = raw.trim() ? JSON.parse(raw) : {};
|
|
21
|
+
const out = runHook(event, payload);
|
|
22
|
+
if (out.additionalContext)
|
|
23
|
+
process.stdout.write(JSON.stringify(out));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
try {
|
|
27
|
+
const ctx = resolveContext(payload);
|
|
28
|
+
if (ctx) {
|
|
29
|
+
fs.appendFileSync(path.join(ctx.ws.dir, ".hook-errors.log"), `${new Date().toISOString()} ${event}: ${err.stack ?? String(err)}\n`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
main();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./workspace.js";
|
|
2
|
+
export * from "./state.js";
|
|
3
|
+
export * from "./events.js";
|
|
4
|
+
export * from "./repos.js";
|
|
5
|
+
export * from "./extensions.js";
|
|
6
|
+
export * from "./coreindex.js";
|
|
7
|
+
export { installCore, syncExtensions, packageVersion, installedCoreVersion } from "./core.js";
|
|
8
|
+
export { redact, redactString } from "./redact.js";
|
|
9
|
+
export { snapshotWorktree } from "./git.js";
|
|
10
|
+
export { initWorkspace } from "./commands/workspace.js";
|
|
11
|
+
export { createProject, findOverlaps } from "./commands/project.js";
|
|
12
|
+
export { run } from "./cli.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./workspace.js";
|
|
2
|
+
export * from "./state.js";
|
|
3
|
+
export * from "./events.js";
|
|
4
|
+
export * from "./repos.js";
|
|
5
|
+
export * from "./extensions.js";
|
|
6
|
+
export * from "./coreindex.js";
|
|
7
|
+
export { installCore, syncExtensions, packageVersion, installedCoreVersion } from "./core.js";
|
|
8
|
+
export { redact, redactString } from "./redact.js";
|
|
9
|
+
export { snapshotWorktree } from "./git.js";
|
|
10
|
+
export { initWorkspace } from "./commands/workspace.js";
|
|
11
|
+
export { createProject, findOverlaps } from "./commands/project.js";
|
|
12
|
+
export { run } from "./cli.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Workspace } from "./workspace.js";
|
|
2
|
+
export interface LaunchOptions {
|
|
3
|
+
ws: Workspace;
|
|
4
|
+
agent: string;
|
|
5
|
+
prompt?: string;
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
project?: string;
|
|
9
|
+
/** Extra args forwarded verbatim to the host CLI (after `--`). */
|
|
10
|
+
passthrough?: string[];
|
|
11
|
+
/** Keep Copilot's per-tool permission prompts (`--ask`). Default: auto-approve tools. */
|
|
12
|
+
ask?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Host CLI binary. Overridable for tests and for future hosts (e.g. `claude`). */
|
|
15
|
+
export declare function hostBinary(env?: NodeJS.ProcessEnv): string;
|
|
16
|
+
/**
|
|
17
|
+
* Directories the session's agents work in, passed as `--add-dir` so path verification stays on
|
|
18
|
+
* without prompting: the workspace root, the project's worktrees, cloned repos and known local repos.
|
|
19
|
+
*/
|
|
20
|
+
export declare function allowedDirs(ws: Workspace, project?: string): string[];
|
|
21
|
+
export declare function buildCopilotArgs(opts: LaunchOptions): string[];
|
|
22
|
+
export declare function launchEnv(opts: LaunchOptions, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
23
|
+
/** Launch Copilot CLI interactively in the workspace root. Returns its exit code. */
|
|
24
|
+
export declare function launch(opts: LaunchOptions): number;
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { qualifiedAgentName } from "./attribution.js";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { isDir, realish } from "./fsutil.js";
|
|
6
|
+
import { readRepos } from "./repos.js";
|
|
7
|
+
import { projectPaths, UserError } from "./workspace.js";
|
|
8
|
+
/** Host CLI binary. Overridable for tests and for future hosts (e.g. `claude`). */
|
|
9
|
+
export function hostBinary(env = process.env) {
|
|
10
|
+
return env.OPENFLEET_COPILOT_BIN || "copilot";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Directories the session's agents work in, passed as `--add-dir` so path verification stays on
|
|
14
|
+
* without prompting: the workspace root, the project's worktrees, cloned repos and known local repos.
|
|
15
|
+
*/
|
|
16
|
+
export function allowedDirs(ws, project) {
|
|
17
|
+
const dirs = new Set();
|
|
18
|
+
const add = (p) => {
|
|
19
|
+
if (!isDir(p))
|
|
20
|
+
return;
|
|
21
|
+
dirs.add(p);
|
|
22
|
+
dirs.add(realish(p));
|
|
23
|
+
};
|
|
24
|
+
add(ws.root);
|
|
25
|
+
if (project)
|
|
26
|
+
add(projectPaths(ws, project).worktrees);
|
|
27
|
+
add(path.join(ws.dir, "repos"));
|
|
28
|
+
for (const repo of Object.values(readRepos(ws).repos)) {
|
|
29
|
+
if (repo.path)
|
|
30
|
+
add(path.resolve(ws.root, repo.path));
|
|
31
|
+
}
|
|
32
|
+
return [...dirs];
|
|
33
|
+
}
|
|
34
|
+
export function buildCopilotArgs(opts) {
|
|
35
|
+
const args = ["--plugin-dir", opts.ws.core, "--agent", qualifiedAgentName(opts.agent)];
|
|
36
|
+
if (opts.sessionId)
|
|
37
|
+
args.push("--session-id", opts.sessionId);
|
|
38
|
+
if (opts.name)
|
|
39
|
+
args.push("--name", opts.name);
|
|
40
|
+
// Tools run without prompts; paths stay limited to allowedDirs and URLs still prompt.
|
|
41
|
+
if (!opts.ask)
|
|
42
|
+
args.push("--allow-all-tools");
|
|
43
|
+
for (const dir of allowedDirs(opts.ws, opts.project))
|
|
44
|
+
args.push("--add-dir", dir);
|
|
45
|
+
if (opts.prompt)
|
|
46
|
+
args.push("-i", opts.prompt);
|
|
47
|
+
args.push(...(opts.passthrough ?? []));
|
|
48
|
+
return args;
|
|
49
|
+
}
|
|
50
|
+
export function launchEnv(opts, env = process.env) {
|
|
51
|
+
return {
|
|
52
|
+
...env,
|
|
53
|
+
OPENFLEET_WORKSPACE: opts.ws.root,
|
|
54
|
+
...(opts.project ? { OPENFLEET_PROJECT: opts.project } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Launch Copilot CLI interactively in the workspace root. Returns its exit code. */
|
|
58
|
+
export function launch(opts) {
|
|
59
|
+
const bin = hostBinary();
|
|
60
|
+
// Create the worktrees dir up front so it can be passed to --add-dir before any worktree exists.
|
|
61
|
+
if (opts.project)
|
|
62
|
+
fs.mkdirSync(projectPaths(opts.ws, opts.project).worktrees, { recursive: true });
|
|
63
|
+
const res = spawnSync(bin, buildCopilotArgs(opts), {
|
|
64
|
+
cwd: opts.ws.root,
|
|
65
|
+
stdio: "inherit",
|
|
66
|
+
env: launchEnv(opts),
|
|
67
|
+
});
|
|
68
|
+
if (res.error) {
|
|
69
|
+
if (res.error.code === "ENOENT") {
|
|
70
|
+
throw new UserError(`Could not find \`${bin}\`. Install GitHub Copilot CLI (https://github.com/github/copilot-cli) and try again.`);
|
|
71
|
+
}
|
|
72
|
+
throw res.error;
|
|
73
|
+
}
|
|
74
|
+
return res.status ?? 1;
|
|
75
|
+
}
|
package/dist/lock.d.ts
ADDED
package/dist/lock.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
const STALE_MS = 10_000;
|
|
3
|
+
const TIMEOUT_MS = 15_000;
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Cross-process exclusive lock using an O_EXCL lock file. Synchronous so it
|
|
9
|
+
* can be used from short-lived hook processes. Stale locks (crashed holder)
|
|
10
|
+
* are broken after STALE_MS.
|
|
11
|
+
*/
|
|
12
|
+
export function withLock(lockFile, fn) {
|
|
13
|
+
const start = Date.now();
|
|
14
|
+
let fd;
|
|
15
|
+
while (fd === undefined) {
|
|
16
|
+
try {
|
|
17
|
+
fd = fs.openSync(lockFile, "wx");
|
|
18
|
+
fs.writeSync(fd, String(process.pid));
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err.code !== "EEXIST")
|
|
22
|
+
throw err;
|
|
23
|
+
try {
|
|
24
|
+
if (Date.now() - fs.statSync(lockFile).mtimeMs > STALE_MS) {
|
|
25
|
+
fs.rmSync(lockFile, { force: true });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (Date.now() - start > TIMEOUT_MS) {
|
|
33
|
+
throw new Error(`Timed out waiting for lock ${lockFile}`);
|
|
34
|
+
}
|
|
35
|
+
sleep(5 + Math.random() * 20);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return fn();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
fs.rmSync(lockFile, { force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/redact.d.ts
ADDED
package/dist/redact.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const REDACTED = "[REDACTED]";
|
|
2
|
+
const PATTERNS = [
|
|
3
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
4
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g,
|
|
5
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
6
|
+
/\bnpm_[A-Za-z0-9]{30,}\b/g,
|
|
7
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
8
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
9
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
10
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
11
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
12
|
+
];
|
|
13
|
+
// key=value / key: value style secrets; keeps the key, redacts the value.
|
|
14
|
+
const ASSIGNMENT = /\b([A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret)[A-Za-z0-9_.-]*)(["']?\s*[:=]\s*["']?)([^\s"',;]{4,})/gi;
|
|
15
|
+
const BEARER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g;
|
|
16
|
+
// user:pass@host in URLs
|
|
17
|
+
const URL_CREDS = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:/@]+):([^\s/@]+)@/gi;
|
|
18
|
+
export function redactString(input) {
|
|
19
|
+
let out = input;
|
|
20
|
+
for (const re of PATTERNS)
|
|
21
|
+
out = out.replace(re, REDACTED);
|
|
22
|
+
out = out.replace(ASSIGNMENT, (_m, key, sep) => `${key}${sep}${REDACTED}`);
|
|
23
|
+
out = out.replace(BEARER, (_m, scheme) => `${scheme} ${REDACTED}`);
|
|
24
|
+
out = out.replace(URL_CREDS, (_m, scheme, user) => `${scheme}${user}:${REDACTED}@`);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export function redact(value) {
|
|
28
|
+
if (typeof value === "string")
|
|
29
|
+
return redactString(value);
|
|
30
|
+
if (Array.isArray(value))
|
|
31
|
+
return value.map((v) => redact(v));
|
|
32
|
+
if (value && typeof value === "object") {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, v] of Object.entries(value))
|
|
35
|
+
out[k] = redact(v);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
package/dist/repos.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Workspace } from "./workspace.js";
|
|
2
|
+
export interface PackageEntry {
|
|
3
|
+
/** Path relative to the repo root. */
|
|
4
|
+
path: string;
|
|
5
|
+
build?: string;
|
|
6
|
+
test?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RepoEntry {
|
|
9
|
+
/** Absolute or workspace-relative path to a local checkout. */
|
|
10
|
+
path?: string;
|
|
11
|
+
/** Git URL to clone when no local checkout exists. */
|
|
12
|
+
url?: string;
|
|
13
|
+
defaultBranch?: string;
|
|
14
|
+
build?: string;
|
|
15
|
+
test?: string;
|
|
16
|
+
/** Monorepo packages, keyed by package name. */
|
|
17
|
+
packages?: Record<string, PackageEntry>;
|
|
18
|
+
description?: string;
|
|
19
|
+
/** Build/test commands were confirmed by the user. */
|
|
20
|
+
confirmed?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface ReposFile {
|
|
23
|
+
v: 1;
|
|
24
|
+
repos: Record<string, RepoEntry>;
|
|
25
|
+
}
|
|
26
|
+
export declare function emptyRepos(): ReposFile;
|
|
27
|
+
export declare function readRepos(ws: Workspace): ReposFile;
|
|
28
|
+
export declare function writeRepos(ws: Workspace, repos: ReposFile): void;
|
package/dist/repos.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { readJsonOr, writeJson } from "./fsutil.js";
|
|
2
|
+
export function emptyRepos() {
|
|
3
|
+
return { v: 1, repos: {} };
|
|
4
|
+
}
|
|
5
|
+
export function readRepos(ws) {
|
|
6
|
+
return readJsonOr(ws.reposFile, emptyRepos());
|
|
7
|
+
}
|
|
8
|
+
export function writeRepos(ws, repos) {
|
|
9
|
+
writeJson(ws.reposFile, repos);
|
|
10
|
+
}
|