@tablation/crew 0.1.7 → 0.3.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 +232 -48
- package/package.json +1 -1
- package/prompts/default/personas/common.md +15 -0
- package/prompts/default/personas/lane-qa.md +6 -0
- package/prompts/dev-qa/personas/common.md +15 -0
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ crew pause|resume [route] [R] pause everything, or one role
|
|
|
211
211
|
crew log [route] tail the log
|
|
212
212
|
crew inbox [--member NAME] your tickets across every workspace
|
|
213
213
|
crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
|
|
214
|
-
crew repos add ROUTE PATH
|
|
214
|
+
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)
|
|
215
215
|
crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
|
|
216
216
|
crew install write and load this platform's scheduler unit
|
|
217
217
|
crew uninstall unload and remove it
|
package/dist/cli.js
CHANGED
|
@@ -2746,7 +2746,7 @@ function qaDigest(i) {
|
|
|
2746
2746
|
"## Current queue \u2014 built for you by the poll\n",
|
|
2747
2747
|
'\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
|
|
2748
2748
|
'\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
|
|
2749
|
-
"\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **MISSING** means the branch is gone
|
|
2749
|
+
"\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
|
|
2750
2750
|
"\n### Still in verification \u2014 yours, unfinished (take these first)\n",
|
|
2751
2751
|
table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
|
|
2752
2752
|
"\n### Awaiting verification, in pick order\n",
|
|
@@ -2813,6 +2813,13 @@ function branches(cwd, pattern) {
|
|
|
2813
2813
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2814
2814
|
}
|
|
2815
2815
|
function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2816
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2817
|
+
const found = branches(cwd, c);
|
|
2818
|
+
if (found.length) return found[0];
|
|
2819
|
+
}
|
|
2820
|
+
return null;
|
|
2821
|
+
}
|
|
2822
|
+
function branchCandidates(issueKey, templates, render2) {
|
|
2816
2823
|
const num = issueKey.replace(/^\D+/, "");
|
|
2817
2824
|
const unpadded = String(Number.parseInt(num, 10));
|
|
2818
2825
|
const candidates = [];
|
|
@@ -2827,12 +2834,31 @@ function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, r
|
|
|
2827
2834
|
}
|
|
2828
2835
|
candidates.push(`issue-${num}`);
|
|
2829
2836
|
if (num !== unpadded) candidates.push(`issue-${unpadded}`);
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2837
|
+
return [...new Set(candidates)];
|
|
2838
|
+
}
|
|
2839
|
+
function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2840
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
2841
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2842
|
+
const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
|
|
2843
|
+
const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
|
|
2844
|
+
if (hit) return hit.slice(prefix.length);
|
|
2833
2845
|
}
|
|
2834
2846
|
return null;
|
|
2835
2847
|
}
|
|
2848
|
+
function materializeRemoteBranch(cwd, remote, branch) {
|
|
2849
|
+
if (!remoteBranchExists(cwd, remote, branch)) {
|
|
2850
|
+
return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
|
|
2851
|
+
}
|
|
2852
|
+
if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
|
|
2853
|
+
return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
|
|
2854
|
+
}
|
|
2855
|
+
gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
|
|
2856
|
+
return true;
|
|
2857
|
+
}
|
|
2858
|
+
function deleteRemoteBranch(cwd, remote, branch) {
|
|
2859
|
+
if (!remoteBranchExists(cwd, remote, branch)) return true;
|
|
2860
|
+
return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
|
|
2861
|
+
}
|
|
2836
2862
|
function worktreeForNumber(cwd, num) {
|
|
2837
2863
|
const re = new RegExp(`(?:^|-)${num}$`);
|
|
2838
2864
|
return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
|
|
@@ -3138,11 +3164,32 @@ function worktreeForTicket(dir, cfg, t, role) {
|
|
|
3138
3164
|
const number = t.issue_id.replace(/^\D+/, "");
|
|
3139
3165
|
return effectiveWorktreeDirName(cfg, dir, branchName, number);
|
|
3140
3166
|
}
|
|
3141
|
-
function
|
|
3167
|
+
function branchTemplates(cfg, t) {
|
|
3142
3168
|
const { prefix } = ticketBranchContext(t);
|
|
3143
3169
|
const name = effectiveBranchTemplate(cfg, prefix);
|
|
3144
3170
|
const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
|
|
3145
|
-
return
|
|
3171
|
+
return { name, push };
|
|
3172
|
+
}
|
|
3173
|
+
function existingBranchForTicket(dir, cfg, t, role) {
|
|
3174
|
+
return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
3175
|
+
}
|
|
3176
|
+
function locateBranchForTicket(dir, cfg, t, role, fetchedDirs) {
|
|
3177
|
+
const local = existingBranchForTicket(dir, cfg, t, role);
|
|
3178
|
+
if (local) return { branch: local, where: "local" };
|
|
3179
|
+
const remote = cfg.branch.remote;
|
|
3180
|
+
if (!remoteConfigured(dir, remote)) return null;
|
|
3181
|
+
if (fetchedDirs && !fetchedDirs.has(dir)) {
|
|
3182
|
+
fetchedDirs.add(dir);
|
|
3183
|
+
fetchRemote(dir, remote);
|
|
3184
|
+
}
|
|
3185
|
+
const found = remoteBranchForIssue(dir, remote, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
3186
|
+
return found ? { branch: found, where: "remote" } : null;
|
|
3187
|
+
}
|
|
3188
|
+
function materializeBranchForTicket(dir, cfg, t, role) {
|
|
3189
|
+
const loc = locateBranchForTicket(dir, cfg, t, role);
|
|
3190
|
+
if (!loc) return null;
|
|
3191
|
+
materializeRemoteBranch(dir, cfg.branch.remote, loc.branch);
|
|
3192
|
+
return loc.branch;
|
|
3146
3193
|
}
|
|
3147
3194
|
|
|
3148
3195
|
// src/poll.ts
|
|
@@ -3312,6 +3359,7 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
3312
3359
|
repoCache.set(dir, cfg);
|
|
3313
3360
|
return cfg;
|
|
3314
3361
|
};
|
|
3362
|
+
const fetchedDirs = /* @__PURE__ */ new Set();
|
|
3315
3363
|
const dirFor = (t) => dirForRepo(o.route, t.repo_id);
|
|
3316
3364
|
const render2 = (t) => branchRenderer(t, role);
|
|
3317
3365
|
const input = {
|
|
@@ -3336,6 +3384,10 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
3336
3384
|
existingBranchFor: (t) => {
|
|
3337
3385
|
const dir = dirFor(t);
|
|
3338
3386
|
if (!dir) return null;
|
|
3387
|
+
if (role === "qa") {
|
|
3388
|
+
const loc = locateBranchForTicket(dir, repoFor(dir), t, role, fetchedDirs);
|
|
3389
|
+
return loc ? loc.where === "remote" ? `${loc.branch} (on ${repoFor(dir).branch.remote} only)` : loc.branch : null;
|
|
3390
|
+
}
|
|
3339
3391
|
return existingBranchForTicket(dir, repoFor(dir), t, role);
|
|
3340
3392
|
},
|
|
3341
3393
|
tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
|
|
@@ -3510,9 +3562,17 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
|
|
|
3510
3562
|
|
|
3511
3563
|
// src/stranded-verified.ts
|
|
3512
3564
|
var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
|
|
3565
|
+
var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
|
|
3513
3566
|
var priorStrandedFlags = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).length;
|
|
3514
3567
|
function planStrandedVerified(candidate, base, remote, comments) {
|
|
3515
|
-
|
|
3568
|
+
const stamps = comments.filter((c) => c.ticket_id === candidate.ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).map((c) => Date.parse(c.created_at)).filter((n) => !Number.isNaN(n));
|
|
3569
|
+
return {
|
|
3570
|
+
candidate,
|
|
3571
|
+
base,
|
|
3572
|
+
remote,
|
|
3573
|
+
priorFlags: priorStrandedFlags(candidate.ticket, comments),
|
|
3574
|
+
lastFlaggedAt: stamps.length ? Math.max(...stamps) : void 0
|
|
3575
|
+
};
|
|
3516
3576
|
}
|
|
3517
3577
|
function flagComment(s) {
|
|
3518
3578
|
const key = s.candidate.ticket.issue_id;
|
|
@@ -3528,12 +3588,16 @@ function escalateComment2(s) {
|
|
|
3528
3588
|
return `${STRANDED_MARKER}
|
|
3529
3589
|
**Still nothing to merge \u2014 stopping rather than repeating.**
|
|
3530
3590
|
|
|
3531
|
-
\`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
|
|
3591
|
+
\`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship or on \`${s.remote}\` and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
|
|
3532
3592
|
|
|
3533
3593
|
The crew will not close this on its own.`;
|
|
3534
3594
|
}
|
|
3535
|
-
async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun) {
|
|
3595
|
+
async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun, now = Date.now()) {
|
|
3536
3596
|
const t = { ticket: s.candidate.ticket.issue_id, step: "merge" };
|
|
3597
|
+
if (s.priorFlags > 0 && s.lastFlaggedAt !== void 0 && now - s.lastFlaggedAt < STRANDED_ESCALATE_AFTER_MS) {
|
|
3598
|
+
log.emit("flagged as stranded recently \u2014 not escalating yet", t);
|
|
3599
|
+
return { kind: "waiting" };
|
|
3600
|
+
}
|
|
3537
3601
|
if (s.priorFlags > 0) {
|
|
3538
3602
|
if (dryRun) {
|
|
3539
3603
|
log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 flagged before`, t);
|
|
@@ -5313,7 +5377,7 @@ function refreshBase(o) {
|
|
|
5313
5377
|
return { ok: true };
|
|
5314
5378
|
case "fetch-failed":
|
|
5315
5379
|
o.emit.warn(`${r.detail} \u2014 releasing from what this ship already has`);
|
|
5316
|
-
return { ok: true };
|
|
5380
|
+
return { ok: true, stale: true };
|
|
5317
5381
|
case "would-fast-forward":
|
|
5318
5382
|
case "fast-forwarded":
|
|
5319
5383
|
o.emit.emit(r.detail);
|
|
@@ -5356,7 +5420,10 @@ async function runRelease(o) {
|
|
|
5356
5420
|
const decision = decideRelease(o.cwd, o.tickets, o.contract, {
|
|
5357
5421
|
tagPattern,
|
|
5358
5422
|
base: o.repo.branch.base,
|
|
5359
|
-
|
|
5423
|
+
// CREW-1364: a branch another ship built and pushed at `fixed` exists
|
|
5424
|
+
// here only on the remote. Materialize it so the squash below has a ref.
|
|
5425
|
+
// A dry run only looks.
|
|
5426
|
+
branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
|
|
5360
5427
|
});
|
|
5361
5428
|
if (!fresh.ok) {
|
|
5362
5429
|
o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
|
|
@@ -5405,7 +5472,9 @@ async function runRelease(o) {
|
|
|
5405
5472
|
);
|
|
5406
5473
|
} else {
|
|
5407
5474
|
o.emit.emit("verified but has no branch and nothing on the base names it \u2014 nothing to merge", { ticket: c.ticket.issue_id });
|
|
5408
|
-
|
|
5475
|
+
if (fresh.ok && fresh.stale) {
|
|
5476
|
+
o.emit.warn("not flagging as stranded \u2014 the remote could not be fetched, so the branch may exist there", { ticket: c.ticket.issue_id });
|
|
5477
|
+
} else unbuildable.push(c);
|
|
5409
5478
|
}
|
|
5410
5479
|
continue;
|
|
5411
5480
|
}
|
|
@@ -7042,6 +7111,27 @@ function matchRepoName(origin, resolved) {
|
|
|
7042
7111
|
}
|
|
7043
7112
|
return void 0;
|
|
7044
7113
|
}
|
|
7114
|
+
function routeNamesOf(text) {
|
|
7115
|
+
const routes = parseDocument(text).get("routes", true);
|
|
7116
|
+
if (!isSeq(routes)) return [];
|
|
7117
|
+
return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
|
|
7118
|
+
}
|
|
7119
|
+
function parseAddArgs(args, routes, isDir) {
|
|
7120
|
+
const rest = [...args];
|
|
7121
|
+
let route;
|
|
7122
|
+
if (rest.length > 0 && routes.includes(rest[0])) route = rest.shift();
|
|
7123
|
+
else if (rest.length > 0 && /^[^/\s]+\/[^/\s]+$/.test(rest[0]) && !isDir(rest[0])) {
|
|
7124
|
+
throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
|
|
7125
|
+
}
|
|
7126
|
+
if (!route) {
|
|
7127
|
+
if (routes.length === 1) route = routes[0];
|
|
7128
|
+
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(", ")}`);
|
|
7129
|
+
}
|
|
7130
|
+
if (rest.length > 2) throw new ReposError("too many arguments: expected [route] [name] [path]");
|
|
7131
|
+
if (rest.length === 2) return { route, name: rest[0], path: rest[1] };
|
|
7132
|
+
if (rest.length === 1) return isDir(rest[0]) ? { route, path: rest[0] } : { route, name: rest[0], path: "." };
|
|
7133
|
+
return { route, path: "." };
|
|
7134
|
+
}
|
|
7045
7135
|
function expandDir(p, base) {
|
|
7046
7136
|
const home = p === "~" ? homedir3() : p.startsWith("~/") ? `${homedir3()}/${p.slice(2)}` : p;
|
|
7047
7137
|
return resolve3(base, home);
|
|
@@ -7081,7 +7171,7 @@ function planRepoAdd(i) {
|
|
|
7081
7171
|
throw new ReposError(`${target} is already configured on route ${i.route}`);
|
|
7082
7172
|
}
|
|
7083
7173
|
const matched = matchRepoName(i.origin, i.resolved);
|
|
7084
|
-
const name = matched ?? i.name ?? basename5(target);
|
|
7174
|
+
const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename5(target);
|
|
7085
7175
|
let warning;
|
|
7086
7176
|
if (!matched) {
|
|
7087
7177
|
const known = Object.values(i.resolved?.repoNames ?? {});
|
|
@@ -7178,6 +7268,7 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
7178
7268
|
}
|
|
7179
7269
|
|
|
7180
7270
|
// src/worktree-sweep.ts
|
|
7271
|
+
import { existsSync as existsSync14 } from "fs";
|
|
7181
7272
|
import { basename as basename6 } from "path";
|
|
7182
7273
|
function planWorktreeSweep(target, tickets, contract) {
|
|
7183
7274
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
@@ -7197,10 +7288,24 @@ function planWorktreeSweep(target, tickets, contract) {
|
|
|
7197
7288
|
}
|
|
7198
7289
|
return actions;
|
|
7199
7290
|
}
|
|
7200
|
-
async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
7291
|
+
async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
|
|
7201
7292
|
let removed = 0;
|
|
7202
7293
|
let keptBranches = 0;
|
|
7203
7294
|
for (const a of actions) {
|
|
7295
|
+
if (!dryRun && existsSync14(a.path)) {
|
|
7296
|
+
let dirty = false;
|
|
7297
|
+
try {
|
|
7298
|
+
dirty = status(a.path).length > 0;
|
|
7299
|
+
} catch {
|
|
7300
|
+
}
|
|
7301
|
+
if (dirty) {
|
|
7302
|
+
log.warn(
|
|
7303
|
+
`not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
|
|
7304
|
+
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
7305
|
+
);
|
|
7306
|
+
continue;
|
|
7307
|
+
}
|
|
7308
|
+
}
|
|
7204
7309
|
const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
|
|
7205
7310
|
const pids = pidsInWorktree(a.path, n);
|
|
7206
7311
|
if (pids.length) {
|
|
@@ -7220,7 +7325,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
|
7220
7325
|
continue;
|
|
7221
7326
|
}
|
|
7222
7327
|
gitOk(cwd, ["worktree", "remove", "--force", a.path]);
|
|
7223
|
-
if (!a.keepBranch && a.branch)
|
|
7328
|
+
if (!a.keepBranch && a.branch) {
|
|
7329
|
+
gitOk(cwd, ["branch", "-D", a.branch]);
|
|
7330
|
+
if (remoteConfigured(cwd, remote) && !deleteRemoteBranch(cwd, remote, a.branch)) {
|
|
7331
|
+
log.warn(`could not delete ${remote}/${a.branch} \u2014 leaving it for the next sweep`, { ticket: a.ticket.issue_id, step: "worktree" });
|
|
7332
|
+
}
|
|
7333
|
+
}
|
|
7224
7334
|
log.emit(
|
|
7225
7335
|
`removed worktree for ${a.ticket.issue_id} (${a.ticket.status}); ${branchNote}`,
|
|
7226
7336
|
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
@@ -7229,14 +7339,53 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
|
7229
7339
|
}
|
|
7230
7340
|
return { removed, keptBranches };
|
|
7231
7341
|
}
|
|
7342
|
+
function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
|
|
7343
|
+
if (!remoteConfigured(cwd, remote)) return [];
|
|
7344
|
+
const stems = /* @__PURE__ */ new Set();
|
|
7345
|
+
for (const t of tickets) {
|
|
7346
|
+
if (t.status !== contract.statuses.deployed) continue;
|
|
7347
|
+
const num = t.issue_id.replace(/^\D+/, "");
|
|
7348
|
+
const unpadded = String(Number.parseInt(num, 10));
|
|
7349
|
+
const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
|
|
7350
|
+
for (const p of prefixes) {
|
|
7351
|
+
stems.add(`${p}-${num}`);
|
|
7352
|
+
stems.add(`${p}-${unpadded}`);
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
if (stems.size === 0) return [];
|
|
7356
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
7357
|
+
const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
|
|
7358
|
+
const out = [];
|
|
7359
|
+
for (const ref of refs) {
|
|
7360
|
+
const name = ref.slice(prefix.length);
|
|
7361
|
+
if (name === "HEAD" || name === base) continue;
|
|
7362
|
+
const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
|
|
7363
|
+
if (m && stems.has(m[1])) out.push(name);
|
|
7364
|
+
}
|
|
7365
|
+
return out;
|
|
7366
|
+
}
|
|
7367
|
+
function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
7368
|
+
let deleted = 0;
|
|
7369
|
+
for (const b of branches2) {
|
|
7370
|
+
if (dryRun) {
|
|
7371
|
+
log.emit(`would delete ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
|
|
7372
|
+
continue;
|
|
7373
|
+
}
|
|
7374
|
+
if (deleteRemoteBranch(cwd, remote, b)) {
|
|
7375
|
+
deleted++;
|
|
7376
|
+
log.emit(`deleted ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
|
|
7377
|
+
} else log.warn(`could not delete ${remote}/${b} \u2014 leaving it for the next sweep`, { step: "worktree" });
|
|
7378
|
+
}
|
|
7379
|
+
return deleted;
|
|
7380
|
+
}
|
|
7232
7381
|
|
|
7233
7382
|
// src/stream-sweep.ts
|
|
7234
|
-
import { existsSync as
|
|
7383
|
+
import { existsSync as existsSync15, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
|
|
7235
7384
|
import { join as join13 } from "path";
|
|
7236
7385
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
7237
7386
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
7238
7387
|
const dir = join13(stateDir, "streams");
|
|
7239
|
-
if (!
|
|
7388
|
+
if (!existsSync15(dir)) return [];
|
|
7240
7389
|
const cutoff = now - retentionDays * DAY_MS;
|
|
7241
7390
|
return readdirSync2(dir).map((name) => join13(dir, name)).filter((path) => {
|
|
7242
7391
|
try {
|
|
@@ -7265,13 +7414,13 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
7265
7414
|
}
|
|
7266
7415
|
|
|
7267
7416
|
// src/cli.ts
|
|
7268
|
-
import { readFileSync as readFileSync12, existsSync as
|
|
7417
|
+
import { readFileSync as readFileSync12, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
|
|
7269
7418
|
import { createInterface } from "readline/promises";
|
|
7270
7419
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
7271
7420
|
|
|
7272
7421
|
// src/ssh-keys.ts
|
|
7273
7422
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
7274
|
-
import { existsSync as
|
|
7423
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
|
|
7275
7424
|
import { dirname as dirname8, join as join14 } from "path";
|
|
7276
7425
|
var SshKeyError = class extends Error {
|
|
7277
7426
|
};
|
|
@@ -7281,7 +7430,7 @@ function shipSshKeyPathFor(stateDir) {
|
|
|
7281
7430
|
function ensureShipSshKeypair(stateDir) {
|
|
7282
7431
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
7283
7432
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
7284
|
-
if (!
|
|
7433
|
+
if (!existsSync16(privateKeyPath)) {
|
|
7285
7434
|
mkdirSync7(dirname8(privateKeyPath), { recursive: true });
|
|
7286
7435
|
try {
|
|
7287
7436
|
execFileSync3("ssh-keygen", [
|
|
@@ -7302,7 +7451,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
7302
7451
|
}
|
|
7303
7452
|
chmodSync(privateKeyPath, 384);
|
|
7304
7453
|
}
|
|
7305
|
-
if (!
|
|
7454
|
+
if (!existsSync16(publicKeyPath)) {
|
|
7306
7455
|
throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
|
|
7307
7456
|
}
|
|
7308
7457
|
const publicKey = readFileSync9(publicKeyPath, "utf8").trim();
|
|
@@ -7495,7 +7644,7 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
7495
7644
|
|
|
7496
7645
|
// src/tunnel.ts
|
|
7497
7646
|
import { spawn as spawn3 } from "child_process";
|
|
7498
|
-
import { existsSync as
|
|
7647
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
|
|
7499
7648
|
import { randomInt } from "crypto";
|
|
7500
7649
|
import { join as join15 } from "path";
|
|
7501
7650
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
@@ -7568,7 +7717,7 @@ function tunnelStatePath(stateDir, workspaceId) {
|
|
|
7568
7717
|
}
|
|
7569
7718
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
7570
7719
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
7571
|
-
if (!
|
|
7720
|
+
if (!existsSync17(path)) return void 0;
|
|
7572
7721
|
try {
|
|
7573
7722
|
return JSON.parse(readFileSync10(path, "utf8"));
|
|
7574
7723
|
} catch {
|
|
@@ -7676,7 +7825,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7676
7825
|
}
|
|
7677
7826
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
7678
7827
|
const dir = join15(stateDir, "passengers");
|
|
7679
|
-
if (!
|
|
7828
|
+
if (!existsSync17(dir)) return [];
|
|
7680
7829
|
return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
7681
7830
|
}
|
|
7682
7831
|
|
|
@@ -7797,7 +7946,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
7797
7946
|
import { createServer as createHttpServer } from "http";
|
|
7798
7947
|
import { spawn as spawn4 } from "child_process";
|
|
7799
7948
|
import { randomBytes } from "crypto";
|
|
7800
|
-
import { existsSync as
|
|
7949
|
+
import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
|
|
7801
7950
|
import { join as join16 } from "path";
|
|
7802
7951
|
|
|
7803
7952
|
// src/secret-compare.ts
|
|
@@ -7891,7 +8040,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
|
|
|
7891
8040
|
}
|
|
7892
8041
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
7893
8042
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
7894
|
-
if (!
|
|
8043
|
+
if (!existsSync18(path)) return void 0;
|
|
7895
8044
|
try {
|
|
7896
8045
|
return JSON.parse(readFileSync11(path, "utf8"));
|
|
7897
8046
|
} catch {
|
|
@@ -7922,7 +8071,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
7922
8071
|
}
|
|
7923
8072
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
7924
8073
|
const dir = join16(stateDir, "passengers");
|
|
7925
|
-
if (!
|
|
8074
|
+
if (!existsSync18(dir)) return [];
|
|
7926
8075
|
const suffix = "-sync.json";
|
|
7927
8076
|
return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
7928
8077
|
}
|
|
@@ -8327,7 +8476,7 @@ function usage() {
|
|
|
8327
8476
|
crew agents sync [route] [--force] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona)
|
|
8328
8477
|
crew agents prompt R [route] print one persona's current prompt (e.g. R=pair, for a SessionStart hook)
|
|
8329
8478
|
crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
|
|
8330
|
-
crew repos add ROUTE PATH
|
|
8479
|
+
crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
|
|
8331
8480
|
crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
|
|
8332
8481
|
crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
|
|
8333
8482
|
crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
|
|
@@ -8634,7 +8783,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8634
8783
|
if (host === "linux" && hasSystemd) {
|
|
8635
8784
|
const label = labelFor(CREW_HOME, "run");
|
|
8636
8785
|
const staleTimer = join18(homedir4(), ".config", "systemd", "user", `${label}.timer`);
|
|
8637
|
-
if (
|
|
8786
|
+
if (existsSync19(staleTimer)) {
|
|
8638
8787
|
process.stdout.write(
|
|
8639
8788
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
8640
8789
|
`
|
|
@@ -8659,7 +8808,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8659
8808
|
const tracker = new Tracker(c, cfg.ship);
|
|
8660
8809
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
8661
8810
|
for (const r of await resolvedRepos(c)) {
|
|
8662
|
-
if (!
|
|
8811
|
+
if (!existsSync19(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
8663
8812
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
8664
8813
|
problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
8665
8814
|
}
|
|
@@ -8677,7 +8826,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8677
8826
|
}
|
|
8678
8827
|
async function doctorFix(o) {
|
|
8679
8828
|
const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
|
|
8680
|
-
const unitExists = (p) =>
|
|
8829
|
+
const unitExists = (p) => existsSync19(p);
|
|
8681
8830
|
const crontab = o.host === "linux" ? spawnSync2("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
8682
8831
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
8683
8832
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
@@ -8897,9 +9046,11 @@ ${changed2.map((c) => ` - ${c}
|
|
|
8897
9046
|
const actions = planWorktreeSweep(target, scoped, tracker.contract);
|
|
8898
9047
|
if (actions.length) {
|
|
8899
9048
|
remit.enter("worktree");
|
|
8900
|
-
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit);
|
|
9049
|
+
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
|
|
8901
9050
|
remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
|
|
8902
9051
|
}
|
|
9052
|
+
const stale = planRemoteBranchCleanup(target.dir, repo.branch.remote, repo.branch.base, scoped, tracker.contract);
|
|
9053
|
+
if (stale.length) applyRemoteBranchCleanup(target.dir, repo.branch.remote, stale, dryRun, remit);
|
|
8903
9054
|
} catch (e) {
|
|
8904
9055
|
remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
|
|
8905
9056
|
}
|
|
@@ -9553,7 +9704,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9553
9704
|
`);
|
|
9554
9705
|
if (!dryRun) {
|
|
9555
9706
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
9556
|
-
const raw =
|
|
9707
|
+
const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
|
|
9557
9708
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
9558
9709
|
raw.agentSkills = skillResult.agentSkills;
|
|
9559
9710
|
mkdirSync10(dirOf(resolvedPath), { recursive: true });
|
|
@@ -9602,7 +9753,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9602
9753
|
`);
|
|
9603
9754
|
if (!dryRun) {
|
|
9604
9755
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
9605
|
-
const raw =
|
|
9756
|
+
const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
|
|
9606
9757
|
raw.agentSkills = result.agentSkills;
|
|
9607
9758
|
mkdirSync10(dirOf(resolvedPath), { recursive: true });
|
|
9608
9759
|
writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -9613,22 +9764,41 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9613
9764
|
}
|
|
9614
9765
|
case "repos": {
|
|
9615
9766
|
const sub = positional[1];
|
|
9616
|
-
const routeName = positional[2];
|
|
9617
9767
|
const usage2 = () => {
|
|
9618
|
-
process.stderr.write("crew repos add
|
|
9768
|
+
process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos list <workspace/project>\n");
|
|
9619
9769
|
process.exit(2);
|
|
9620
9770
|
};
|
|
9621
|
-
if (sub !== "add" && sub !== "list"
|
|
9771
|
+
if (sub !== "add" && sub !== "list") usage2();
|
|
9772
|
+
const configText = readFileSync12(cfg.configFile, "utf8");
|
|
9773
|
+
let routeName = positional[2];
|
|
9774
|
+
let addPath = ".";
|
|
9775
|
+
let addName;
|
|
9776
|
+
if (sub === "list" && !routeName) usage2();
|
|
9777
|
+
if (sub === "add") {
|
|
9778
|
+
try {
|
|
9779
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
|
|
9780
|
+
routeName = a.route;
|
|
9781
|
+
addPath = a.path;
|
|
9782
|
+
addName = a.name;
|
|
9783
|
+
} catch (e) {
|
|
9784
|
+
if (e instanceof ReposError) {
|
|
9785
|
+
process.stderr.write(`crew repos: ${e.message}
|
|
9786
|
+
`);
|
|
9787
|
+
process.exit(2);
|
|
9788
|
+
}
|
|
9789
|
+
throw e;
|
|
9790
|
+
}
|
|
9791
|
+
}
|
|
9622
9792
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
9623
9793
|
let resolvedRemotes;
|
|
9624
|
-
if (
|
|
9794
|
+
if (existsSync19(resolvedFile)) {
|
|
9625
9795
|
try {
|
|
9626
9796
|
resolvedRemotes = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9627
9797
|
} catch {
|
|
9628
9798
|
}
|
|
9629
9799
|
}
|
|
9630
9800
|
const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
|
|
9631
|
-
const text =
|
|
9801
|
+
const text = configText;
|
|
9632
9802
|
const base = dirname9(cfg.configFile);
|
|
9633
9803
|
try {
|
|
9634
9804
|
if (sub === "list") {
|
|
@@ -9638,14 +9808,14 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9638
9808
|
for (const r of repos) {
|
|
9639
9809
|
const origin2 = originOf(r.dir);
|
|
9640
9810
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
9641
|
-
const note = !
|
|
9811
|
+
const note = !existsSync19(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
9642
9812
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
9643
9813
|
`);
|
|
9644
9814
|
}
|
|
9645
9815
|
break;
|
|
9646
9816
|
}
|
|
9647
|
-
const target = resolvePath(
|
|
9648
|
-
const top =
|
|
9817
|
+
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir4()));
|
|
9818
|
+
const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
9649
9819
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
9650
9820
|
const origin = originOf(top);
|
|
9651
9821
|
const plan = planRepoAdd({
|
|
@@ -9655,7 +9825,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9655
9825
|
path: top,
|
|
9656
9826
|
origin,
|
|
9657
9827
|
resolved: resolvedRemotes,
|
|
9658
|
-
name: value("name"),
|
|
9828
|
+
name: value("name") ?? addName,
|
|
9659
9829
|
nameForExisting: (d) => matchRepoName(originOf(d), resolvedRemotes)
|
|
9660
9830
|
});
|
|
9661
9831
|
let rowTracker;
|
|
@@ -9693,7 +9863,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9693
9863
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
9694
9864
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
9695
9865
|
`);
|
|
9696
|
-
if (
|
|
9866
|
+
if (existsSync19(resolvedFile)) {
|
|
9697
9867
|
const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9698
9868
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
9699
9869
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -9926,7 +10096,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
9926
10096
|
operator = await pickOperator(found.holds);
|
|
9927
10097
|
}
|
|
9928
10098
|
let previous;
|
|
9929
|
-
const hadPreviousFile =
|
|
10099
|
+
const hadPreviousFile = existsSync19(resolvedPath);
|
|
9930
10100
|
if (hadPreviousFile) {
|
|
9931
10101
|
try {
|
|
9932
10102
|
previous = JSON.parse(readFileSync12(resolvedPath, "utf8"));
|
|
@@ -10182,9 +10352,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10182
10352
|
const { byRepo } = ticketsByRepo(route, terminal);
|
|
10183
10353
|
for (const r of await resolvedRepos(route)) {
|
|
10184
10354
|
const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
|
|
10355
|
+
const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
|
|
10356
|
+
if (stale.length) {
|
|
10357
|
+
anything = true;
|
|
10358
|
+
applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
|
|
10359
|
+
}
|
|
10185
10360
|
if (!actions.length) continue;
|
|
10186
10361
|
anything = true;
|
|
10187
|
-
const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit);
|
|
10362
|
+
const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
|
|
10188
10363
|
emit.emit(
|
|
10189
10364
|
`swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
|
|
10190
10365
|
{ step: "worktree" }
|
|
@@ -10240,8 +10415,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10240
10415
|
break;
|
|
10241
10416
|
}
|
|
10242
10417
|
gitOk(target.dir, ["worktree", "remove", "--force", wt]);
|
|
10243
|
-
if (branch)
|
|
10244
|
-
|
|
10418
|
+
if (branch) {
|
|
10419
|
+
gitOk(target.dir, ["branch", "-D", branch]);
|
|
10420
|
+
const { remote, base } = target.config.branch;
|
|
10421
|
+
if (remoteConfigured(target.dir, remote) && remoteBranchExists(target.dir, remote, branch)) {
|
|
10422
|
+
if (findKeyInRange(target.dir, `ISSUE-${n}`, `${remote}/${base}`)) {
|
|
10423
|
+
emit.emit(deleteRemoteBranch(target.dir, remote, branch) ? `deleted ${remote}/${branch}` : `could not delete ${remote}/${branch}`);
|
|
10424
|
+
} else {
|
|
10425
|
+
emit.warn(`${remote}/${branch} kept \u2014 nothing on ${remote}/${base} names ISSUE-${n} yet, so it may be the only copy`);
|
|
10426
|
+
}
|
|
10427
|
+
}
|
|
10428
|
+
} else emit.warn(`no branch found for ISSUE-${n} in ${target.name} \u2014 worktree removed, nothing to delete`);
|
|
10245
10429
|
emit.emit(`dropped ISSUE-${n} from ${target.name}`);
|
|
10246
10430
|
break;
|
|
10247
10431
|
}
|
package/package.json
CHANGED
|
@@ -191,6 +191,12 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
191
191
|
comment is what clears you to act again, not the flag itself. Clearing
|
|
192
192
|
`needs_planning`/`needs_review` is always the operator's move, never
|
|
193
193
|
yours, whatever you find when you resume.
|
|
194
|
+
- **Before resuming in an existing worktree, run `crew sync`** (it fetches and
|
|
195
|
+
fast-forwards every worktree branch that tracks a remote, never merging or
|
|
196
|
+
rebasing) — another ship may have pushed to the branch since you left it.
|
|
197
|
+
If it reports your branch as diverged (local commits not on the remote AND
|
|
198
|
+
the remote has moved), stop: comment on the ticket with what it printed and
|
|
199
|
+
set `needs_info`; do not merge or rebase it yourself.
|
|
194
200
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
195
201
|
reassigned to your row, with a comment saying what still fails. Treat
|
|
196
202
|
that exactly like new direction from a hold: read the comment, fix what
|
|
@@ -520,6 +526,15 @@ if `report_type` is either of those.
|
|
|
520
526
|
part).
|
|
521
527
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
522
528
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
529
|
+
**Push the branch first, in the same step:**
|
|
530
|
+
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
531
|
+
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
532
|
+
configured has nothing to push to, so say so in your comment and skip it).
|
|
533
|
+
Another ship may be the one that verifies and releases this ticket, and a
|
|
534
|
+
branch that never left this machine looks MISSING to it. A bounce back to
|
|
535
|
+
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
536
|
+
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
537
|
+
and say so on the ticket instead of forcing it.
|
|
523
538
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
524
539
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
525
540
|
would leave QA nothing to test. Your last progress comment is what QA
|
|
@@ -50,6 +50,12 @@ human review, so "probably fine" is not a pass.
|
|
|
50
50
|
Does the change actually do what the comment claims? Does it handle the
|
|
51
51
|
empty/error/permission path, or only the happy one? Does it touch
|
|
52
52
|
anything the ticket never mentioned?
|
|
53
|
+
**A branch on the remote is testable.** The builder may be on another ship:
|
|
54
|
+
the digest's `branch` column then reads `<name> (on origin only)` and no
|
|
55
|
+
worktree exists here yet. Cut one from the remote branch —
|
|
56
|
+
`git worktree add <the digest's worktree> <name>` from the ticket's repo
|
|
57
|
+
checkout (git creates the local tracking branch) — and carry on. Only
|
|
58
|
+
**MISSING** (gone locally and on the remote) means there is nothing to test.
|
|
53
59
|
5. **Run it.** The builder left its worktree in place for you: `cd` into it,
|
|
54
60
|
`nvm use` if the repository pins a version, and `eval` the **`ports`** hook
|
|
55
61
|
for this worktree's own ports (anything already listening on them is a dead
|
|
@@ -172,6 +172,12 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
172
172
|
to leave such tickets alone, which — combined with the poll not waking
|
|
173
173
|
for them — left ISSUE-049 parked for hours mid-implementation with no
|
|
174
174
|
question outstanding, restartable only by someone commenting on it.
|
|
175
|
+
- **Before resuming in an existing worktree, run `crew sync`** (it fetches and
|
|
176
|
+
fast-forwards every worktree branch that tracks a remote, never merging or
|
|
177
|
+
rebasing) — another ship may have pushed to the branch since you left it.
|
|
178
|
+
If it reports your branch as diverged (local commits not on the remote AND
|
|
179
|
+
the remote has moved), stop: comment on the ticket with what it printed and
|
|
180
|
+
set `needs_info`; do not merge or rebase it yourself.
|
|
175
181
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
176
182
|
reassigned to your row, with a comment saying what still fails. Treat
|
|
177
183
|
that exactly like new direction from a hold: read the comment, fix what
|
|
@@ -501,6 +507,15 @@ if `report_type` is either of those.
|
|
|
501
507
|
part).
|
|
502
508
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
503
509
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
510
|
+
**Push the branch first, in the same step:**
|
|
511
|
+
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
512
|
+
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
513
|
+
configured has nothing to push to, so say so in your comment and skip it).
|
|
514
|
+
Another ship may be the one that verifies and releases this ticket, and a
|
|
515
|
+
branch that never left this machine looks MISSING to it. A bounce back to
|
|
516
|
+
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
517
|
+
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
518
|
+
and say so on the ticket instead of forcing it.
|
|
504
519
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
505
520
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
506
521
|
would leave QA nothing to test. Your last progress comment is what QA
|