@inteeka/task-cli 0.2.4 → 0.2.6
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/dist/cli.js +964 -226
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -191,6 +191,9 @@ var CLI_AUDIT_ACTIONS = Object.freeze([
|
|
|
191
191
|
"cli.run.tests_failed",
|
|
192
192
|
"cli.run.pr_opened",
|
|
193
193
|
"cli.run.pr_failed",
|
|
194
|
+
"cli.run.push_failed",
|
|
195
|
+
"cli.run.resumed",
|
|
196
|
+
"cli.work.pr_recovered",
|
|
194
197
|
"cli.schedule.created",
|
|
195
198
|
"cli.schedule.paused",
|
|
196
199
|
"cli.schedule.resumed",
|
|
@@ -224,6 +227,13 @@ var c = {
|
|
|
224
227
|
var CliError = class extends Error {
|
|
225
228
|
code;
|
|
226
229
|
hint;
|
|
230
|
+
/**
|
|
231
|
+
* Discriminator for the multi-work loop's between-iteration cleanup.
|
|
232
|
+
* `post_push` means the per-ticket branch is on origin (with a commit)
|
|
233
|
+
* and should be preserved for `task resume`. Pre-push failures (or
|
|
234
|
+
* unset / 'pre_push') let the loop purge the branch as usual.
|
|
235
|
+
*/
|
|
236
|
+
phase;
|
|
227
237
|
constructor(code, message, hint) {
|
|
228
238
|
super(message);
|
|
229
239
|
this.code = code;
|
|
@@ -988,9 +998,243 @@ function printTable(headers, rows) {
|
|
|
988
998
|
}
|
|
989
999
|
|
|
990
1000
|
// src/commands/status.ts
|
|
1001
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
1002
|
+
|
|
1003
|
+
// src/git/branch.ts
|
|
1004
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1005
|
+
|
|
1006
|
+
// src/git/commit.ts
|
|
991
1007
|
import { execFileSync } from "child_process";
|
|
1008
|
+
function commitOnly(args) {
|
|
1009
|
+
execFileSync("git", ["add", "-A"], { cwd: args.cwd });
|
|
1010
|
+
const statusRaw = execFileSync("git", ["status", "--porcelain"], {
|
|
1011
|
+
cwd: args.cwd,
|
|
1012
|
+
encoding: "utf8"
|
|
1013
|
+
});
|
|
1014
|
+
if (!statusRaw.trim()) {
|
|
1015
|
+
throw new Error("No changes to commit (empty diff)");
|
|
1016
|
+
}
|
|
1017
|
+
execFileSync("git", ["commit", "-m", args.message], { cwd: args.cwd });
|
|
1018
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
1019
|
+
cwd: args.cwd,
|
|
1020
|
+
encoding: "utf8"
|
|
1021
|
+
}).trim();
|
|
1022
|
+
return { sha };
|
|
1023
|
+
}
|
|
1024
|
+
function currentBranch(cwd) {
|
|
1025
|
+
try {
|
|
1026
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1027
|
+
cwd,
|
|
1028
|
+
encoding: "utf8"
|
|
1029
|
+
}).trim();
|
|
1030
|
+
} catch {
|
|
1031
|
+
return "HEAD";
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/git/branch.ts
|
|
1036
|
+
var VALID_BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
1037
|
+
var TICKET_BRANCH = /^task\/[a-z0-9-]{1,80}$/;
|
|
1038
|
+
function assertValidBranchName(branch) {
|
|
1039
|
+
if (!VALID_BRANCH.test(branch) || branch.includes("..") || branch.startsWith("/") || branch.endsWith("/")) {
|
|
1040
|
+
throw new CliError(
|
|
1041
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1042
|
+
`Invalid branch name: ${branch}`,
|
|
1043
|
+
'Branch names must contain only [A-Za-z0-9._/-], no "..", and no leading/trailing slash.'
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
function isWorkingTreeClean(cwd) {
|
|
1048
|
+
const out = execFileSync2("git", ["status", "--porcelain"], {
|
|
1049
|
+
cwd,
|
|
1050
|
+
encoding: "utf8"
|
|
1051
|
+
});
|
|
1052
|
+
return out.trim().length === 0;
|
|
1053
|
+
}
|
|
1054
|
+
function assertBaseBranch(cwd, expected) {
|
|
1055
|
+
assertValidBranchName(expected);
|
|
1056
|
+
const current = currentBranch(cwd);
|
|
1057
|
+
if (current !== expected) {
|
|
1058
|
+
throw new CliError(
|
|
1059
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1060
|
+
`task work requires branch "${expected}" but you're on "${current}"`,
|
|
1061
|
+
`Run "git checkout ${expected}" first. The base branch is configured per project; ask an admin if it should be different.`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (!isWorkingTreeClean(cwd)) {
|
|
1065
|
+
throw new CliError(
|
|
1066
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1067
|
+
"Working tree is dirty",
|
|
1068
|
+
"Commit, stash, or discard your local changes before running task work."
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function createTicketBranch(cwd, branchName, baseBranch) {
|
|
1073
|
+
assertValidBranchName(branchName);
|
|
1074
|
+
assertValidBranchName(baseBranch);
|
|
1075
|
+
if (!TICKET_BRANCH.test(branchName)) {
|
|
1076
|
+
throw new CliError(
|
|
1077
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1078
|
+
`Per-ticket branch must match ^task/[a-z0-9-]{1,80}$ \u2014 got "${branchName}"`
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
try {
|
|
1082
|
+
execFileSync2("git", ["checkout", "-b", branchName, baseBranch], {
|
|
1083
|
+
cwd,
|
|
1084
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1085
|
+
});
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1088
|
+
throw new CliError(
|
|
1089
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1090
|
+
`Could not create branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function deleteLocalBranch(cwd, branchName) {
|
|
1095
|
+
if (!VALID_BRANCH.test(branchName)) return;
|
|
1096
|
+
try {
|
|
1097
|
+
execFileSync2("git", ["branch", "-D", branchName], {
|
|
1098
|
+
cwd,
|
|
1099
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
1100
|
+
});
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function checkoutBranch(cwd, branchName) {
|
|
1105
|
+
assertValidBranchName(branchName);
|
|
1106
|
+
try {
|
|
1107
|
+
execFileSync2("git", ["checkout", branchName], {
|
|
1108
|
+
cwd,
|
|
1109
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1110
|
+
});
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1113
|
+
throw new CliError(
|
|
1114
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1115
|
+
`Could not check out branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
function pushBranch(cwd, branchName) {
|
|
1120
|
+
assertValidBranchName(branchName);
|
|
1121
|
+
try {
|
|
1122
|
+
execFileSync2("git", ["push", "-u", "origin", branchName], {
|
|
1123
|
+
cwd,
|
|
1124
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1125
|
+
});
|
|
1126
|
+
return { remote: "origin" };
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1129
|
+
throw new CliError(
|
|
1130
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1131
|
+
`Push failed: ${stderr.slice(0, 600) || err.message}`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function enforceBaseBranchClean(cwd, baseBranch, opts = {}) {
|
|
1136
|
+
assertValidBranchName(baseBranch);
|
|
1137
|
+
try {
|
|
1138
|
+
execFileSync2("git", ["restore", "--staged", "--worktree", "."], {
|
|
1139
|
+
cwd,
|
|
1140
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1141
|
+
});
|
|
1142
|
+
} catch {
|
|
1143
|
+
try {
|
|
1144
|
+
execFileSync2("git", ["reset", "--hard", "HEAD"], {
|
|
1145
|
+
cwd,
|
|
1146
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1147
|
+
});
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
execFileSync2("git", ["clean", "-fd"], {
|
|
1153
|
+
cwd,
|
|
1154
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1155
|
+
});
|
|
1156
|
+
} catch {
|
|
1157
|
+
}
|
|
1158
|
+
checkoutBranch(cwd, baseBranch);
|
|
1159
|
+
const current = currentBranch(cwd);
|
|
1160
|
+
if (current !== baseBranch) {
|
|
1161
|
+
throw new CliError(
|
|
1162
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1163
|
+
`Branch enforcement failed: expected "${baseBranch}", on "${current}"`,
|
|
1164
|
+
`Run "git checkout ${baseBranch}" manually, then re-run task multi-work.`
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
if (!isWorkingTreeClean(cwd)) {
|
|
1168
|
+
throw new CliError(
|
|
1169
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1170
|
+
"Branch enforcement failed: working tree is dirty after returning to base",
|
|
1171
|
+
'Inspect with "git status" and clean up before re-running task multi-work.'
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (opts.deleteBranch && opts.deleteBranch !== baseBranch) {
|
|
1175
|
+
deleteLocalBranch(cwd, opts.deleteBranch);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
function remoteBranchExists(cwd, branchName, opts = {}) {
|
|
1179
|
+
assertValidBranchName(branchName);
|
|
1180
|
+
const remote = opts.remote ?? "origin";
|
|
1181
|
+
if (!/^[A-Za-z0-9._-]{1,100}$/.test(remote)) {
|
|
1182
|
+
throw new CliError(CLI_EXIT_CODES.MISCONFIGURATION, `Invalid remote name: ${remote}`);
|
|
1183
|
+
}
|
|
1184
|
+
let stdout;
|
|
1185
|
+
try {
|
|
1186
|
+
stdout = execFileSync2("git", ["ls-remote", "--heads", remote, branchName], {
|
|
1187
|
+
cwd,
|
|
1188
|
+
encoding: "utf8",
|
|
1189
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1190
|
+
});
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1193
|
+
throw new CliError(
|
|
1194
|
+
CLI_EXIT_CODES.NETWORK_UNREACHABLE,
|
|
1195
|
+
`Could not reach remote "${remote}": ${stderr.slice(0, 400) || err.message}`,
|
|
1196
|
+
"Check that the remote exists (`git remote -v`) and that you have credentials to talk to it."
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return stdout.trim().length > 0;
|
|
1200
|
+
}
|
|
1201
|
+
function listLocalTicketBranches(cwd) {
|
|
1202
|
+
let stdout;
|
|
1203
|
+
try {
|
|
1204
|
+
stdout = execFileSync2(
|
|
1205
|
+
"git",
|
|
1206
|
+
["for-each-ref", "--format=%(refname:short) %(upstream:short)", "refs/heads/task/"],
|
|
1207
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
|
1208
|
+
);
|
|
1209
|
+
} catch {
|
|
1210
|
+
return [];
|
|
1211
|
+
}
|
|
1212
|
+
const out = [];
|
|
1213
|
+
for (const rawLine of stdout.split("\n")) {
|
|
1214
|
+
const line = rawLine.trim();
|
|
1215
|
+
if (line.length === 0) continue;
|
|
1216
|
+
const [name, upstream = ""] = line.split(" ");
|
|
1217
|
+
if (!name || !TICKET_BRANCH.test(name)) continue;
|
|
1218
|
+
const seqMatch = name.match(/^task\/(\d+)/);
|
|
1219
|
+
out.push({
|
|
1220
|
+
name,
|
|
1221
|
+
sequenceNumber: seqMatch && seqMatch[1] ? parseInt(seqMatch[1], 10) : null,
|
|
1222
|
+
hasUpstream: upstream.trim().length > 0
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
return out;
|
|
1226
|
+
}
|
|
1227
|
+
function branchSlug(sequenceNumber, title) {
|
|
1228
|
+
const safeTitle = title.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1229
|
+
const slugBudget = 70;
|
|
1230
|
+
const truncatedSlug = safeTitle.slice(0, slugBudget);
|
|
1231
|
+
const tail = truncatedSlug.length > 0 ? `-${truncatedSlug}` : "";
|
|
1232
|
+
return `task/${sequenceNumber}${tail}`.replace(/-+$/, "");
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/commands/status.ts
|
|
992
1236
|
function registerStatus(program2) {
|
|
993
|
-
program2.command("status").description("Show CLI auth, link, and
|
|
1237
|
+
program2.command("status").alias("s").description("Show CLI auth, link, git state, in-flight tickets, and orphan branches").option("--remote", "Also fetch /cli/access for live state").action(async (_opts) => {
|
|
994
1238
|
const creds = await readCredentials();
|
|
995
1239
|
const root = findRepoRoot();
|
|
996
1240
|
const project = await readProjectConfig(root);
|
|
@@ -1026,12 +1270,13 @@ ${c.bold("Project link")}
|
|
|
1026
1270
|
process.stdout.write(`
|
|
1027
1271
|
${c.bold("Repo")}
|
|
1028
1272
|
`);
|
|
1273
|
+
let inGitRepo = false;
|
|
1029
1274
|
try {
|
|
1030
|
-
const branch =
|
|
1275
|
+
const branch = execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1031
1276
|
cwd: root,
|
|
1032
1277
|
encoding: "utf8"
|
|
1033
1278
|
}).trim();
|
|
1034
|
-
const dirty =
|
|
1279
|
+
const dirty = execFileSync3("git", ["status", "--porcelain"], {
|
|
1035
1280
|
cwd: root,
|
|
1036
1281
|
encoding: "utf8"
|
|
1037
1282
|
});
|
|
@@ -1041,10 +1286,58 @@ ${c.bold("Repo")}
|
|
|
1041
1286
|
` ${dirty.trim().length > 0 ? c.warn("working tree dirty") : c.ok("clean")}
|
|
1042
1287
|
`
|
|
1043
1288
|
);
|
|
1289
|
+
inGitRepo = true;
|
|
1044
1290
|
} catch {
|
|
1045
1291
|
process.stdout.write(` ${c.warn("!")} not inside a git repo
|
|
1046
1292
|
`);
|
|
1047
1293
|
}
|
|
1294
|
+
if (creds && project && inGitRepo) {
|
|
1295
|
+
const inFlight = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
1296
|
+
query: {
|
|
1297
|
+
project_id: project.project_id,
|
|
1298
|
+
ai_fix_status: "building",
|
|
1299
|
+
limit: 100
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
const inFlightTickets = inFlight.ok && inFlight.data ? inFlight.data : [];
|
|
1303
|
+
const localBranches = listLocalTicketBranches(root);
|
|
1304
|
+
const inFlightBranchNames = new Set(
|
|
1305
|
+
inFlightTickets.map((t) => branchSlug(t.sequence_number, t.title))
|
|
1306
|
+
);
|
|
1307
|
+
process.stdout.write(`
|
|
1308
|
+
${c.bold("In-flight tickets")}
|
|
1309
|
+
`);
|
|
1310
|
+
if (inFlightTickets.length === 0) {
|
|
1311
|
+
process.stdout.write(c.dim(" none\n"));
|
|
1312
|
+
} else {
|
|
1313
|
+
for (const t of inFlightTickets) {
|
|
1314
|
+
const expectedBranch = branchSlug(t.sequence_number, t.title);
|
|
1315
|
+
const branchPresent = localBranches.some((b) => b.name === expectedBranch);
|
|
1316
|
+
const tag = branchPresent ? c.ok("local branch present") : c.warn("local branch missing");
|
|
1317
|
+
process.stdout.write(
|
|
1318
|
+
` ${c.dim("\xB7")} #${t.sequence_number} "${t.title}" \u2014 ${tag} \u2192 ${c.cyan(`task resume #${t.sequence_number}`)}
|
|
1319
|
+
`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
const orphans = localBranches.filter((b) => !inFlightBranchNames.has(b.name));
|
|
1324
|
+
process.stdout.write(`
|
|
1325
|
+
${c.bold("Orphan task/* branches")}
|
|
1326
|
+
`);
|
|
1327
|
+
if (orphans.length === 0) {
|
|
1328
|
+
process.stdout.write(c.dim(" none\n"));
|
|
1329
|
+
} else {
|
|
1330
|
+
for (const b of orphans) {
|
|
1331
|
+
const upstream = b.hasUpstream ? c.dim(" (has upstream)") : c.dim(" (no upstream)");
|
|
1332
|
+
process.stdout.write(` ${c.dim("\xB7")} ${b.name}${upstream}
|
|
1333
|
+
`);
|
|
1334
|
+
}
|
|
1335
|
+
process.stdout.write(
|
|
1336
|
+
` ${c.dim("\u2192 run")} ${c.cyan("task reset")} ${c.dim("to clean up")}
|
|
1337
|
+
`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1048
1341
|
});
|
|
1049
1342
|
}
|
|
1050
1343
|
|
|
@@ -1277,7 +1570,7 @@ async function runAgent(args) {
|
|
|
1277
1570
|
}
|
|
1278
1571
|
|
|
1279
1572
|
// src/guardrail/diff-check.ts
|
|
1280
|
-
import { execFileSync as
|
|
1573
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
1281
1574
|
|
|
1282
1575
|
// src/guardrail/protected-paths.ts
|
|
1283
1576
|
import picomatch from "picomatch";
|
|
@@ -1336,7 +1629,7 @@ function checkDiff(args) {
|
|
|
1336
1629
|
}
|
|
1337
1630
|
function safeGitOutput(args, cwd) {
|
|
1338
1631
|
try {
|
|
1339
|
-
return
|
|
1632
|
+
return execFileSync4("git", args, { cwd, encoding: "utf8" });
|
|
1340
1633
|
} catch {
|
|
1341
1634
|
return "";
|
|
1342
1635
|
}
|
|
@@ -1345,186 +1638,8 @@ function splitLines(text) {
|
|
|
1345
1638
|
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
1346
1639
|
}
|
|
1347
1640
|
|
|
1348
|
-
// src/
|
|
1349
|
-
import { execFileSync as
|
|
1350
|
-
function commitOnly(args) {
|
|
1351
|
-
execFileSync3("git", ["add", "-A"], { cwd: args.cwd });
|
|
1352
|
-
const statusRaw = execFileSync3("git", ["status", "--porcelain"], {
|
|
1353
|
-
cwd: args.cwd,
|
|
1354
|
-
encoding: "utf8"
|
|
1355
|
-
});
|
|
1356
|
-
if (!statusRaw.trim()) {
|
|
1357
|
-
throw new Error("No changes to commit (empty diff)");
|
|
1358
|
-
}
|
|
1359
|
-
execFileSync3("git", ["commit", "-m", args.message], { cwd: args.cwd });
|
|
1360
|
-
const sha = execFileSync3("git", ["rev-parse", "HEAD"], {
|
|
1361
|
-
cwd: args.cwd,
|
|
1362
|
-
encoding: "utf8"
|
|
1363
|
-
}).trim();
|
|
1364
|
-
return { sha };
|
|
1365
|
-
}
|
|
1366
|
-
function currentBranch(cwd) {
|
|
1367
|
-
try {
|
|
1368
|
-
return execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1369
|
-
cwd,
|
|
1370
|
-
encoding: "utf8"
|
|
1371
|
-
}).trim();
|
|
1372
|
-
} catch {
|
|
1373
|
-
return "HEAD";
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// src/git/branch.ts
|
|
1378
|
-
import { execFileSync as execFileSync4 } from "child_process";
|
|
1379
|
-
var VALID_BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
1380
|
-
var TICKET_BRANCH = /^task\/[a-z0-9-]{1,80}$/;
|
|
1381
|
-
function assertValidBranchName(branch) {
|
|
1382
|
-
if (!VALID_BRANCH.test(branch) || branch.includes("..") || branch.startsWith("/") || branch.endsWith("/")) {
|
|
1383
|
-
throw new CliError(
|
|
1384
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1385
|
-
`Invalid branch name: ${branch}`,
|
|
1386
|
-
'Branch names must contain only [A-Za-z0-9._/-], no "..", and no leading/trailing slash.'
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
function isWorkingTreeClean(cwd) {
|
|
1391
|
-
const out = execFileSync4("git", ["status", "--porcelain"], {
|
|
1392
|
-
cwd,
|
|
1393
|
-
encoding: "utf8"
|
|
1394
|
-
});
|
|
1395
|
-
return out.trim().length === 0;
|
|
1396
|
-
}
|
|
1397
|
-
function assertBaseBranch(cwd, expected) {
|
|
1398
|
-
assertValidBranchName(expected);
|
|
1399
|
-
const current = currentBranch(cwd);
|
|
1400
|
-
if (current !== expected) {
|
|
1401
|
-
throw new CliError(
|
|
1402
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1403
|
-
`task work requires branch "${expected}" but you're on "${current}"`,
|
|
1404
|
-
`Run "git checkout ${expected}" first. The base branch is configured per project; ask an admin if it should be different.`
|
|
1405
|
-
);
|
|
1406
|
-
}
|
|
1407
|
-
if (!isWorkingTreeClean(cwd)) {
|
|
1408
|
-
throw new CliError(
|
|
1409
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1410
|
-
"Working tree is dirty",
|
|
1411
|
-
"Commit, stash, or discard your local changes before running task work."
|
|
1412
|
-
);
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
function createTicketBranch(cwd, branchName, baseBranch) {
|
|
1416
|
-
assertValidBranchName(branchName);
|
|
1417
|
-
assertValidBranchName(baseBranch);
|
|
1418
|
-
if (!TICKET_BRANCH.test(branchName)) {
|
|
1419
|
-
throw new CliError(
|
|
1420
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1421
|
-
`Per-ticket branch must match ^task/[a-z0-9-]{1,80}$ \u2014 got "${branchName}"`
|
|
1422
|
-
);
|
|
1423
|
-
}
|
|
1424
|
-
try {
|
|
1425
|
-
execFileSync4("git", ["checkout", "-b", branchName, baseBranch], {
|
|
1426
|
-
cwd,
|
|
1427
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1428
|
-
});
|
|
1429
|
-
} catch (err) {
|
|
1430
|
-
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1431
|
-
throw new CliError(
|
|
1432
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1433
|
-
`Could not create branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1434
|
-
);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
function deleteLocalBranch(cwd, branchName) {
|
|
1438
|
-
if (!VALID_BRANCH.test(branchName)) return;
|
|
1439
|
-
try {
|
|
1440
|
-
execFileSync4("git", ["branch", "-D", branchName], {
|
|
1441
|
-
cwd,
|
|
1442
|
-
stdio: ["ignore", "ignore", "ignore"]
|
|
1443
|
-
});
|
|
1444
|
-
} catch {
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
function checkoutBranch(cwd, branchName) {
|
|
1448
|
-
assertValidBranchName(branchName);
|
|
1449
|
-
try {
|
|
1450
|
-
execFileSync4("git", ["checkout", branchName], {
|
|
1451
|
-
cwd,
|
|
1452
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1453
|
-
});
|
|
1454
|
-
} catch (err) {
|
|
1455
|
-
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1456
|
-
throw new CliError(
|
|
1457
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1458
|
-
`Could not check out branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1459
|
-
);
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
function pushBranch(cwd, branchName) {
|
|
1463
|
-
assertValidBranchName(branchName);
|
|
1464
|
-
try {
|
|
1465
|
-
execFileSync4("git", ["push", "-u", "origin", branchName], {
|
|
1466
|
-
cwd,
|
|
1467
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1468
|
-
});
|
|
1469
|
-
return { remote: "origin" };
|
|
1470
|
-
} catch (err) {
|
|
1471
|
-
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1472
|
-
throw new CliError(
|
|
1473
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1474
|
-
`Push failed: ${stderr.slice(0, 600) || err.message}`
|
|
1475
|
-
);
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
function enforceBaseBranchClean(cwd, baseBranch, opts = {}) {
|
|
1479
|
-
assertValidBranchName(baseBranch);
|
|
1480
|
-
try {
|
|
1481
|
-
execFileSync4("git", ["restore", "--staged", "--worktree", "."], {
|
|
1482
|
-
cwd,
|
|
1483
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1484
|
-
});
|
|
1485
|
-
} catch {
|
|
1486
|
-
try {
|
|
1487
|
-
execFileSync4("git", ["reset", "--hard", "HEAD"], {
|
|
1488
|
-
cwd,
|
|
1489
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1490
|
-
});
|
|
1491
|
-
} catch {
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
try {
|
|
1495
|
-
execFileSync4("git", ["clean", "-fd"], {
|
|
1496
|
-
cwd,
|
|
1497
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1498
|
-
});
|
|
1499
|
-
} catch {
|
|
1500
|
-
}
|
|
1501
|
-
checkoutBranch(cwd, baseBranch);
|
|
1502
|
-
const current = currentBranch(cwd);
|
|
1503
|
-
if (current !== baseBranch) {
|
|
1504
|
-
throw new CliError(
|
|
1505
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1506
|
-
`Branch enforcement failed: expected "${baseBranch}", on "${current}"`,
|
|
1507
|
-
`Run "git checkout ${baseBranch}" manually, then re-run task multi-work.`
|
|
1508
|
-
);
|
|
1509
|
-
}
|
|
1510
|
-
if (!isWorkingTreeClean(cwd)) {
|
|
1511
|
-
throw new CliError(
|
|
1512
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1513
|
-
"Branch enforcement failed: working tree is dirty after returning to base",
|
|
1514
|
-
'Inspect with "git status" and clean up before re-running task multi-work.'
|
|
1515
|
-
);
|
|
1516
|
-
}
|
|
1517
|
-
if (opts.deleteBranch && opts.deleteBranch !== baseBranch) {
|
|
1518
|
-
deleteLocalBranch(cwd, opts.deleteBranch);
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
function branchSlug(sequenceNumber, title) {
|
|
1522
|
-
const safeTitle = title.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1523
|
-
const slugBudget = 70;
|
|
1524
|
-
const truncatedSlug = safeTitle.slice(0, slugBudget);
|
|
1525
|
-
const tail = truncatedSlug.length > 0 ? `-${truncatedSlug}` : "";
|
|
1526
|
-
return `task/${sequenceNumber}${tail}`.replace(/-+$/, "");
|
|
1527
|
-
}
|
|
1641
|
+
// src/commands/work.ts
|
|
1642
|
+
import { execFileSync as execFileSync6 } from "child_process";
|
|
1528
1643
|
|
|
1529
1644
|
// src/git/restore.ts
|
|
1530
1645
|
import { execFileSync as execFileSync5 } from "child_process";
|
|
@@ -1636,7 +1751,13 @@ async function buildWorkContext(opts) {
|
|
|
1636
1751
|
};
|
|
1637
1752
|
}
|
|
1638
1753
|
function registerWork(program2) {
|
|
1639
|
-
program2.command("work [ticketId]").description("Run the agent on a CLI-approved ticket \u2014 cuts a per-ticket branch and opens a PR").option("--auto", "Pick the next eligible ticket without prompting").option("--next", "Alias for --auto --max 1").option("--dry-run", "Run the agent + tests but do not commit, push, or open a PR").option("--no-push", "[deprecated in Phase 2 \u2014 task work always pushes via per-ticket branch]").option("--max <n>", "Process up to N tickets in this invocation", "1").option("--silent", "Suppress TTY output (used by scheduled tasks)").option(
|
|
1754
|
+
program2.command("work [ticketId]").description("Run the agent on a CLI-approved ticket \u2014 cuts a per-ticket branch and opens a PR").option("--auto", "Pick the next eligible ticket without prompting").option("--next", "Alias for --auto --max 1").option("--dry-run", "Run the agent + tests but do not commit, push, or open a PR").option("--no-push", "[deprecated in Phase 2 \u2014 task work always pushes via per-ticket branch]").option("--max <n>", "Process up to N tickets in this invocation", "1").option("--silent", "Suppress TTY output (used by scheduled tasks)").option(
|
|
1755
|
+
"--reset",
|
|
1756
|
+
"DESTRUCTIVE: discard all local working-tree changes before running. Requires interactive y/n; pair with --confirm in non-TTY contexts."
|
|
1757
|
+
).option(
|
|
1758
|
+
"--confirm",
|
|
1759
|
+
"Confirm --reset in non-TTY (silent / scheduled-task) contexts. Has no effect without --reset."
|
|
1760
|
+
).option("--schedule-id <id>", "Internal: schedule id when invoked from a scheduled task").action(async (ticketId, opts) => {
|
|
1640
1761
|
await runWork(ticketId, opts);
|
|
1641
1762
|
});
|
|
1642
1763
|
}
|
|
@@ -1668,6 +1789,9 @@ async function runWork(ticketId, opts) {
|
|
|
1668
1789
|
}
|
|
1669
1790
|
async function processOneTicket(ctx, opts, ticketIdHint) {
|
|
1670
1791
|
const { cwd, baseBranch, silent } = ctx;
|
|
1792
|
+
if (opts.reset) {
|
|
1793
|
+
await purgeWorkingTreeWithConsent(ctx, opts);
|
|
1794
|
+
}
|
|
1671
1795
|
try {
|
|
1672
1796
|
assertBaseBranch(cwd, baseBranch);
|
|
1673
1797
|
} catch (err) {
|
|
@@ -1695,15 +1819,36 @@ async function processOneTicket(ctx, opts, ticketIdHint) {
|
|
|
1695
1819
|
}
|
|
1696
1820
|
const branchName = branchSlug(detail.sequence_number, detail.title);
|
|
1697
1821
|
const testCommand = detail.project_cli_test_command ?? null;
|
|
1822
|
+
const ticketBaseBranch = detail.project_cli_base_branch || baseBranch;
|
|
1698
1823
|
if (!silent) {
|
|
1699
1824
|
process.stdout.write(`
|
|
1700
1825
|
${c.bold(`#${detail.sequence_number}: ${detail.title}`)}
|
|
1701
1826
|
`);
|
|
1702
|
-
process.stdout.write(c.dim(` base branch: ${
|
|
1827
|
+
process.stdout.write(c.dim(` base branch: ${ticketBaseBranch}
|
|
1703
1828
|
`));
|
|
1704
1829
|
process.stdout.write(c.dim(` ticket branch: ${branchName}
|
|
1705
1830
|
`));
|
|
1706
1831
|
}
|
|
1832
|
+
try {
|
|
1833
|
+
if (!remoteBranchExists(cwd, ticketBaseBranch)) {
|
|
1834
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1835
|
+
body: {
|
|
1836
|
+
ticket_id: detail.id,
|
|
1837
|
+
schedule_id: opts.scheduleId,
|
|
1838
|
+
event: "branch_check_failed",
|
|
1839
|
+
output_excerpt: `base branch "${ticketBaseBranch}" missing on origin`
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
throw new CliError(
|
|
1843
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1844
|
+
`Base branch "${ticketBaseBranch}" does not exist on origin`,
|
|
1845
|
+
`Push it (\`git push origin ${ticketBaseBranch}\`) or update the project's cli_base_branch via the dashboard / PATCH /api/v1/projects/<id>.`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
if (err instanceof CliError) throw err;
|
|
1850
|
+
throw err;
|
|
1851
|
+
}
|
|
1707
1852
|
const runId = randomUUID();
|
|
1708
1853
|
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1709
1854
|
body: {
|
|
@@ -1714,18 +1859,44 @@ ${c.bold(`#${detail.sequence_number}: ${detail.title}`)}
|
|
|
1714
1859
|
}
|
|
1715
1860
|
});
|
|
1716
1861
|
try {
|
|
1717
|
-
createTicketBranch(cwd, branchName,
|
|
1862
|
+
createTicketBranch(cwd, branchName, ticketBaseBranch);
|
|
1718
1863
|
} catch (err) {
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1864
|
+
const stderr = err instanceof Error ? err.message : String(err);
|
|
1865
|
+
const looksLikeAlreadyExists = /already exists/i.test(stderr);
|
|
1866
|
+
if (looksLikeAlreadyExists) {
|
|
1867
|
+
const recovered = await tryAutoCleanOrphanBranch(
|
|
1868
|
+
ctx,
|
|
1869
|
+
detail,
|
|
1870
|
+
branchName,
|
|
1871
|
+
ticketBaseBranch,
|
|
1872
|
+
runId,
|
|
1873
|
+
opts.scheduleId
|
|
1874
|
+
);
|
|
1875
|
+
if (recovered === "recreated") {
|
|
1876
|
+
} else {
|
|
1877
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1878
|
+
body: {
|
|
1879
|
+
ticket_id: detail.id,
|
|
1880
|
+
schedule_id: opts.scheduleId,
|
|
1881
|
+
event: "branch_check_failed",
|
|
1882
|
+
claude_session_id: runId,
|
|
1883
|
+
output_excerpt: stderr.slice(0, 4e3)
|
|
1884
|
+
}
|
|
1885
|
+
});
|
|
1886
|
+
throw err;
|
|
1726
1887
|
}
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1888
|
+
} else {
|
|
1889
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1890
|
+
body: {
|
|
1891
|
+
ticket_id: detail.id,
|
|
1892
|
+
schedule_id: opts.scheduleId,
|
|
1893
|
+
event: "branch_check_failed",
|
|
1894
|
+
claude_session_id: runId,
|
|
1895
|
+
output_excerpt: stderr.slice(0, 4e3)
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
throw err;
|
|
1899
|
+
}
|
|
1729
1900
|
}
|
|
1730
1901
|
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1731
1902
|
body: {
|
|
@@ -1973,25 +2144,33 @@ Claude session: ${runId}
|
|
|
1973
2144
|
body: {
|
|
1974
2145
|
ticket_id: detail.id,
|
|
1975
2146
|
schedule_id: opts.scheduleId,
|
|
1976
|
-
event: "
|
|
2147
|
+
event: "push_failed",
|
|
1977
2148
|
claude_session_id: runId,
|
|
1978
2149
|
output_excerpt: err.message.slice(0, 4e3)
|
|
1979
2150
|
}
|
|
1980
2151
|
});
|
|
2152
|
+
if (err instanceof CliError) err.phase = "post_push";
|
|
1981
2153
|
throw err;
|
|
1982
2154
|
}
|
|
1983
2155
|
if (!silent)
|
|
1984
2156
|
process.stdout.write(`${c.ok("\u2713 Pushed")} ${branchName} (${commitSha.slice(0, 12)})
|
|
1985
2157
|
`);
|
|
1986
2158
|
const prTitle = `task #${detail.sequence_number}: ${detail.title}`.slice(0, 200);
|
|
1987
|
-
const prBody = buildPrBody({
|
|
2159
|
+
const prBody = buildPrBody({
|
|
2160
|
+
detail,
|
|
2161
|
+
runId,
|
|
2162
|
+
commitSha,
|
|
2163
|
+
branchName,
|
|
2164
|
+
baseBranch: ticketBaseBranch,
|
|
2165
|
+
testResult
|
|
2166
|
+
});
|
|
1988
2167
|
let prNumber;
|
|
1989
2168
|
let prUrl;
|
|
1990
2169
|
try {
|
|
1991
2170
|
const prResp = await apiCallOrThrow("POST", `/api/v1/cli/me/tickets/${detail.id}/pull-requests`, {
|
|
1992
2171
|
body: {
|
|
1993
2172
|
source_branch: branchName,
|
|
1994
|
-
base_branch:
|
|
2173
|
+
base_branch: ticketBaseBranch,
|
|
1995
2174
|
title: prTitle,
|
|
1996
2175
|
body: prBody
|
|
1997
2176
|
}
|
|
@@ -2009,7 +2188,7 @@ Claude session: ${runId}
|
|
|
2009
2188
|
});
|
|
2010
2189
|
if (!silent) {
|
|
2011
2190
|
process.stdout.write(
|
|
2012
|
-
`${c.ok("\u2713 PR opened")} ${c.cyan(prResp.pr_url)} \u2192 ${
|
|
2191
|
+
`${c.ok("\u2713 PR opened")} ${c.cyan(prResp.pr_url)} \u2192 ${ticketBaseBranch}
|
|
2013
2192
|
` + (prResp.ticket_status_advanced ? c.dim(` Ticket status auto-advanced to 'git_review'.
|
|
2014
2193
|
`) : "")
|
|
2015
2194
|
);
|
|
@@ -2024,6 +2203,7 @@ Claude session: ${runId}
|
|
|
2024
2203
|
output_excerpt: err.message.slice(0, 4e3)
|
|
2025
2204
|
}
|
|
2026
2205
|
});
|
|
2206
|
+
if (err instanceof CliError) err.phase = "post_push";
|
|
2027
2207
|
throw err;
|
|
2028
2208
|
}
|
|
2029
2209
|
try {
|
|
@@ -2063,6 +2243,127 @@ function buildPrBody(args) {
|
|
|
2063
2243
|
"Please review carefully \u2014 this is an AI-generated change."
|
|
2064
2244
|
].filter(Boolean).join("\n");
|
|
2065
2245
|
}
|
|
2246
|
+
async function purgeWorkingTreeWithConsent(ctx, opts) {
|
|
2247
|
+
const { cwd, baseBranch, silent } = ctx;
|
|
2248
|
+
const isTty = !silent && process.stdin.isTTY === true;
|
|
2249
|
+
const status = (() => {
|
|
2250
|
+
try {
|
|
2251
|
+
return execFileSync6("git", ["status", "--short"], { cwd, encoding: "utf8" }).trim();
|
|
2252
|
+
} catch {
|
|
2253
|
+
return "";
|
|
2254
|
+
}
|
|
2255
|
+
})();
|
|
2256
|
+
const onBranch = (() => {
|
|
2257
|
+
try {
|
|
2258
|
+
return currentBranch(cwd);
|
|
2259
|
+
} catch {
|
|
2260
|
+
return "(unknown)";
|
|
2261
|
+
}
|
|
2262
|
+
})();
|
|
2263
|
+
const willSwitchBranch = onBranch !== baseBranch && onBranch !== "(unknown)";
|
|
2264
|
+
if (!isTty) {
|
|
2265
|
+
if (!opts.confirm) {
|
|
2266
|
+
throw new CliError(
|
|
2267
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2268
|
+
"--reset requires --confirm in non-interactive (silent / scheduled-task) contexts",
|
|
2269
|
+
"Re-run with both flags only after confirming the destructive purge is intentional."
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
if (!silent) {
|
|
2273
|
+
process.stdout.write(c.dim(" --reset --confirm: discarding working-tree changes\n"));
|
|
2274
|
+
if (willSwitchBranch) {
|
|
2275
|
+
process.stdout.write(
|
|
2276
|
+
c.dim(` --reset --confirm: also switching from "${onBranch}" to "${baseBranch}"
|
|
2277
|
+
`)
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
} else {
|
|
2282
|
+
if (!silent) {
|
|
2283
|
+
process.stdout.write(`${c.bold("Current branch:")} ${c.cyan(onBranch)}
|
|
2284
|
+
`);
|
|
2285
|
+
process.stdout.write(`${c.bold("Working tree changes:")}
|
|
2286
|
+
`);
|
|
2287
|
+
process.stdout.write(status.length > 0 ? `${c.dim(status)}
|
|
2288
|
+
` : c.dim(" (none)\n"));
|
|
2289
|
+
process.stdout.write(`
|
|
2290
|
+
${c.bold("--reset will:")}
|
|
2291
|
+
`);
|
|
2292
|
+
process.stdout.write(
|
|
2293
|
+
c.dim(" \u2022 discard ALL working-tree changes above (git restore --staged --worktree .)\n")
|
|
2294
|
+
);
|
|
2295
|
+
process.stdout.write(c.dim(" \u2022 delete untracked files + directories (git clean -fd)\n"));
|
|
2296
|
+
if (willSwitchBranch) {
|
|
2297
|
+
process.stdout.write(
|
|
2298
|
+
c.dim(` \u2022 switch from ${c.cyan(onBranch)} ${c.dim("to")} ${c.cyan(baseBranch)}
|
|
2299
|
+
`)
|
|
2300
|
+
);
|
|
2301
|
+
} else {
|
|
2302
|
+
process.stdout.write(
|
|
2303
|
+
c.dim(` \u2022 stay on ${c.cyan(baseBranch)} ${c.dim("(already on base)")}
|
|
2304
|
+
`)
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
process.stdout.write("\n");
|
|
2308
|
+
}
|
|
2309
|
+
const answer = await inquirer2.prompt([
|
|
2310
|
+
{ type: "confirm", name: "confirm", message: "Proceed?", default: false }
|
|
2311
|
+
]);
|
|
2312
|
+
if (!answer.confirm) {
|
|
2313
|
+
if (!silent) process.stdout.write(c.dim("Aborted \u2014 working tree untouched.\n"));
|
|
2314
|
+
process.exit(CLI_EXIT_CODES.SUCCESS);
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
enforceBaseBranchClean(cwd, baseBranch);
|
|
2318
|
+
}
|
|
2319
|
+
async function tryAutoCleanOrphanBranch(ctx, detail, branchName, ticketBaseBranch, runId, scheduleId) {
|
|
2320
|
+
const inFlight = await apiCall(
|
|
2321
|
+
"GET",
|
|
2322
|
+
"/api/v1/cli/me/tickets",
|
|
2323
|
+
{
|
|
2324
|
+
query: {
|
|
2325
|
+
project_id: ctx.project.project_id,
|
|
2326
|
+
ai_fix_status: "building",
|
|
2327
|
+
limit: 100
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
);
|
|
2331
|
+
const claimedByMe = inFlight.ok && inFlight.data ? inFlight.data.some((t) => t.sequence_number === detail.sequence_number) : false;
|
|
2332
|
+
if (claimedByMe) {
|
|
2333
|
+
if (!ctx.silent) {
|
|
2334
|
+
process.stderr.write(
|
|
2335
|
+
`${c.dim(" Local branch matches an in-flight ticket; refusing to auto-delete.")}
|
|
2336
|
+
${c.dim(` Run \`task resume #${detail.sequence_number}\` to retry the PR for the existing branch.`)}
|
|
2337
|
+
`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
return "in_flight";
|
|
2341
|
+
}
|
|
2342
|
+
try {
|
|
2343
|
+
deleteLocalBranch(ctx.cwd, branchName);
|
|
2344
|
+
createTicketBranch(ctx.cwd, branchName, ticketBaseBranch);
|
|
2345
|
+
} catch {
|
|
2346
|
+
return "failed";
|
|
2347
|
+
}
|
|
2348
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
2349
|
+
body: {
|
|
2350
|
+
ticket_id: detail.id,
|
|
2351
|
+
schedule_id: scheduleId,
|
|
2352
|
+
event: "branch_check_failed",
|
|
2353
|
+
claude_session_id: runId,
|
|
2354
|
+
output_excerpt: "auto-cleaned-orphan-and-recreated"
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
if (!ctx.silent) {
|
|
2358
|
+
process.stdout.write(
|
|
2359
|
+
c.dim(
|
|
2360
|
+
` Auto-cleaned orphan local branch '${branchName}' and re-cut from ${ticketBaseBranch}.
|
|
2361
|
+
`
|
|
2362
|
+
)
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
return "recreated";
|
|
2366
|
+
}
|
|
2066
2367
|
async function pickNextEligible(projectId) {
|
|
2067
2368
|
const result = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
2068
2369
|
query: { project_id: projectId, limit: 1 }
|
|
@@ -2109,20 +2410,26 @@ function registerMultiWork(program2) {
|
|
|
2109
2410
|
).option("--max <n>", "Process up to N tickets in this batch", "10").option("--dry-run", "Run the agent + tests but do not commit, push, or open PRs").option("--silent", "Suppress TTY output (used by scheduled tasks)").option(
|
|
2110
2411
|
"--abort-on-failure",
|
|
2111
2412
|
"Stop the batch on the first per-ticket failure (default: skip and continue)"
|
|
2112
|
-
).option(
|
|
2413
|
+
).option(
|
|
2414
|
+
"--reset",
|
|
2415
|
+
"DESTRUCTIVE: discard local working-tree changes before the first ticket. Requires --confirm in non-TTY contexts."
|
|
2416
|
+
).option("--confirm", "Confirm --reset in non-TTY (silent / scheduled-task) contexts.").option("--schedule-id <id>", "Internal: schedule id when invoked from a scheduled task").action(async (opts) => {
|
|
2113
2417
|
await runMultiWork(opts);
|
|
2114
2418
|
});
|
|
2115
2419
|
}
|
|
2116
2420
|
async function runMultiWork(opts) {
|
|
2117
2421
|
const ctx = await buildWorkContext({ max: opts.max, silent: opts.silent });
|
|
2118
2422
|
const max = Math.max(1, parseInt(opts.max, 10) || 10);
|
|
2423
|
+
let firstIteration = true;
|
|
2119
2424
|
const innerOpts = {
|
|
2120
2425
|
auto: true,
|
|
2121
2426
|
next: false,
|
|
2122
2427
|
dryRun: opts.dryRun,
|
|
2123
2428
|
max: "1",
|
|
2124
2429
|
silent: opts.silent,
|
|
2125
|
-
scheduleId: opts.scheduleId
|
|
2430
|
+
scheduleId: opts.scheduleId,
|
|
2431
|
+
reset: opts.reset,
|
|
2432
|
+
confirm: opts.confirm
|
|
2126
2433
|
};
|
|
2127
2434
|
const results = [];
|
|
2128
2435
|
let processed = 0;
|
|
@@ -2137,11 +2444,28 @@ async function runMultiWork(opts) {
|
|
|
2137
2444
|
let caughtError = null;
|
|
2138
2445
|
try {
|
|
2139
2446
|
outcome = await processOneTicket(ctx, innerOpts, null);
|
|
2447
|
+
if (firstIteration) {
|
|
2448
|
+
firstIteration = false;
|
|
2449
|
+
innerOpts.reset = false;
|
|
2450
|
+
innerOpts.confirm = false;
|
|
2451
|
+
}
|
|
2140
2452
|
} catch (err) {
|
|
2453
|
+
if (firstIteration) {
|
|
2454
|
+
firstIteration = false;
|
|
2455
|
+
innerOpts.reset = false;
|
|
2456
|
+
innerOpts.confirm = false;
|
|
2457
|
+
}
|
|
2141
2458
|
caughtError = err instanceof Error ? err.message : String(err);
|
|
2459
|
+
const phaseTag = err instanceof CliError && err.phase === "post_push" ? "post_push" : "pre_push";
|
|
2142
2460
|
if (!ctx.silent) {
|
|
2143
2461
|
process.stderr.write(`${c.err("\u2717 Ticket failed")}: ${caughtError}
|
|
2144
2462
|
`);
|
|
2463
|
+
if (phaseTag === "post_push") {
|
|
2464
|
+
process.stderr.write(
|
|
2465
|
+
`${c.dim(" Branch kept on disk + remote \u2014 run `task resume` to retry the PR.")}
|
|
2466
|
+
`
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2145
2469
|
}
|
|
2146
2470
|
if (opts.abortOnFailure) {
|
|
2147
2471
|
try {
|
|
@@ -2224,6 +2548,356 @@ ${c.bold("Batch summary")}
|
|
|
2224
2548
|
);
|
|
2225
2549
|
}
|
|
2226
2550
|
|
|
2551
|
+
// src/commands/resume.ts
|
|
2552
|
+
import { execFileSync as execFileSync7 } from "child_process";
|
|
2553
|
+
import inquirer3 from "inquirer";
|
|
2554
|
+
function registerResume(program2) {
|
|
2555
|
+
program2.command("resume [ticketRef]").description(
|
|
2556
|
+
"Resume a ticket whose previous `task work` run failed after the per-ticket branch was pushed (i.e. ai_fix_status='building'). Re-pushes the branch and re-attempts the PR \u2014 idempotent end-to-end."
|
|
2557
|
+
).option("--silent", "Suppress TTY output").action(async (ticketRef, opts) => {
|
|
2558
|
+
await runResume(ticketRef, opts);
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
async function runResume(ticketRef, opts) {
|
|
2562
|
+
const cwd = findRepoRoot();
|
|
2563
|
+
const project = await readProjectConfig(cwd);
|
|
2564
|
+
if (!project) {
|
|
2565
|
+
throw new CliError(
|
|
2566
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2567
|
+
"No project link in this repo",
|
|
2568
|
+
"Run 'task link' first."
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
const baseBranch = project.cli_base_branch ?? "development";
|
|
2572
|
+
const silent = !!opts.silent;
|
|
2573
|
+
assertBaseBranch(cwd, baseBranch);
|
|
2574
|
+
const access2 = await apiCallOrThrow("GET", "/api/v1/cli/access");
|
|
2575
|
+
const ticketId = await resolveTicketId(project, ticketRef, silent);
|
|
2576
|
+
if (!ticketId) {
|
|
2577
|
+
if (!silent) {
|
|
2578
|
+
process.stdout.write(c.dim("No in-flight tickets to resume.\n"));
|
|
2579
|
+
}
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
const detail = await apiCallOrThrow(
|
|
2583
|
+
"GET",
|
|
2584
|
+
`/api/v1/cli/me/tickets/${ticketId}`
|
|
2585
|
+
);
|
|
2586
|
+
if (detail.project_id !== project.project_id) {
|
|
2587
|
+
throw new CliError(
|
|
2588
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2589
|
+
`Ticket #${detail.sequence_number} belongs to a different project than this repo's link`,
|
|
2590
|
+
"cd to the project repo this ticket belongs to (or re-link with `task link`) and retry."
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
if (detail.ai_fix_status !== "building") {
|
|
2594
|
+
throw new CliError(
|
|
2595
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
2596
|
+
`Ticket #${detail.sequence_number} is in ai_fix_status='${detail.ai_fix_status}', not 'building' \u2014 nothing to resume`,
|
|
2597
|
+
"A previous resume attempt may already have succeeded. Check the dashboard for the ticket's PR."
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
if (detail.claimed_by_user_id && detail.claimed_by_user_id !== access2.user_id) {
|
|
2601
|
+
throw new CliError(
|
|
2602
|
+
CLI_EXIT_CODES.UNAUTHORISED,
|
|
2603
|
+
`Ticket #${detail.sequence_number} is claimed by another user`,
|
|
2604
|
+
'Ask that user to resume it, or have an admin reset ai_fix_status to "approved" so the ticket can be re-claimed fresh.'
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2607
|
+
const branchName = branchSlug(detail.sequence_number, detail.title);
|
|
2608
|
+
const ticketBaseBranch = detail.project_cli_base_branch || baseBranch;
|
|
2609
|
+
if (!localBranchExists(cwd, branchName)) {
|
|
2610
|
+
throw new CliError(
|
|
2611
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
2612
|
+
`Local branch '${branchName}' is missing \u2014 cannot resume`,
|
|
2613
|
+
'The branch was deleted (or never existed locally). Ask an admin to set ai_fix_status back to "approved" so `task work` can produce a fresh fix.'
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
if (!isAncestor(cwd, ticketBaseBranch, branchName)) {
|
|
2617
|
+
throw new CliError(
|
|
2618
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
2619
|
+
`Base branch '${ticketBaseBranch}' is no longer an ancestor of '${branchName}' \u2014 cannot resume`,
|
|
2620
|
+
'The base branch has moved (rebase/force-push). Have an admin reset ai_fix_status to "approved" and re-run `task work` for a fresh branch.'
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
if (!silent) {
|
|
2624
|
+
process.stdout.write(`
|
|
2625
|
+
${c.bold(`Resuming #${detail.sequence_number}: ${detail.title}`)}
|
|
2626
|
+
`);
|
|
2627
|
+
process.stdout.write(c.dim(` branch: ${branchName} \u2192 ${ticketBaseBranch}
|
|
2628
|
+
`));
|
|
2629
|
+
}
|
|
2630
|
+
checkoutBranch(cwd, branchName);
|
|
2631
|
+
try {
|
|
2632
|
+
pushBranch(cwd, branchName);
|
|
2633
|
+
if (!silent) process.stdout.write(c.dim(" \u2713 pushed (idempotent)\n"));
|
|
2634
|
+
} catch (err) {
|
|
2635
|
+
try {
|
|
2636
|
+
checkoutBranch(cwd, baseBranch);
|
|
2637
|
+
} catch {
|
|
2638
|
+
}
|
|
2639
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
2640
|
+
body: {
|
|
2641
|
+
ticket_id: detail.id,
|
|
2642
|
+
event: "push_failed",
|
|
2643
|
+
output_excerpt: err.message.slice(0, 4e3)
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
throw err;
|
|
2647
|
+
}
|
|
2648
|
+
const prTitle = `task #${detail.sequence_number}: ${detail.title}`.slice(0, 200);
|
|
2649
|
+
const prBody = buildResumePrBody(detail, branchName, ticketBaseBranch);
|
|
2650
|
+
let prResp;
|
|
2651
|
+
try {
|
|
2652
|
+
prResp = await apiCallOrThrow("POST", `/api/v1/cli/me/tickets/${detail.id}/pull-requests`, {
|
|
2653
|
+
body: {
|
|
2654
|
+
source_branch: branchName,
|
|
2655
|
+
base_branch: ticketBaseBranch,
|
|
2656
|
+
title: prTitle,
|
|
2657
|
+
body: prBody
|
|
2658
|
+
}
|
|
2659
|
+
});
|
|
2660
|
+
} catch (err) {
|
|
2661
|
+
try {
|
|
2662
|
+
checkoutBranch(cwd, baseBranch);
|
|
2663
|
+
} catch {
|
|
2664
|
+
}
|
|
2665
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
2666
|
+
body: {
|
|
2667
|
+
ticket_id: detail.id,
|
|
2668
|
+
event: "pr_failed",
|
|
2669
|
+
output_excerpt: err.message.slice(0, 4e3)
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
throw err;
|
|
2673
|
+
}
|
|
2674
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
2675
|
+
body: {
|
|
2676
|
+
ticket_id: detail.id,
|
|
2677
|
+
event: "resumed",
|
|
2678
|
+
output_excerpt: `${prResp.recovered ? "recovered" : "opened"} PR #${prResp.pr_number}: ${prResp.pr_url}`
|
|
2679
|
+
}
|
|
2680
|
+
});
|
|
2681
|
+
if (!silent) {
|
|
2682
|
+
const tag = prResp.recovered ? c.ok("\u2713 Recovered PR") : c.ok("\u2713 PR opened");
|
|
2683
|
+
process.stdout.write(`${tag} ${c.cyan(prResp.pr_url)}
|
|
2684
|
+
`);
|
|
2685
|
+
}
|
|
2686
|
+
try {
|
|
2687
|
+
checkoutBranch(cwd, baseBranch);
|
|
2688
|
+
} catch {
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
async function resolveTicketId(project, ticketRef, silent) {
|
|
2692
|
+
if (ticketRef && ticketRef.length > 0) {
|
|
2693
|
+
if (/^[0-9a-f-]{36}$/i.test(ticketRef)) return ticketRef;
|
|
2694
|
+
const seqMatch = ticketRef.match(/^#?(\d+)$/);
|
|
2695
|
+
if (seqMatch) {
|
|
2696
|
+
const result2 = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
2697
|
+
query: { project_id: project.project_id, ai_fix_status: "building", limit: 100 }
|
|
2698
|
+
});
|
|
2699
|
+
if (!result2.ok || !result2.data) {
|
|
2700
|
+
throw new CliError(
|
|
2701
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
2702
|
+
`Could not list in-flight tickets (HTTP ${result2.status})${result2.error?.message ? `: ${result2.error.message}` : ""}`
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
const seq = parseInt(seqMatch[1] ?? "", 10);
|
|
2706
|
+
const match = result2.data.find((t) => t.sequence_number === seq);
|
|
2707
|
+
if (!match) {
|
|
2708
|
+
throw new CliError(
|
|
2709
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
2710
|
+
`No in-flight ticket #${seq} claimed by you on this project`,
|
|
2711
|
+
"Run `task doctor` to list your in-flight tickets, or pass the UUID directly."
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
return match.id;
|
|
2715
|
+
}
|
|
2716
|
+
throw new CliError(
|
|
2717
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2718
|
+
`Invalid ticket reference: ${ticketRef}`,
|
|
2719
|
+
"Pass either a UUID or a sequence number like #42."
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
const result = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
2723
|
+
query: { project_id: project.project_id, ai_fix_status: "building", limit: 100 }
|
|
2724
|
+
});
|
|
2725
|
+
if (!result.ok) {
|
|
2726
|
+
throw new CliError(
|
|
2727
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
2728
|
+
`Could not list in-flight tickets (HTTP ${result.status})${result.error?.message ? `: ${result.error.message}` : ""}`
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
if (!result.data || result.data.length === 0) return null;
|
|
2732
|
+
if (result.data.length === 1) {
|
|
2733
|
+
const only = result.data[0];
|
|
2734
|
+
return only ? only.id : null;
|
|
2735
|
+
}
|
|
2736
|
+
if (silent) {
|
|
2737
|
+
throw new CliError(
|
|
2738
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2739
|
+
`${result.data.length} in-flight tickets \u2014 pass a specific reference in silent mode`,
|
|
2740
|
+
"Run `task doctor` to see the list, then `task resume #N` for the one you want."
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
const answer = await inquirer3.prompt([
|
|
2744
|
+
{
|
|
2745
|
+
type: "list",
|
|
2746
|
+
name: "ticketId",
|
|
2747
|
+
message: "Pick an in-flight ticket to resume:",
|
|
2748
|
+
choices: result.data.map((t) => ({
|
|
2749
|
+
name: `#${t.sequence_number} \u2014 ${t.title}`,
|
|
2750
|
+
value: t.id
|
|
2751
|
+
}))
|
|
2752
|
+
}
|
|
2753
|
+
]);
|
|
2754
|
+
return answer.ticketId;
|
|
2755
|
+
}
|
|
2756
|
+
function localBranchExists(cwd, branchName) {
|
|
2757
|
+
try {
|
|
2758
|
+
execFileSync7("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
|
|
2759
|
+
cwd,
|
|
2760
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
2761
|
+
});
|
|
2762
|
+
return true;
|
|
2763
|
+
} catch {
|
|
2764
|
+
return false;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
function isAncestor(cwd, ancestor, descendant) {
|
|
2768
|
+
try {
|
|
2769
|
+
execFileSync7("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
|
|
2770
|
+
cwd,
|
|
2771
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
2772
|
+
});
|
|
2773
|
+
return true;
|
|
2774
|
+
} catch {
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
function buildResumePrBody(detail, branchName, baseBranch) {
|
|
2779
|
+
return [
|
|
2780
|
+
`Resolves ticket #${detail.sequence_number}: ${detail.title}`,
|
|
2781
|
+
"",
|
|
2782
|
+
detail.description ? `> ${detail.description.slice(0, 1500)}` : "",
|
|
2783
|
+
"",
|
|
2784
|
+
"---",
|
|
2785
|
+
"",
|
|
2786
|
+
`**Generated by:** \`task resume\` (recovery of a previous \`task work\` run)`,
|
|
2787
|
+
`**Branch:** \`${branchName}\` \u2190 \`${baseBranch}\``,
|
|
2788
|
+
"",
|
|
2789
|
+
"Please review carefully \u2014 this is an AI-generated change."
|
|
2790
|
+
].filter(Boolean).join("\n");
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// src/commands/reset.ts
|
|
2794
|
+
import inquirer4 from "inquirer";
|
|
2795
|
+
function registerReset(program2) {
|
|
2796
|
+
program2.command("reset").alias("r").description(
|
|
2797
|
+
"Delete orphan local task/* branches \u2014 branches the CLI created locally but that are no longer in your in-flight (`ai_fix_status='building'`) list. Interactive y/n in TTY; pair with --confirm in non-TTY contexts."
|
|
2798
|
+
).option("--silent", "Suppress TTY output").option("--confirm", "Confirm deletion in non-TTY (silent / scheduled-task) contexts").action(async (opts) => {
|
|
2799
|
+
await runReset(opts);
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
async function runReset(opts) {
|
|
2803
|
+
const cwd = findRepoRoot();
|
|
2804
|
+
const project = await readProjectConfig(cwd);
|
|
2805
|
+
if (!project) {
|
|
2806
|
+
throw new CliError(
|
|
2807
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2808
|
+
"No project link in this repo",
|
|
2809
|
+
"Run 'task link' first."
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
const silent = !!opts.silent;
|
|
2813
|
+
const localBranches = listLocalTicketBranches(cwd);
|
|
2814
|
+
if (localBranches.length === 0) {
|
|
2815
|
+
if (!silent) process.stdout.write(c.dim("No local task/* branches \u2014 nothing to clean up.\n"));
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
const inFlight = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
2819
|
+
query: { project_id: project.project_id, ai_fix_status: "building", limit: 100 }
|
|
2820
|
+
});
|
|
2821
|
+
const inFlightBranchNames = /* @__PURE__ */ new Set();
|
|
2822
|
+
if (inFlight.ok && inFlight.data) {
|
|
2823
|
+
for (const t of inFlight.data) {
|
|
2824
|
+
inFlightBranchNames.add(branchSlug(t.sequence_number, t.title));
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
const orphans = localBranches.filter((b) => !inFlightBranchNames.has(b.name));
|
|
2828
|
+
const kept = localBranches.filter((b) => inFlightBranchNames.has(b.name));
|
|
2829
|
+
if (orphans.length === 0) {
|
|
2830
|
+
if (!silent) {
|
|
2831
|
+
process.stdout.write(c.dim("No orphan task/* branches.\n"));
|
|
2832
|
+
if (kept.length > 0) {
|
|
2833
|
+
process.stdout.write(
|
|
2834
|
+
c.dim(
|
|
2835
|
+
` ${kept.length} in-flight branch(es) preserved: ${kept.map((b) => b.name).join(", ")}
|
|
2836
|
+
`
|
|
2837
|
+
)
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (!silent) {
|
|
2844
|
+
process.stdout.write(`${c.bold("Orphan task/* branches:")}
|
|
2845
|
+
`);
|
|
2846
|
+
for (const b of orphans) {
|
|
2847
|
+
const upstream = b.hasUpstream ? c.dim(" (has upstream)") : c.dim(" (no upstream)");
|
|
2848
|
+
process.stdout.write(` ${c.dim("\xB7")} ${b.name}${upstream}
|
|
2849
|
+
`);
|
|
2850
|
+
}
|
|
2851
|
+
if (kept.length > 0) {
|
|
2852
|
+
process.stdout.write(`
|
|
2853
|
+
${c.dim("In-flight branch(es) preserved (use `task resume`):")}
|
|
2854
|
+
`);
|
|
2855
|
+
for (const b of kept) {
|
|
2856
|
+
process.stdout.write(` ${c.dim("\xB7")} ${b.name}
|
|
2857
|
+
`);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
process.stdout.write("\n");
|
|
2861
|
+
}
|
|
2862
|
+
const isTty = !silent && process.stdin.isTTY === true;
|
|
2863
|
+
if (!isTty) {
|
|
2864
|
+
if (!opts.confirm) {
|
|
2865
|
+
throw new CliError(
|
|
2866
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2867
|
+
"`task reset` requires --confirm in non-interactive (silent / scheduled-task) contexts",
|
|
2868
|
+
"Re-run with --confirm only after verifying the orphan list is what you expect."
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
} else {
|
|
2872
|
+
const answer = await inquirer4.prompt([
|
|
2873
|
+
{
|
|
2874
|
+
type: "confirm",
|
|
2875
|
+
name: "confirm",
|
|
2876
|
+
message: `Delete ${orphans.length} orphan branch(es)?`,
|
|
2877
|
+
default: false
|
|
2878
|
+
}
|
|
2879
|
+
]);
|
|
2880
|
+
if (!answer.confirm) {
|
|
2881
|
+
if (!silent) process.stdout.write(c.dim("Aborted \u2014 no branches deleted.\n"));
|
|
2882
|
+
process.exit(CLI_EXIT_CODES.SUCCESS);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
let deleted = 0;
|
|
2886
|
+
for (const b of orphans) {
|
|
2887
|
+
deleteLocalBranch(cwd, b.name);
|
|
2888
|
+
deleted += 1;
|
|
2889
|
+
if (!silent) process.stdout.write(`${c.ok("\u2713")} deleted ${b.name}
|
|
2890
|
+
`);
|
|
2891
|
+
}
|
|
2892
|
+
if (!silent) {
|
|
2893
|
+
process.stdout.write(
|
|
2894
|
+
`
|
|
2895
|
+
${c.bold(`Cleaned up ${deleted} branch(es).`)} ${c.dim("Run `task work` to start fresh.")}
|
|
2896
|
+
`
|
|
2897
|
+
);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2227
2901
|
// src/commands/scan.ts
|
|
2228
2902
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2229
2903
|
import ora2 from "ora";
|
|
@@ -2931,7 +3605,7 @@ function clampInt(raw, min, max, fallback) {
|
|
|
2931
3605
|
}
|
|
2932
3606
|
|
|
2933
3607
|
// src/commands/pr-test.ts
|
|
2934
|
-
import { execFileSync as
|
|
3608
|
+
import { execFileSync as execFileSync8 } from "child_process";
|
|
2935
3609
|
function registerPrTest(program2) {
|
|
2936
3610
|
program2.command("pr-test").description(
|
|
2937
3611
|
"Dry-run the full PR pipeline \u2014 cuts a throwaway branch, opens a real PR via the dashboard, then cleans up. Use this to verify your git integration before running task work on real tickets."
|
|
@@ -2974,7 +3648,7 @@ async function runPrTest(opts) {
|
|
|
2974
3648
|
try {
|
|
2975
3649
|
if (!silent) process.stdout.write(`${c.dim("Step 3/6: empty commit\u2026")}
|
|
2976
3650
|
`);
|
|
2977
|
-
|
|
3651
|
+
execFileSync8(
|
|
2978
3652
|
"git",
|
|
2979
3653
|
["commit", "--allow-empty", "-m", `task pr-test: connectivity probe ${timestamp}`],
|
|
2980
3654
|
{ cwd, stdio: ["ignore", "pipe", "pipe"] }
|
|
@@ -3077,7 +3751,7 @@ import { platform as platform2 } from "os";
|
|
|
3077
3751
|
import { mkdir as mkdir7, readFile as readFile5, writeFile as writeFile8, unlink as unlink3, readdir } from "fs/promises";
|
|
3078
3752
|
import { homedir as homedir6 } from "os";
|
|
3079
3753
|
import { join as join8 } from "path";
|
|
3080
|
-
import { execFileSync as
|
|
3754
|
+
import { execFileSync as execFileSync9, spawn as spawn4 } from "child_process";
|
|
3081
3755
|
|
|
3082
3756
|
// src/scheduler/cron-translate.ts
|
|
3083
3757
|
function translateToLaunchd(cron) {
|
|
@@ -3245,17 +3919,17 @@ var launchdAdapter = {
|
|
|
3245
3919
|
const path = plistPath(entry.id);
|
|
3246
3920
|
await writeFile8(path, buildPlist(entry));
|
|
3247
3921
|
try {
|
|
3248
|
-
|
|
3922
|
+
execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
|
|
3249
3923
|
} catch {
|
|
3250
3924
|
}
|
|
3251
3925
|
if (entry.enabled) {
|
|
3252
|
-
|
|
3926
|
+
execFileSync9("launchctl", ["bootstrap", bootstrapDomain(), path]);
|
|
3253
3927
|
}
|
|
3254
3928
|
},
|
|
3255
3929
|
async remove(id) {
|
|
3256
3930
|
const path = plistPath(id);
|
|
3257
3931
|
try {
|
|
3258
|
-
|
|
3932
|
+
execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
|
|
3259
3933
|
} catch {
|
|
3260
3934
|
}
|
|
3261
3935
|
try {
|
|
@@ -3320,10 +3994,10 @@ var launchdAdapter = {
|
|
|
3320
3994
|
xml = xml.replace(/\s*<key>Disabled<\/key>\s*<true\/>/, "");
|
|
3321
3995
|
await writeFile8(path, xml);
|
|
3322
3996
|
try {
|
|
3323
|
-
|
|
3997
|
+
execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
|
|
3324
3998
|
} catch {
|
|
3325
3999
|
}
|
|
3326
|
-
|
|
4000
|
+
execFileSync9("launchctl", ["bootstrap", bootstrapDomain(), path]);
|
|
3327
4001
|
} else {
|
|
3328
4002
|
if (!/<key>Disabled<\/key>/.test(xml)) {
|
|
3329
4003
|
xml = xml.replace(
|
|
@@ -3333,7 +4007,7 @@ var launchdAdapter = {
|
|
|
3333
4007
|
await writeFile8(path, xml);
|
|
3334
4008
|
}
|
|
3335
4009
|
try {
|
|
3336
|
-
|
|
4010
|
+
execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
|
|
3337
4011
|
} catch {
|
|
3338
4012
|
}
|
|
3339
4013
|
}
|
|
@@ -3341,7 +4015,7 @@ var launchdAdapter = {
|
|
|
3341
4015
|
};
|
|
3342
4016
|
|
|
3343
4017
|
// src/scheduler/cron.ts
|
|
3344
|
-
import { execFileSync as
|
|
4018
|
+
import { execFileSync as execFileSync10, spawn as spawn5 } from "child_process";
|
|
3345
4019
|
|
|
3346
4020
|
// src/scheduler/safe-command.ts
|
|
3347
4021
|
var FORBIDDEN = /[;&|`$()<>\\]/;
|
|
@@ -3396,7 +4070,7 @@ var MARK_OPEN = (id) => `# task-cli:${id}:start`;
|
|
|
3396
4070
|
var MARK_CLOSE = (id) => `# task-cli:${id}:end`;
|
|
3397
4071
|
function readCrontab() {
|
|
3398
4072
|
try {
|
|
3399
|
-
return
|
|
4073
|
+
return execFileSync10("crontab", ["-l"], { encoding: "utf8" });
|
|
3400
4074
|
} catch {
|
|
3401
4075
|
return "";
|
|
3402
4076
|
}
|
|
@@ -3511,7 +4185,7 @@ var cronAdapter = {
|
|
|
3511
4185
|
};
|
|
3512
4186
|
|
|
3513
4187
|
// src/scheduler/windows.ts
|
|
3514
|
-
import { execFileSync as
|
|
4188
|
+
import { execFileSync as execFileSync11, spawn as spawn6 } from "child_process";
|
|
3515
4189
|
var TASK_PREFIX = "TaskCLI_";
|
|
3516
4190
|
function taskName(id) {
|
|
3517
4191
|
return `${TASK_PREFIX}${id.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
@@ -3576,22 +4250,22 @@ function pad(v) {
|
|
|
3576
4250
|
var windowsAdapter = {
|
|
3577
4251
|
async upsert(entry) {
|
|
3578
4252
|
const args = buildSchtasksArgs(entry, entry.command);
|
|
3579
|
-
|
|
4253
|
+
execFileSync11("schtasks.exe", args, { stdio: "ignore" });
|
|
3580
4254
|
if (!entry.enabled) {
|
|
3581
|
-
|
|
4255
|
+
execFileSync11("schtasks.exe", ["/Change", "/TN", taskName(entry.id), "/DISABLE"], {
|
|
3582
4256
|
stdio: "ignore"
|
|
3583
4257
|
});
|
|
3584
4258
|
}
|
|
3585
4259
|
},
|
|
3586
4260
|
async remove(id) {
|
|
3587
4261
|
try {
|
|
3588
|
-
|
|
4262
|
+
execFileSync11("schtasks.exe", ["/Delete", "/TN", taskName(id), "/F"], { stdio: "ignore" });
|
|
3589
4263
|
} catch {
|
|
3590
4264
|
}
|
|
3591
4265
|
},
|
|
3592
4266
|
async list() {
|
|
3593
4267
|
try {
|
|
3594
|
-
const csv =
|
|
4268
|
+
const csv = execFileSync11("schtasks.exe", ["/Query", "/FO", "CSV", "/V"], {
|
|
3595
4269
|
encoding: "utf8"
|
|
3596
4270
|
});
|
|
3597
4271
|
const lines = csv.split(/\r?\n/);
|
|
@@ -3641,7 +4315,7 @@ var windowsAdapter = {
|
|
|
3641
4315
|
},
|
|
3642
4316
|
async setEnabled(id, enabled) {
|
|
3643
4317
|
try {
|
|
3644
|
-
|
|
4318
|
+
execFileSync11(
|
|
3645
4319
|
"schtasks.exe",
|
|
3646
4320
|
["/Change", "/TN", taskName(id), enabled ? "/ENABLE" : "/DISABLE"],
|
|
3647
4321
|
{ stdio: "ignore" }
|
|
@@ -4049,7 +4723,7 @@ function registerConfig(program2) {
|
|
|
4049
4723
|
}
|
|
4050
4724
|
|
|
4051
4725
|
// src/commands/doctor.ts
|
|
4052
|
-
import { execFileSync as
|
|
4726
|
+
import { execFileSync as execFileSync12 } from "child_process";
|
|
4053
4727
|
import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
|
|
4054
4728
|
import { join as join11 } from "path";
|
|
4055
4729
|
import { request as request5 } from "undici";
|
|
@@ -4100,8 +4774,27 @@ function registerDoctor(program2) {
|
|
|
4100
4774
|
detail: `${apiUrl}: ${err.message}`
|
|
4101
4775
|
});
|
|
4102
4776
|
}
|
|
4777
|
+
if (project) {
|
|
4778
|
+
const baseBranch = project.cli_base_branch ?? "development";
|
|
4779
|
+
try {
|
|
4780
|
+
const exists = remoteBranchExists(root, baseBranch);
|
|
4781
|
+
checks.push({
|
|
4782
|
+
name: "base branch on origin",
|
|
4783
|
+
ok: exists,
|
|
4784
|
+
detail: exists ? `origin/${baseBranch} reachable` : `origin has no branch "${baseBranch}"`,
|
|
4785
|
+
remediation: exists ? void 0 : `push it (\`git push origin ${baseBranch}\`) or update the project's cli_base_branch via PATCH /api/v1/projects/${project.project_id}`
|
|
4786
|
+
});
|
|
4787
|
+
} catch (err) {
|
|
4788
|
+
checks.push({
|
|
4789
|
+
name: "base branch on origin",
|
|
4790
|
+
ok: false,
|
|
4791
|
+
detail: err instanceof CliError ? err.message : `could not check origin: ${err.message}`,
|
|
4792
|
+
remediation: err instanceof CliError && err.hint ? err.hint : "Verify that `origin` is configured (`git remote -v`)."
|
|
4793
|
+
});
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4103
4796
|
try {
|
|
4104
|
-
const dirty =
|
|
4797
|
+
const dirty = execFileSync12("git", ["status", "--porcelain"], {
|
|
4105
4798
|
cwd: root,
|
|
4106
4799
|
encoding: "utf8"
|
|
4107
4800
|
}).trim();
|
|
@@ -4119,10 +4812,19 @@ function registerDoctor(program2) {
|
|
|
4119
4812
|
opts.fix === true
|
|
4120
4813
|
);
|
|
4121
4814
|
checks.push(testCheck);
|
|
4815
|
+
let inFlight = [];
|
|
4816
|
+
if (creds && project) {
|
|
4817
|
+
inFlight = await listInFlightTickets(project.project_id, root);
|
|
4818
|
+
checks.push({
|
|
4819
|
+
name: "in-flight tickets",
|
|
4820
|
+
ok: true,
|
|
4821
|
+
detail: inFlight.length === 0 ? "none" : `${inFlight.length} ticket(s) waiting to be resumed`
|
|
4822
|
+
});
|
|
4823
|
+
}
|
|
4122
4824
|
let allOk = true;
|
|
4123
4825
|
for (const check of checks) {
|
|
4124
4826
|
const sym = check.ok ? c.ok("\u2713") : c.err("\u2717");
|
|
4125
|
-
process.stdout.write(`${sym} ${check.name.padEnd(
|
|
4827
|
+
process.stdout.write(`${sym} ${check.name.padEnd(20)} ${c.dim(check.detail)}
|
|
4126
4828
|
`);
|
|
4127
4829
|
if (!check.ok) {
|
|
4128
4830
|
allOk = false;
|
|
@@ -4132,9 +4834,42 @@ function registerDoctor(program2) {
|
|
|
4132
4834
|
}
|
|
4133
4835
|
}
|
|
4134
4836
|
}
|
|
4837
|
+
for (const t of inFlight) {
|
|
4838
|
+
const status = t.branchPresent ? c.ok("local branch present") : c.err("local branch missing");
|
|
4839
|
+
process.stdout.write(
|
|
4840
|
+
` ${c.dim("\u2192")} #${t.sequenceNumber} "${t.title}" \u2014 ${status} \u2014 ${c.cyan(`task resume #${t.sequenceNumber}`)}
|
|
4841
|
+
`
|
|
4842
|
+
);
|
|
4843
|
+
}
|
|
4135
4844
|
if (!allOk) process.exit(1);
|
|
4136
4845
|
});
|
|
4137
4846
|
}
|
|
4847
|
+
async function listInFlightTickets(projectId, cwd) {
|
|
4848
|
+
const result = await apiCall(
|
|
4849
|
+
"GET",
|
|
4850
|
+
"/api/v1/cli/me/tickets",
|
|
4851
|
+
{
|
|
4852
|
+
query: { project_id: projectId, ai_fix_status: "building", limit: 100 }
|
|
4853
|
+
}
|
|
4854
|
+
);
|
|
4855
|
+
if (!result.ok || !result.data) return [];
|
|
4856
|
+
return result.data.map((t) => ({
|
|
4857
|
+
sequenceNumber: t.sequence_number,
|
|
4858
|
+
title: t.title,
|
|
4859
|
+
branchPresent: localBranchExists2(cwd, branchSlug(t.sequence_number, t.title))
|
|
4860
|
+
}));
|
|
4861
|
+
}
|
|
4862
|
+
function localBranchExists2(cwd, branchName) {
|
|
4863
|
+
try {
|
|
4864
|
+
execFileSync12("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
|
|
4865
|
+
cwd,
|
|
4866
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
4867
|
+
});
|
|
4868
|
+
return true;
|
|
4869
|
+
} catch {
|
|
4870
|
+
return false;
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4138
4873
|
async function checkPrePushTest(root, configuredCommand, fix) {
|
|
4139
4874
|
const command = configuredCommand && configuredCommand.trim().length > 0 ? configuredCommand.trim() : DEFAULT_TEST_COMMAND;
|
|
4140
4875
|
const argv = command.split(/\s+/).filter((s) => s.length > 0);
|
|
@@ -4209,11 +4944,12 @@ async function checkPrePushTest(root, configuredCommand, fix) {
|
|
|
4209
4944
|
function resolveScriptName(argv) {
|
|
4210
4945
|
const [exe, ...rest] = argv;
|
|
4211
4946
|
if (!exe || rest.length === 0) return null;
|
|
4947
|
+
const hasAnyFlag = rest.some((tok) => tok.startsWith("-"));
|
|
4948
|
+
if (hasAnyFlag) return null;
|
|
4212
4949
|
if (exe === "pnpm" || exe === "yarn" || exe === "bun") {
|
|
4213
4950
|
const next = rest[0];
|
|
4214
4951
|
if (!next) return null;
|
|
4215
4952
|
if (next === "run") return rest[1] ?? null;
|
|
4216
|
-
if (next.startsWith("-")) return null;
|
|
4217
4953
|
return next;
|
|
4218
4954
|
}
|
|
4219
4955
|
if (exe === "npm") {
|
|
@@ -4231,7 +4967,7 @@ function detectIndent(raw) {
|
|
|
4231
4967
|
}
|
|
4232
4968
|
function checkBinary(name, command) {
|
|
4233
4969
|
try {
|
|
4234
|
-
const out =
|
|
4970
|
+
const out = execFileSync12(command, ["--version"], { encoding: "utf8" }).trim();
|
|
4235
4971
|
return { name, ok: true, detail: out.split("\n")[0] ?? out };
|
|
4236
4972
|
} catch {
|
|
4237
4973
|
return { name, ok: false, detail: `'${command}' not found on PATH` };
|
|
@@ -4239,7 +4975,7 @@ function checkBinary(name, command) {
|
|
|
4239
4975
|
}
|
|
4240
4976
|
|
|
4241
4977
|
// src/commands/version.ts
|
|
4242
|
-
var CLI_VERSION = true ? "0.2.
|
|
4978
|
+
var CLI_VERSION = true ? "0.2.6" : "0.0.0-dev";
|
|
4243
4979
|
function registerVersion(program2) {
|
|
4244
4980
|
program2.command("version").description("Print the CLI version").action(() => {
|
|
4245
4981
|
process.stdout.write(CLI_VERSION + "\n");
|
|
@@ -4263,6 +4999,8 @@ registerTickets(program);
|
|
|
4263
4999
|
registerTicket(program);
|
|
4264
5000
|
registerWork(program);
|
|
4265
5001
|
registerMultiWork(program);
|
|
5002
|
+
registerResume(program);
|
|
5003
|
+
registerReset(program);
|
|
4266
5004
|
registerScan(program);
|
|
4267
5005
|
registerPrTest(program);
|
|
4268
5006
|
registerScheduledTask(program);
|