@tablation/crew 0.63.2 → 0.65.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/README.md +1 -1
- package/dist/cli.js +373 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -263,7 +263,7 @@ crew inbox [--member NAME] your tickets across every workspace
|
|
|
263
263
|
crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
|
|
264
264
|
crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
|
|
265
265
|
crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
|
|
266
|
-
crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo writes them, --json)
|
|
266
|
+
crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo writes them, --recipes, --agent, --json)
|
|
267
267
|
crew install [--replace] write and load this platform's scheduler unit (macOS: clears a crew installed from another path)
|
|
268
268
|
crew uninstall [--all] unload and remove it (--all: every crew launchd unit, whichever path it came from)
|
|
269
269
|
```
|
package/dist/cli.js
CHANGED
|
@@ -5209,6 +5209,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
5209
5209
|
"repo",
|
|
5210
5210
|
"role",
|
|
5211
5211
|
"route",
|
|
5212
|
+
"recipes",
|
|
5212
5213
|
"ship-name",
|
|
5213
5214
|
"tail",
|
|
5214
5215
|
"ticket",
|
|
@@ -13026,6 +13027,62 @@ function configuredRepos(text, route, base) {
|
|
|
13026
13027
|
const dir = m.get("dir");
|
|
13027
13028
|
return dir ? [{ name: basename7(String(dir)), dir: expandDir(String(dir), base) }] : [];
|
|
13028
13029
|
}
|
|
13030
|
+
function routeSetsHooks(text, route, name) {
|
|
13031
|
+
const m = routeMapOf(parseDocument(text), route);
|
|
13032
|
+
const repos = m.get("repos", true);
|
|
13033
|
+
let target = m;
|
|
13034
|
+
if (isMap(repos)) target = repos.get(name, true);
|
|
13035
|
+
if (!isMap(target)) return false;
|
|
13036
|
+
const hooks = target.get("hooks", true);
|
|
13037
|
+
return isMap(hooks) && hooks.items.length > 0;
|
|
13038
|
+
}
|
|
13039
|
+
function listRepoRows(i) {
|
|
13040
|
+
const checkouts = configuredRepos(i.text, i.route, i.base);
|
|
13041
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
13042
|
+
const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
|
|
13043
|
+
const originFor = (c, remote) => {
|
|
13044
|
+
const o = i.originOf(c.dir) ?? null;
|
|
13045
|
+
if (!o) return null;
|
|
13046
|
+
return remote !== null && normalizeRemote(o) === normalizeRemote(remote) ? null : o;
|
|
13047
|
+
};
|
|
13048
|
+
const rows = [];
|
|
13049
|
+
const names = i.resolved?.repoNames ?? {};
|
|
13050
|
+
const remotes = i.resolved?.repoRemotes ?? {};
|
|
13051
|
+
for (const [id, name] of Object.entries(names)) {
|
|
13052
|
+
const remote = remotes[id] ?? null;
|
|
13053
|
+
const want = normalizeRemote(remote);
|
|
13054
|
+
let idx = checkouts.findIndex((c2, n) => !claimed.has(n) && want !== void 0 && normalizeRemote(i.originOf(c2.dir)) === want);
|
|
13055
|
+
if (idx < 0) idx = checkouts.findIndex((c2, n) => !claimed.has(n) && c2.name === name);
|
|
13056
|
+
const c = idx >= 0 ? checkouts[idx] : void 0;
|
|
13057
|
+
if (idx >= 0) claimed.add(idx);
|
|
13058
|
+
rows.push({
|
|
13059
|
+
name,
|
|
13060
|
+
remote,
|
|
13061
|
+
repoRowId: id,
|
|
13062
|
+
attached: c !== void 0,
|
|
13063
|
+
dir: c?.dir ?? null,
|
|
13064
|
+
exists: c ? i.exists(c.dir) : false,
|
|
13065
|
+
origin: c ? originFor(c, remote) : null,
|
|
13066
|
+
matchedRow: c ? name : null,
|
|
13067
|
+
hooksSource: c ? hooksSourceOf(c) : "none"
|
|
13068
|
+
});
|
|
13069
|
+
}
|
|
13070
|
+
checkouts.forEach((c, n) => {
|
|
13071
|
+
if (claimed.has(n)) return;
|
|
13072
|
+
rows.push({
|
|
13073
|
+
name: c.name,
|
|
13074
|
+
remote: null,
|
|
13075
|
+
repoRowId: null,
|
|
13076
|
+
attached: true,
|
|
13077
|
+
dir: c.dir,
|
|
13078
|
+
exists: i.exists(c.dir),
|
|
13079
|
+
origin: originFor(c, null),
|
|
13080
|
+
matchedRow: null,
|
|
13081
|
+
hooksSource: hooksSourceOf(c)
|
|
13082
|
+
});
|
|
13083
|
+
});
|
|
13084
|
+
return rows;
|
|
13085
|
+
}
|
|
13029
13086
|
function planRepoAdd(i) {
|
|
13030
13087
|
const doc = parseDocument(i.text);
|
|
13031
13088
|
if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
|
|
@@ -13204,17 +13261,62 @@ var DETECTORS = [
|
|
|
13204
13261
|
{ source: "pyproject.toml", detect: fromPyproject },
|
|
13205
13262
|
{ source: "Makefile", detect: fromMakefile }
|
|
13206
13263
|
];
|
|
13264
|
+
var WORKFLOW_RE = /\.ya?ml$/;
|
|
13265
|
+
function detectRecipes(f) {
|
|
13266
|
+
const workflows = (f.list?.(".github/workflows") ?? []).filter((n) => WORKFLOW_RE.test(n)).sort();
|
|
13267
|
+
if (workflows.length === 0) return [];
|
|
13268
|
+
const evidence = workflows.map((n) => `.github/workflows/${n}`);
|
|
13269
|
+
return [
|
|
13270
|
+
{
|
|
13271
|
+
id: "github-actions-release",
|
|
13272
|
+
title: "Releases run in GitHub Actions",
|
|
13273
|
+
description: "CI publishes the release after the crew merges; the crew watches `gh release view` for the live tag.",
|
|
13274
|
+
evidence,
|
|
13275
|
+
// `ltrimstr` because `verify.match: version` compares the bare version, not `v1.2.3`.
|
|
13276
|
+
hooks: { released: `gh release view --json tagName -q '.tagName | ltrimstr("v")'` },
|
|
13277
|
+
release: { mode: "ci_auto", ci: "github", verifyMatch: "version" },
|
|
13278
|
+
writable: true,
|
|
13279
|
+
defaultOn: true
|
|
13280
|
+
},
|
|
13281
|
+
{
|
|
13282
|
+
id: "github-actions-ci-status",
|
|
13283
|
+
title: "Check CI status before hand-off",
|
|
13284
|
+
description: "Ask GitHub whether the latest run on the branch passed before a ticket is handed to QA. The repo spec has no hook for this yet, so it is reported but cannot be written.",
|
|
13285
|
+
evidence,
|
|
13286
|
+
hooks: {},
|
|
13287
|
+
check: `gh run list --branch "$CREW_BRANCH" --limit 1 --json conclusion -q '.[0].conclusion == "success"'`,
|
|
13288
|
+
writable: false,
|
|
13289
|
+
defaultOn: false
|
|
13290
|
+
}
|
|
13291
|
+
];
|
|
13292
|
+
}
|
|
13207
13293
|
function suggestHooks(f) {
|
|
13294
|
+
const recipes = detectRecipes(f);
|
|
13208
13295
|
const found = DETECTORS.flatMap((d) => {
|
|
13209
13296
|
const hooks = d.detect(f);
|
|
13210
13297
|
return hooks && Object.keys(hooks).length > 0 ? [{ source: d.source, hooks }] : [];
|
|
13211
13298
|
});
|
|
13212
13299
|
if (found.length === 0) {
|
|
13213
|
-
return { source: null, hooks: {}, notes: ["no package.json scripts, Package.swift, pyproject.toml or Makefile test/build targets found \u2014 nothing to propose"] };
|
|
13300
|
+
return { source: null, hooks: {}, notes: ["no package.json scripts, Package.swift, pyproject.toml or Makefile test/build targets found \u2014 nothing to propose"], recipes };
|
|
13214
13301
|
}
|
|
13215
13302
|
const [first, ...others] = found;
|
|
13216
13303
|
const notes = others.length > 0 ? [`also found ${others.map((o) => o.source).join(", ")}; proposing from ${first.source} only`] : [];
|
|
13217
|
-
return { source: first.source, hooks: first.hooks, notes };
|
|
13304
|
+
return { source: first.source, hooks: first.hooks, notes, recipes };
|
|
13305
|
+
}
|
|
13306
|
+
function selectRecipes(s, ids) {
|
|
13307
|
+
const chosen = ids.map((id) => {
|
|
13308
|
+
const r = s.recipes.find((x) => x.id === id);
|
|
13309
|
+
if (!r) throw new ReposError(`no recipe "${id}" here (have: ${s.recipes.map((x) => x.id).join(", ") || "none"})`);
|
|
13310
|
+
if (!r.writable) throw new ReposError(`recipe "${id}" cannot be written: ${r.description}`);
|
|
13311
|
+
return r;
|
|
13312
|
+
});
|
|
13313
|
+
let hooks = { ...s.hooks };
|
|
13314
|
+
let release;
|
|
13315
|
+
for (const r of chosen) {
|
|
13316
|
+
hooks = { ...r.hooks, ...hooks };
|
|
13317
|
+
release ??= r.release;
|
|
13318
|
+
}
|
|
13319
|
+
return { hooks, ...release ? { release } : {} };
|
|
13218
13320
|
}
|
|
13219
13321
|
function parseHooksArgs(args, routes) {
|
|
13220
13322
|
const rest = [...args];
|
|
@@ -13224,7 +13326,7 @@ function parseHooksArgs(args, routes) {
|
|
|
13224
13326
|
if (routes.length === 1) route = routes[0];
|
|
13225
13327
|
else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
|
|
13226
13328
|
}
|
|
13227
|
-
if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--write route|repo] [--json]");
|
|
13329
|
+
if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--json]");
|
|
13228
13330
|
return { route, name: rest[0] };
|
|
13229
13331
|
}
|
|
13230
13332
|
function repoDirOf(text, base, route, name) {
|
|
@@ -13270,7 +13372,7 @@ function planRouteHooks(text, route, name, hooks) {
|
|
|
13270
13372
|
}
|
|
13271
13373
|
return { text: String(doc), written, kept };
|
|
13272
13374
|
}
|
|
13273
|
-
function starterRepoConfig(hooks) {
|
|
13375
|
+
function starterRepoConfig(hooks, release) {
|
|
13274
13376
|
const lines = [
|
|
13275
13377
|
"# Starter .crew.yaml proposed by `crew repos hooks --write repo`.",
|
|
13276
13378
|
"# Review the hooks, then commit this file; it is the durable home for them",
|
|
@@ -13283,7 +13385,12 @@ function starterRepoConfig(hooks) {
|
|
|
13283
13385
|
const entries = Object.entries(hooks).filter(([, v]) => v !== void 0);
|
|
13284
13386
|
if (entries.length === 0) lines.push(' # test: "..."', ' # build: "..."');
|
|
13285
13387
|
for (const [k, v] of entries) lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
13286
|
-
|
|
13388
|
+
if (release) {
|
|
13389
|
+
lines.push("", "release:", ` mode: ${release.mode}`);
|
|
13390
|
+
if (release.ci) lines.push(` ci: { provider: ${release.ci} }`);
|
|
13391
|
+
if (release.verifyMatch) lines.push(" verify:", ` match: ${release.verifyMatch}`);
|
|
13392
|
+
lines.push("");
|
|
13393
|
+
} else lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
|
|
13287
13394
|
const text = lines.join("\n");
|
|
13288
13395
|
parseRepoConfig(text, ".crew.yaml");
|
|
13289
13396
|
return text;
|
|
@@ -13292,6 +13399,178 @@ function existingRepoConfig(f) {
|
|
|
13292
13399
|
return REPO_CONFIG_FILENAMES.find((n) => f.exists(n));
|
|
13293
13400
|
}
|
|
13294
13401
|
|
|
13402
|
+
// src/repos-hooks-agent.ts
|
|
13403
|
+
import { spawn as spawn4, execFileSync as execFileSync4 } from "child_process";
|
|
13404
|
+
import { realpathSync as realpathSync2 } from "fs";
|
|
13405
|
+
import { isAbsolute as isAbsolute2, relative } from "path";
|
|
13406
|
+
var AgentSuggestError = class extends Error {
|
|
13407
|
+
reason;
|
|
13408
|
+
constructor(reason, message) {
|
|
13409
|
+
super(message);
|
|
13410
|
+
this.reason = reason;
|
|
13411
|
+
}
|
|
13412
|
+
};
|
|
13413
|
+
var HOOK_KEYS = ["test", "build", "version", "bump", "released"];
|
|
13414
|
+
var READ_ONLY_TOOLS = ["Read", "Grep", "Glob"];
|
|
13415
|
+
var WRITING_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "WebFetch", "WebSearch", "Task", "Agent"];
|
|
13416
|
+
var DEFAULT_TIMEOUT_MS = 18e4;
|
|
13417
|
+
function buildAgentPrompt(sniff) {
|
|
13418
|
+
return [
|
|
13419
|
+
"You are reviewing a git checkout (the current directory) to work out how to test and build it.",
|
|
13420
|
+
"Read README*, CLAUDE.md, AGENTS.md, GEMINI.md, package/build manifests and .github/workflows/* as needed.",
|
|
13421
|
+
"You may only read files. Do not modify anything and do not run commands.",
|
|
13422
|
+
"",
|
|
13423
|
+
`A file sniff already proposed: ${JSON.stringify(sniff.hooks)}.`,
|
|
13424
|
+
"Propose the commands a contributor should run, using the repo's own documented ones over guesses.",
|
|
13425
|
+
"Omit any hook you cannot support from a file you read.",
|
|
13426
|
+
"",
|
|
13427
|
+
"Answer with ONLY one JSON object, no prose and no code fence:",
|
|
13428
|
+
'{"hooks": {"test": "...", "build": "...", "version": "...", "bump": "...", "released": "..."}, "notes": ["..."]}',
|
|
13429
|
+
"`version` prints the bare version, `bump` bumps by $CREW_BUMP (patch|minor|major) and prints the new version,",
|
|
13430
|
+
"`released` prints what is live (a version or sha). Keys you cannot support are left out."
|
|
13431
|
+
].join("\n");
|
|
13432
|
+
}
|
|
13433
|
+
function agentInvocation(spec, model) {
|
|
13434
|
+
if (spec.harness !== "claude") {
|
|
13435
|
+
throw new AgentSuggestError("unsupported_harness", `--agent supports the claude harness only (toolchain "${spec.name}" is ${spec.harness})`);
|
|
13436
|
+
}
|
|
13437
|
+
return wrapInvocation(spec, {
|
|
13438
|
+
model,
|
|
13439
|
+
args: [
|
|
13440
|
+
"-p",
|
|
13441
|
+
"--allowedTools",
|
|
13442
|
+
...READ_ONLY_TOOLS,
|
|
13443
|
+
"--disallowedTools",
|
|
13444
|
+
...WRITING_TOOLS,
|
|
13445
|
+
"--model",
|
|
13446
|
+
model,
|
|
13447
|
+
"--output-format",
|
|
13448
|
+
"stream-json",
|
|
13449
|
+
"--verbose",
|
|
13450
|
+
...spec.args
|
|
13451
|
+
]
|
|
13452
|
+
});
|
|
13453
|
+
}
|
|
13454
|
+
function parseAgentStream(out, dir) {
|
|
13455
|
+
let text = "";
|
|
13456
|
+
let isError = false;
|
|
13457
|
+
const files = /* @__PURE__ */ new Set();
|
|
13458
|
+
let base = dir;
|
|
13459
|
+
try {
|
|
13460
|
+
base = realpathSync2(dir);
|
|
13461
|
+
} catch {
|
|
13462
|
+
}
|
|
13463
|
+
for (const line of out.split("\n")) {
|
|
13464
|
+
if (!line.trim()) continue;
|
|
13465
|
+
let ev;
|
|
13466
|
+
try {
|
|
13467
|
+
ev = JSON.parse(line);
|
|
13468
|
+
} catch {
|
|
13469
|
+
continue;
|
|
13470
|
+
}
|
|
13471
|
+
if (ev?.type === "assistant" && Array.isArray(ev.message?.content)) {
|
|
13472
|
+
for (const b of ev.message.content) {
|
|
13473
|
+
const p = b?.type === "tool_use" && b.name === "Read" ? b.input?.file_path : void 0;
|
|
13474
|
+
if (typeof p === "string") files.add(isAbsolute2(p) ? relative(base, p) : p);
|
|
13475
|
+
}
|
|
13476
|
+
} else if (ev?.type === "result") {
|
|
13477
|
+
text = typeof ev.result === "string" ? ev.result : "";
|
|
13478
|
+
isError = ev.is_error === true;
|
|
13479
|
+
}
|
|
13480
|
+
}
|
|
13481
|
+
return { text, isError, filesRead: [...files].sort() };
|
|
13482
|
+
}
|
|
13483
|
+
function parseAgentHooks(text) {
|
|
13484
|
+
const start = text.indexOf("{");
|
|
13485
|
+
const end = text.lastIndexOf("}");
|
|
13486
|
+
if (start < 0 || end <= start) throw new AgentSuggestError("bad_output", "the toolchain did not answer with a JSON object");
|
|
13487
|
+
let obj;
|
|
13488
|
+
try {
|
|
13489
|
+
obj = JSON.parse(text.slice(start, end + 1));
|
|
13490
|
+
} catch {
|
|
13491
|
+
throw new AgentSuggestError("bad_output", "the toolchain's answer was not valid JSON");
|
|
13492
|
+
}
|
|
13493
|
+
const raw = obj && typeof obj === "object" && obj.hooks && typeof obj.hooks === "object" ? obj.hooks : {};
|
|
13494
|
+
const hooks = {};
|
|
13495
|
+
for (const k of HOOK_KEYS) if (typeof raw[k] === "string" && raw[k].trim()) hooks[k] = raw[k].trim();
|
|
13496
|
+
const notes = Array.isArray(obj.notes) ? obj.notes.filter((n) => typeof n === "string") : [];
|
|
13497
|
+
return { hooks, notes };
|
|
13498
|
+
}
|
|
13499
|
+
var SIGNED_OUT = /not logged in|\/login|invalid api key|authentication|unauthori[sz]ed|\b401\b|credit balance/i;
|
|
13500
|
+
function classifyFailure(o) {
|
|
13501
|
+
if (o.spawnError?.code === "ENOENT") return "not_installed";
|
|
13502
|
+
return SIGNED_OUT.test(o.output) ? "not_signed_in" : "failed";
|
|
13503
|
+
}
|
|
13504
|
+
function porcelain(dir) {
|
|
13505
|
+
try {
|
|
13506
|
+
return execFileSync4("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: dir, encoding: "utf8" });
|
|
13507
|
+
} catch {
|
|
13508
|
+
return void 0;
|
|
13509
|
+
}
|
|
13510
|
+
}
|
|
13511
|
+
async function runAgentSuggest(o) {
|
|
13512
|
+
const model = o.model ?? o.spec.model;
|
|
13513
|
+
const { bin, args } = agentInvocation(o.spec, model);
|
|
13514
|
+
const adapter = adapterFor(o.spec.harness);
|
|
13515
|
+
const env = { ...process.env, ...adapter.runEnv(o.spec) };
|
|
13516
|
+
for (const v of adapter.unsetEnv) delete env[v];
|
|
13517
|
+
delete env.CREW_API_KEY;
|
|
13518
|
+
const before = porcelain(o.dir);
|
|
13519
|
+
const timeout = AbortSignal.timeout(o.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
13520
|
+
const signal = o.signal ? AbortSignal.any([o.signal, timeout]) : timeout;
|
|
13521
|
+
const run2 = await new Promise((resolve6) => {
|
|
13522
|
+
let out = "";
|
|
13523
|
+
let err = "";
|
|
13524
|
+
const child = spawn4(bin, args, { cwd: o.dir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
13525
|
+
let settled = false;
|
|
13526
|
+
const settle = (r) => {
|
|
13527
|
+
if (settled) return;
|
|
13528
|
+
settled = true;
|
|
13529
|
+
signal.removeEventListener("abort", onAbort);
|
|
13530
|
+
resolve6(r);
|
|
13531
|
+
};
|
|
13532
|
+
const onAbort = () => {
|
|
13533
|
+
try {
|
|
13534
|
+
if (child.pid) process.kill(-child.pid, "SIGTERM");
|
|
13535
|
+
} catch {
|
|
13536
|
+
child.kill("SIGTERM");
|
|
13537
|
+
}
|
|
13538
|
+
child.stdout.destroy();
|
|
13539
|
+
child.stderr.destroy();
|
|
13540
|
+
settle({ code: null, out, err, aborted: true });
|
|
13541
|
+
};
|
|
13542
|
+
if (signal.aborted) onAbort();
|
|
13543
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
13544
|
+
child.stdout.on("data", (d) => {
|
|
13545
|
+
out += d.toString();
|
|
13546
|
+
});
|
|
13547
|
+
child.stderr.on("data", (d) => {
|
|
13548
|
+
err += d.toString();
|
|
13549
|
+
});
|
|
13550
|
+
child.on("error", (e) => settle({ code: null, out, err, spawnError: e, aborted: false }));
|
|
13551
|
+
child.on("close", (code) => settle({ code, out, err, aborted: false }));
|
|
13552
|
+
child.stdin.on("error", () => {
|
|
13553
|
+
});
|
|
13554
|
+
child.stdin.end(buildAgentPrompt(o.sniff));
|
|
13555
|
+
});
|
|
13556
|
+
if (run2.aborted || signal.aborted) {
|
|
13557
|
+
throw new AgentSuggestError(o.signal?.aborted ? "cancelled" : "timeout", o.signal?.aborted ? "cancelled" : "the toolchain did not answer in time");
|
|
13558
|
+
}
|
|
13559
|
+
const after = porcelain(o.dir);
|
|
13560
|
+
if (before !== void 0 && after !== before) {
|
|
13561
|
+
throw new AgentSuggestError("modified_checkout", "the checkout changed during --agent; inspect it with `git status` (the agent result was discarded)");
|
|
13562
|
+
}
|
|
13563
|
+
const parsed = parseAgentStream(run2.out, o.dir);
|
|
13564
|
+
if (run2.spawnError || run2.code !== 0 || parsed.isError) {
|
|
13565
|
+
const reason = classifyFailure({ code: run2.code, ...run2.spawnError ? { spawnError: run2.spawnError } : {}, output: `${parsed.text}
|
|
13566
|
+
${run2.err}` });
|
|
13567
|
+
throw new AgentSuggestError(reason, reason === "not_installed" ? `${bin} is not installed` : reason === "not_signed_in" ? `${o.spec.name} is not signed in` : (parsed.text || run2.err).trim().slice(0, 300) || `exited ${run2.code}`);
|
|
13568
|
+
}
|
|
13569
|
+
const { hooks, notes } = parseAgentHooks(parsed.text);
|
|
13570
|
+
const differsFromSniff = HOOK_KEYS.filter((k) => hooks[k] !== void 0 && hooks[k] !== o.sniff.hooks[k]);
|
|
13571
|
+
return { source: "agent", hooks, filesRead: parsed.filesRead, differsFromSniff, notes, toolchain: o.spec.name, model };
|
|
13572
|
+
}
|
|
13573
|
+
|
|
13295
13574
|
// src/sample-ticket.ts
|
|
13296
13575
|
import { basename as basename8, dirname as dirname15, join as join23 } from "path";
|
|
13297
13576
|
var SAMPLE_REPO = "crew-sample";
|
|
@@ -13719,12 +13998,12 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
|
13719
13998
|
}
|
|
13720
13999
|
|
|
13721
14000
|
// src/cli.ts
|
|
13722
|
-
import { realpathSync as
|
|
14001
|
+
import { realpathSync as realpathSync3, readFileSync as readFileSync25, existsSync as existsSync31, mkdirSync as mkdirSync18, writeFileSync as writeFileSync19, rmSync as rmSync12, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync10, readdirSync as readdirSync12 } from "fs";
|
|
13723
14002
|
import { createInterface } from "readline/promises";
|
|
13724
14003
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
13725
14004
|
|
|
13726
14005
|
// src/ssh-keys.ts
|
|
13727
|
-
import { execFileSync as
|
|
14006
|
+
import { execFileSync as execFileSync5 } from "child_process";
|
|
13728
14007
|
import { existsSync as existsSync25, mkdirSync as mkdirSync13, readFileSync as readFileSync19, chmodSync } from "fs";
|
|
13729
14008
|
import { dirname as dirname17, join as join26 } from "path";
|
|
13730
14009
|
var SshKeyError = class extends Error {
|
|
@@ -13738,7 +14017,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
13738
14017
|
if (!existsSync25(privateKeyPath)) {
|
|
13739
14018
|
mkdirSync13(dirname17(privateKeyPath), { recursive: true });
|
|
13740
14019
|
try {
|
|
13741
|
-
|
|
14020
|
+
execFileSync5("ssh-keygen", [
|
|
13742
14021
|
"-t",
|
|
13743
14022
|
"ed25519",
|
|
13744
14023
|
"-N",
|
|
@@ -13764,7 +14043,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
13764
14043
|
}
|
|
13765
14044
|
function sshKeygenAvailable() {
|
|
13766
14045
|
try {
|
|
13767
|
-
|
|
14046
|
+
execFileSync5("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
13768
14047
|
return true;
|
|
13769
14048
|
} catch (e) {
|
|
13770
14049
|
return e.code !== "ENOENT";
|
|
@@ -13772,7 +14051,7 @@ function sshKeygenAvailable() {
|
|
|
13772
14051
|
}
|
|
13773
14052
|
|
|
13774
14053
|
// src/machine-id.ts
|
|
13775
|
-
import { execFileSync as
|
|
14054
|
+
import { execFileSync as execFileSync6 } from "child_process";
|
|
13776
14055
|
import { createHash as createHash4 } from "crypto";
|
|
13777
14056
|
import { existsSync as existsSync26, mkdirSync as mkdirSync14, readFileSync as readFileSync20, writeFileSync as writeFileSync15 } from "fs";
|
|
13778
14057
|
import { dirname as dirname18, join as join27 } from "path";
|
|
@@ -13783,7 +14062,7 @@ var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
|
|
|
13783
14062
|
function readRawMachineId(platform2 = process.platform) {
|
|
13784
14063
|
try {
|
|
13785
14064
|
if (platform2 === "darwin") {
|
|
13786
|
-
const out =
|
|
14065
|
+
const out = execFileSync6("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
13787
14066
|
encoding: "utf8",
|
|
13788
14067
|
stdio: ["ignore", "pipe", "ignore"]
|
|
13789
14068
|
});
|
|
@@ -13822,11 +14101,11 @@ function matchShipRows(ships, shipName2, machineId) {
|
|
|
13822
14101
|
}
|
|
13823
14102
|
|
|
13824
14103
|
// src/passenger-containers.ts
|
|
13825
|
-
import { execFileSync as
|
|
14104
|
+
import { execFileSync as execFileSync7 } from "child_process";
|
|
13826
14105
|
import { createHash as createHash5 } from "crypto";
|
|
13827
14106
|
var realExec = (cmd, args) => {
|
|
13828
14107
|
try {
|
|
13829
|
-
return
|
|
14108
|
+
return execFileSync7(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
13830
14109
|
} catch (e) {
|
|
13831
14110
|
const err = e;
|
|
13832
14111
|
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
@@ -14024,12 +14303,12 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
14024
14303
|
}
|
|
14025
14304
|
|
|
14026
14305
|
// src/tunnel.ts
|
|
14027
|
-
import { spawn as
|
|
14306
|
+
import { spawn as spawn5 } from "child_process";
|
|
14028
14307
|
import { existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync16, unlinkSync as unlinkSync3, readdirSync as readdirSync8 } from "fs";
|
|
14029
14308
|
import { randomInt } from "crypto";
|
|
14030
14309
|
import { join as join28 } from "path";
|
|
14031
14310
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
14032
|
-
var defaultSpawn = (cmd, args) =>
|
|
14311
|
+
var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
|
|
14033
14312
|
var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
14034
14313
|
var SLUG_LENGTH = 12;
|
|
14035
14314
|
function generateTunnelSlug() {
|
|
@@ -14325,7 +14604,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
14325
14604
|
|
|
14326
14605
|
// src/passenger-sync-daemon.ts
|
|
14327
14606
|
import { createServer as createHttpServer } from "http";
|
|
14328
|
-
import { spawn as
|
|
14607
|
+
import { spawn as spawn6 } from "child_process";
|
|
14329
14608
|
import { randomBytes } from "crypto";
|
|
14330
14609
|
import { existsSync as existsSync28, mkdirSync as mkdirSync16, readFileSync as readFileSync22, writeFileSync as writeFileSync17, unlinkSync as unlinkSync4, readdirSync as readdirSync9 } from "fs";
|
|
14331
14610
|
import { join as join29 } from "path";
|
|
@@ -14412,7 +14691,7 @@ function runSyncDaemonFromEnv(env = process.env) {
|
|
|
14412
14691
|
return server;
|
|
14413
14692
|
}
|
|
14414
14693
|
var defaultSpawn2 = (cmd, args, env) => {
|
|
14415
|
-
const child =
|
|
14694
|
+
const child = spawn6(cmd, args, { detached: true, stdio: "ignore", env });
|
|
14416
14695
|
child.unref();
|
|
14417
14696
|
return child;
|
|
14418
14697
|
};
|
|
@@ -14732,7 +15011,7 @@ async function runOnePassUnguarded(o) {
|
|
|
14732
15011
|
await hydrateApiKeys([o.route], o.getSessionStore);
|
|
14733
15012
|
const emit = o.newEmitter();
|
|
14734
15013
|
const decide = o.decide ?? decideCycle;
|
|
14735
|
-
const
|
|
15014
|
+
const spawn7 = o.spawnRoleAgent ?? runRoleAgent;
|
|
14736
15015
|
const breaker = o.spawnBreaker ?? new SpawnBreaker();
|
|
14737
15016
|
const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
|
|
14738
15017
|
try {
|
|
@@ -14793,7 +15072,7 @@ async function runOnePassUnguarded(o) {
|
|
|
14793
15072
|
continue;
|
|
14794
15073
|
}
|
|
14795
15074
|
started.push(role);
|
|
14796
|
-
const run2 =
|
|
15075
|
+
const run2 = spawn7(role, decision, ctx).then(() => {
|
|
14797
15076
|
breaker.recordSuccess(role);
|
|
14798
15077
|
clearShipAttention(o.state, `role_parked:${role}`);
|
|
14799
15078
|
}).catch(async (e) => {
|
|
@@ -15192,8 +15471,8 @@ function usage() {
|
|
|
15192
15471
|
crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
|
|
15193
15472
|
crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
|
|
15194
15473
|
crew repos clone [ROUTE] NAME clone a Repos row's remote into the route directory and attach it (--dir PATH, --dry-run, --json)
|
|
15195
|
-
crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo, --json)
|
|
15196
|
-
crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
|
|
15474
|
+
crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--agent, --write route|repo, --recipes, --json)
|
|
15475
|
+
crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches (--json: every Repos row too)
|
|
15197
15476
|
crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
|
|
15198
15477
|
crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
|
|
15199
15478
|
crew passengers [route] sync Host Passengers containers/tunnels once; what the passengers unit invokes
|
|
@@ -16121,10 +16400,12 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16121
16400
|
lock?.release();
|
|
16122
16401
|
lock = void 0;
|
|
16123
16402
|
};
|
|
16403
|
+
let sigintOwnedByCommand = false;
|
|
16124
16404
|
if (!dryRun) {
|
|
16125
16405
|
process.on("exit", dropLock);
|
|
16126
16406
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
16127
16407
|
process.on(sig, () => {
|
|
16408
|
+
if (sig === "SIGINT" && sigintOwnedByCommand) return;
|
|
16128
16409
|
dropLock();
|
|
16129
16410
|
process.exit(0);
|
|
16130
16411
|
});
|
|
@@ -16879,7 +17160,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16879
17160
|
case "repos": {
|
|
16880
17161
|
const sub = positional[1];
|
|
16881
17162
|
const usage2 = () => {
|
|
16882
|
-
process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos clone [workspace/project] <name> [--dir PATH] [--dry-run] [--json]\ncrew repos list <workspace/project
|
|
17163
|
+
process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos clone [workspace/project] <name> [--dir PATH] [--dry-run] [--json]\ncrew repos list <workspace/project> [--json]\ncrew repos hooks [workspace/project] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--json]\n");
|
|
16883
17164
|
process.exit(2);
|
|
16884
17165
|
};
|
|
16885
17166
|
if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks") usage2();
|
|
@@ -16889,7 +17170,13 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16889
17170
|
const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
|
|
16890
17171
|
const dir = repoDirOf(configText, dirname19(cfg.configFile), a.route, a.name);
|
|
16891
17172
|
if (!existsSync31(dir)) throw new ReposError(`${dir} does not exist on disk`);
|
|
16892
|
-
const files = { exists: (r) => existsSync31(resolvePath(dir, r)),
|
|
17173
|
+
const files = { exists: (r) => existsSync31(resolvePath(dir, r)), list: (r) => {
|
|
17174
|
+
try {
|
|
17175
|
+
return readdirSync12(resolvePath(dir, r));
|
|
17176
|
+
} catch {
|
|
17177
|
+
return [];
|
|
17178
|
+
}
|
|
17179
|
+
}, read: (r) => {
|
|
16893
17180
|
try {
|
|
16894
17181
|
return readFileSync25(resolvePath(dir, r), "utf8");
|
|
16895
17182
|
} catch {
|
|
@@ -16899,21 +17186,43 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16899
17186
|
const suggestion = suggestHooks(files);
|
|
16900
17187
|
const write = value("write");
|
|
16901
17188
|
if (write !== void 0 && write !== "route" && write !== "repo") throw new ReposError(`--write takes "route" or "repo" (got "${write}")`);
|
|
17189
|
+
const recipeIds = (value("recipes") ?? "").split(",").map((x) => x.trim()).filter((x) => x && x !== "none");
|
|
17190
|
+
if (recipeIds.length > 0 && !write) throw new ReposError("--recipes only applies with --write route|repo");
|
|
17191
|
+
if (flag("agent") && write) throw new ReposError("--agent is read-only; drop --write (write the sniffed hooks, then re-run --agent to compare)");
|
|
17192
|
+
let agent = null;
|
|
17193
|
+
let agentError = null;
|
|
17194
|
+
if (flag("agent")) {
|
|
17195
|
+
const ac = new AbortController();
|
|
17196
|
+
const onSigint = () => ac.abort();
|
|
17197
|
+
process.once("SIGINT", onSigint);
|
|
17198
|
+
sigintOwnedByCommand = true;
|
|
17199
|
+
try {
|
|
17200
|
+
const routeCfg = cfg.routes.find((r) => r.route === a.route);
|
|
17201
|
+
agent = await runAgentSuggest({ spec: resolveToolchain(cfg.ship, routeCfg, a.name), dir, sniff: suggestion, signal: ac.signal });
|
|
17202
|
+
} catch (e) {
|
|
17203
|
+
if (!(e instanceof AgentSuggestError)) throw e;
|
|
17204
|
+
agentError = { reason: e.reason, message: e.message };
|
|
17205
|
+
} finally {
|
|
17206
|
+
process.off("SIGINT", onSigint);
|
|
17207
|
+
sigintOwnedByCommand = false;
|
|
17208
|
+
}
|
|
17209
|
+
}
|
|
17210
|
+
const picked = selectRecipes(suggestion, recipeIds);
|
|
16902
17211
|
let written;
|
|
16903
|
-
if (write && suggestion.source === null) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
|
|
17212
|
+
if (write && suggestion.source === null && recipeIds.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
|
|
16904
17213
|
if (write === "route") {
|
|
16905
|
-
const plan = planRouteHooks(configText, a.route, a.name,
|
|
17214
|
+
const plan = planRouteHooks(configText, a.route, a.name, picked.hooks);
|
|
16906
17215
|
writeFileSync19(cfg.configFile, plan.text);
|
|
16907
17216
|
written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept };
|
|
16908
17217
|
} else if (write === "repo") {
|
|
16909
17218
|
const have = existingRepoConfig(files);
|
|
16910
17219
|
if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
|
|
16911
17220
|
const file = resolvePath(dir, ".crew.yaml");
|
|
16912
|
-
writeFileSync19(file, starterRepoConfig(
|
|
16913
|
-
written = { target: "repo", file, hooks: Object.keys(
|
|
17221
|
+
writeFileSync19(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
|
|
17222
|
+
written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [] };
|
|
16914
17223
|
}
|
|
16915
17224
|
if (flag("json")) {
|
|
16916
|
-
process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, written: written ?? null })}
|
|
17225
|
+
process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, agent, agentError, written: written ?? null })}
|
|
16917
17226
|
`);
|
|
16918
17227
|
} else {
|
|
16919
17228
|
if (suggestion.source) process.stdout.write(`${a.name}: proposed from ${suggestion.source}
|
|
@@ -16921,6 +17230,18 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16921
17230
|
for (const [k, v] of Object.entries(suggestion.hooks)) process.stdout.write(` ${k}: ${v}
|
|
16922
17231
|
`);
|
|
16923
17232
|
for (const n of suggestion.notes) process.stdout.write(`note: ${n}
|
|
17233
|
+
`);
|
|
17234
|
+
for (const r of suggestion.recipes) process.stdout.write(`recipe ${r.id}${r.defaultOn ? " (default on)" : ""}${r.writable ? "" : " (advisory)"}: ${r.title} [${r.evidence.join(", ")}]
|
|
17235
|
+
`);
|
|
17236
|
+
if (agent) {
|
|
17237
|
+
process.stdout.write(`agent (${agent.toolchain}, ${agent.model}) read ${agent.filesRead.join(", ") || "no files"}
|
|
17238
|
+
`);
|
|
17239
|
+
for (const [k, v] of Object.entries(agent.hooks)) process.stdout.write(` ${k}: ${v}${agent.differsFromSniff.includes(k) ? " (differs from file sniff)" : ""}
|
|
17240
|
+
`);
|
|
17241
|
+
for (const n of agent.notes) process.stdout.write(`note: ${n}
|
|
17242
|
+
`);
|
|
17243
|
+
}
|
|
17244
|
+
if (agentError) process.stderr.write(`crew repos: --agent failed (${agentError.reason}): ${agentError.message}
|
|
16924
17245
|
`);
|
|
16925
17246
|
if (written) {
|
|
16926
17247
|
process.stdout.write(`wrote ${written.hooks.join(", ") || "nothing new"} to ${written.file}
|
|
@@ -16928,8 +17249,11 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16928
17249
|
if (written.kept.length) process.stdout.write(`kept existing route hooks: ${written.kept.join(", ")}
|
|
16929
17250
|
`);
|
|
16930
17251
|
if (written.target === "repo") process.stdout.write("review it and commit it to the repository\n");
|
|
17252
|
+
if (written.target === "route" && picked.release) process.stdout.write(`note: release.mode ${picked.release.mode} is repo-level; use --write repo to set it
|
|
17253
|
+
`);
|
|
16931
17254
|
}
|
|
16932
17255
|
}
|
|
17256
|
+
if (agentError) process.exitCode = 1;
|
|
16933
17257
|
} catch (e) {
|
|
16934
17258
|
if (e instanceof ReposError) {
|
|
16935
17259
|
process.stderr.write(`crew repos: ${e.message}
|
|
@@ -16986,6 +17310,26 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16986
17310
|
const base = dirname19(cfg.configFile);
|
|
16987
17311
|
try {
|
|
16988
17312
|
if (sub === "list") {
|
|
17313
|
+
if (flag("json")) {
|
|
17314
|
+
const rows = listRepoRows({
|
|
17315
|
+
text,
|
|
17316
|
+
route: routeName,
|
|
17317
|
+
base,
|
|
17318
|
+
resolved: resolvedRemotes,
|
|
17319
|
+
originOf,
|
|
17320
|
+
exists: (d) => existsSync31(d),
|
|
17321
|
+
repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
|
|
17322
|
+
try {
|
|
17323
|
+
return Object.keys(parseRepoConfig(readFileSync25(resolvePath(d, f), "utf8"), f).hooks).length > 0;
|
|
17324
|
+
} catch {
|
|
17325
|
+
return false;
|
|
17326
|
+
}
|
|
17327
|
+
})
|
|
17328
|
+
});
|
|
17329
|
+
process.stdout.write(`${JSON.stringify({ route: routeName, repos: rows })}
|
|
17330
|
+
`);
|
|
17331
|
+
break;
|
|
17332
|
+
}
|
|
16989
17333
|
const repos = configuredRepos(text, routeName, base);
|
|
16990
17334
|
if (repos.length === 0) process.stdout.write(`route ${routeName} has no checkout configured \u2014 \`crew repos add ${routeName} <path>\`
|
|
16991
17335
|
`);
|
|
@@ -18239,7 +18583,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
18239
18583
|
},
|
|
18240
18584
|
inspectCheckout(dir) {
|
|
18241
18585
|
const dirty = status(dir).map((c) => c.path);
|
|
18242
|
-
const linkedWorktrees = worktrees(dir).map((w) => w.path).filter((p) =>
|
|
18586
|
+
const linkedWorktrees = worktrees(dir).map((w) => w.path).filter((p) => realpathSync3(p) !== realpathSync3(dir));
|
|
18243
18587
|
return { dirty, linkedWorktrees };
|
|
18244
18588
|
}
|
|
18245
18589
|
};
|
package/package.json
CHANGED