@inteeka/task-cli 0.2.5 → 0.2.7

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 CHANGED
@@ -998,9 +998,243 @@ function printTable(headers, rows) {
998
998
  }
999
999
 
1000
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
1001
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
1002
1236
  function registerStatus(program2) {
1003
- program2.command("status").description("Show CLI auth, link, and git state").option("--remote", "Also fetch /cli/access for live state").action(async (_opts) => {
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) => {
1004
1238
  const creds = await readCredentials();
1005
1239
  const root = findRepoRoot();
1006
1240
  const project = await readProjectConfig(root);
@@ -1036,12 +1270,13 @@ ${c.bold("Project link")}
1036
1270
  process.stdout.write(`
1037
1271
  ${c.bold("Repo")}
1038
1272
  `);
1273
+ let inGitRepo = false;
1039
1274
  try {
1040
- const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1275
+ const branch = execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1041
1276
  cwd: root,
1042
1277
  encoding: "utf8"
1043
1278
  }).trim();
1044
- const dirty = execFileSync("git", ["status", "--porcelain"], {
1279
+ const dirty = execFileSync3("git", ["status", "--porcelain"], {
1045
1280
  cwd: root,
1046
1281
  encoding: "utf8"
1047
1282
  });
@@ -1051,10 +1286,58 @@ ${c.bold("Repo")}
1051
1286
  ` ${dirty.trim().length > 0 ? c.warn("working tree dirty") : c.ok("clean")}
1052
1287
  `
1053
1288
  );
1289
+ inGitRepo = true;
1054
1290
  } catch {
1055
1291
  process.stdout.write(` ${c.warn("!")} not inside a git repo
1056
1292
  `);
1057
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
+ }
1058
1341
  });
1059
1342
  }
1060
1343
 
@@ -1281,264 +1564,83 @@ async function runAgent(args) {
1281
1564
  child.on("close", (code) => {
1282
1565
  logHandle?.end();
1283
1566
  const exitCode = code ?? 0;
1284
- resolve2({ exitCode, ok: exitCode === 0, outputLogPath, stderrTail: stderrBuffer });
1285
- });
1286
- });
1287
- }
1288
-
1289
- // src/guardrail/diff-check.ts
1290
- import { execFileSync as execFileSync2 } from "child_process";
1291
-
1292
- // src/guardrail/protected-paths.ts
1293
- import picomatch from "picomatch";
1294
- function buildProtectedMatcher(projectExtensions = []) {
1295
- const merged = Array.from(
1296
- /* @__PURE__ */ new Set([
1297
- ...CLI_DEFAULT_PROTECTED_PATHS,
1298
- ...projectExtensions.map((p) => p.trim()).filter(Boolean)
1299
- ])
1300
- );
1301
- const matcher = picomatch(merged, {
1302
- dot: true,
1303
- nocase: false
1304
- });
1305
- function normalise(p) {
1306
- return p.replace(/\\/g, "/");
1307
- }
1308
- return {
1309
- patterns: merged,
1310
- isProtected(path) {
1311
- return matcher(normalise(path));
1312
- },
1313
- matchAll(paths) {
1314
- const offending = [];
1315
- for (const p of paths) {
1316
- if (matcher(normalise(p))) offending.push(p);
1317
- }
1318
- return offending;
1319
- }
1320
- };
1321
- }
1322
-
1323
- // src/guardrail/diff-check.ts
1324
- function checkDiff(args) {
1325
- const matcher = buildProtectedMatcher(args.projectProtectedPaths);
1326
- const stagedRaw = safeGitOutput(["diff", "--cached", "--name-only"], args.cwd);
1327
- const unstagedRaw = safeGitOutput(["diff", "--name-only"], args.cwd);
1328
- const untrackedRaw = safeGitOutput(["ls-files", "--others", "--exclude-standard"], args.cwd);
1329
- const allChanged = Array.from(
1330
- new Set(
1331
- [...splitLines(stagedRaw), ...splitLines(unstagedRaw), ...splitLines(untrackedRaw)].filter(
1332
- (l) => l.length > 0
1333
- )
1334
- )
1335
- );
1336
- const offending = matcher.matchAll(allChanged);
1337
- if (offending.length === 0) {
1338
- return { violation: false, changedPaths: allChanged, allowedPaths: allChanged };
1339
- }
1340
- return {
1341
- violation: true,
1342
- offendingPaths: offending,
1343
- changedPaths: allChanged,
1344
- patterns: matcher.patterns
1345
- };
1346
- }
1347
- function safeGitOutput(args, cwd) {
1348
- try {
1349
- return execFileSync2("git", args, { cwd, encoding: "utf8" });
1350
- } catch {
1351
- return "";
1352
- }
1353
- }
1354
- function splitLines(text) {
1355
- return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
1356
- }
1357
-
1358
- // src/git/commit.ts
1359
- import { execFileSync as execFileSync3 } from "child_process";
1360
- function commitOnly(args) {
1361
- execFileSync3("git", ["add", "-A"], { cwd: args.cwd });
1362
- const statusRaw = execFileSync3("git", ["status", "--porcelain"], {
1363
- cwd: args.cwd,
1364
- encoding: "utf8"
1365
- });
1366
- if (!statusRaw.trim()) {
1367
- throw new Error("No changes to commit (empty diff)");
1368
- }
1369
- execFileSync3("git", ["commit", "-m", args.message], { cwd: args.cwd });
1370
- const sha = execFileSync3("git", ["rev-parse", "HEAD"], {
1371
- cwd: args.cwd,
1372
- encoding: "utf8"
1373
- }).trim();
1374
- return { sha };
1375
- }
1376
- function currentBranch(cwd) {
1377
- try {
1378
- return execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1379
- cwd,
1380
- encoding: "utf8"
1381
- }).trim();
1382
- } catch {
1383
- return "HEAD";
1384
- }
1385
- }
1386
-
1387
- // src/commands/work.ts
1388
- import { execFileSync as execFileSync6 } from "child_process";
1389
-
1390
- // src/git/branch.ts
1391
- import { execFileSync as execFileSync4 } from "child_process";
1392
- var VALID_BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
1393
- var TICKET_BRANCH = /^task\/[a-z0-9-]{1,80}$/;
1394
- function assertValidBranchName(branch) {
1395
- if (!VALID_BRANCH.test(branch) || branch.includes("..") || branch.startsWith("/") || branch.endsWith("/")) {
1396
- throw new CliError(
1397
- CLI_EXIT_CODES.MISCONFIGURATION,
1398
- `Invalid branch name: ${branch}`,
1399
- 'Branch names must contain only [A-Za-z0-9._/-], no "..", and no leading/trailing slash.'
1400
- );
1401
- }
1402
- }
1403
- function isWorkingTreeClean(cwd) {
1404
- const out = execFileSync4("git", ["status", "--porcelain"], {
1405
- cwd,
1406
- encoding: "utf8"
1407
- });
1408
- return out.trim().length === 0;
1409
- }
1410
- function assertBaseBranch(cwd, expected) {
1411
- assertValidBranchName(expected);
1412
- const current = currentBranch(cwd);
1413
- if (current !== expected) {
1414
- throw new CliError(
1415
- CLI_EXIT_CODES.MISCONFIGURATION,
1416
- `task work requires branch "${expected}" but you're on "${current}"`,
1417
- `Run "git checkout ${expected}" first. The base branch is configured per project; ask an admin if it should be different.`
1418
- );
1419
- }
1420
- if (!isWorkingTreeClean(cwd)) {
1421
- throw new CliError(
1422
- CLI_EXIT_CODES.MISCONFIGURATION,
1423
- "Working tree is dirty",
1424
- "Commit, stash, or discard your local changes before running task work."
1425
- );
1426
- }
1427
- }
1428
- function createTicketBranch(cwd, branchName, baseBranch) {
1429
- assertValidBranchName(branchName);
1430
- assertValidBranchName(baseBranch);
1431
- if (!TICKET_BRANCH.test(branchName)) {
1432
- throw new CliError(
1433
- CLI_EXIT_CODES.MISCONFIGURATION,
1434
- `Per-ticket branch must match ^task/[a-z0-9-]{1,80}$ \u2014 got "${branchName}"`
1435
- );
1436
- }
1437
- try {
1438
- execFileSync4("git", ["checkout", "-b", branchName, baseBranch], {
1439
- cwd,
1440
- stdio: ["ignore", "pipe", "pipe"]
1441
- });
1442
- } catch (err) {
1443
- const stderr = err.stderr?.toString("utf8") ?? "";
1444
- throw new CliError(
1445
- CLI_EXIT_CODES.GENERIC_ERROR,
1446
- `Could not create branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
1447
- );
1448
- }
1449
- }
1450
- function deleteLocalBranch(cwd, branchName) {
1451
- if (!VALID_BRANCH.test(branchName)) return;
1452
- try {
1453
- execFileSync4("git", ["branch", "-D", branchName], {
1454
- cwd,
1455
- stdio: ["ignore", "ignore", "ignore"]
1456
- });
1457
- } catch {
1458
- }
1459
- }
1460
- function checkoutBranch(cwd, branchName) {
1461
- assertValidBranchName(branchName);
1462
- try {
1463
- execFileSync4("git", ["checkout", branchName], {
1464
- cwd,
1465
- stdio: ["ignore", "pipe", "pipe"]
1466
- });
1467
- } catch (err) {
1468
- const stderr = err.stderr?.toString("utf8") ?? "";
1469
- throw new CliError(
1470
- CLI_EXIT_CODES.GENERIC_ERROR,
1471
- `Could not check out branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
1472
- );
1473
- }
1474
- }
1475
- function pushBranch(cwd, branchName) {
1476
- assertValidBranchName(branchName);
1477
- try {
1478
- execFileSync4("git", ["push", "-u", "origin", branchName], {
1479
- cwd,
1480
- stdio: ["ignore", "pipe", "pipe"]
1567
+ resolve2({ exitCode, ok: exitCode === 0, outputLogPath, stderrTail: stderrBuffer });
1481
1568
  });
1482
- return { remote: "origin" };
1483
- } catch (err) {
1484
- const stderr = err.stderr?.toString("utf8") ?? "";
1485
- throw new CliError(
1486
- CLI_EXIT_CODES.GENERIC_ERROR,
1487
- `Push failed: ${stderr.slice(0, 600) || err.message}`
1488
- );
1489
- }
1569
+ });
1490
1570
  }
1491
- function enforceBaseBranchClean(cwd, baseBranch, opts = {}) {
1492
- assertValidBranchName(baseBranch);
1493
- try {
1494
- execFileSync4("git", ["restore", "--staged", "--worktree", "."], {
1495
- cwd,
1496
- stdio: ["ignore", "pipe", "pipe"]
1497
- });
1498
- } catch {
1499
- try {
1500
- execFileSync4("git", ["reset", "--hard", "HEAD"], {
1501
- cwd,
1502
- stdio: ["ignore", "pipe", "pipe"]
1503
- });
1504
- } catch {
1571
+
1572
+ // src/guardrail/diff-check.ts
1573
+ import { execFileSync as execFileSync4 } from "child_process";
1574
+
1575
+ // src/guardrail/protected-paths.ts
1576
+ import picomatch from "picomatch";
1577
+ function buildProtectedMatcher(projectExtensions = []) {
1578
+ const merged = Array.from(
1579
+ /* @__PURE__ */ new Set([
1580
+ ...CLI_DEFAULT_PROTECTED_PATHS,
1581
+ ...projectExtensions.map((p) => p.trim()).filter(Boolean)
1582
+ ])
1583
+ );
1584
+ const matcher = picomatch(merged, {
1585
+ dot: true,
1586
+ nocase: false
1587
+ });
1588
+ function normalise(p) {
1589
+ return p.replace(/\\/g, "/");
1590
+ }
1591
+ return {
1592
+ patterns: merged,
1593
+ isProtected(path) {
1594
+ return matcher(normalise(path));
1595
+ },
1596
+ matchAll(paths) {
1597
+ const offending = [];
1598
+ for (const p of paths) {
1599
+ if (matcher(normalise(p))) offending.push(p);
1600
+ }
1601
+ return offending;
1505
1602
  }
1603
+ };
1604
+ }
1605
+
1606
+ // src/guardrail/diff-check.ts
1607
+ function checkDiff(args) {
1608
+ const matcher = buildProtectedMatcher(args.projectProtectedPaths);
1609
+ const stagedRaw = safeGitOutput(["diff", "--cached", "--name-only"], args.cwd);
1610
+ const unstagedRaw = safeGitOutput(["diff", "--name-only"], args.cwd);
1611
+ const untrackedRaw = safeGitOutput(["ls-files", "--others", "--exclude-standard"], args.cwd);
1612
+ const allChanged = Array.from(
1613
+ new Set(
1614
+ [...splitLines(stagedRaw), ...splitLines(unstagedRaw), ...splitLines(untrackedRaw)].filter(
1615
+ (l) => l.length > 0
1616
+ )
1617
+ )
1618
+ );
1619
+ const offending = matcher.matchAll(allChanged);
1620
+ if (offending.length === 0) {
1621
+ return { violation: false, changedPaths: allChanged, allowedPaths: allChanged };
1506
1622
  }
1623
+ return {
1624
+ violation: true,
1625
+ offendingPaths: offending,
1626
+ changedPaths: allChanged,
1627
+ patterns: matcher.patterns
1628
+ };
1629
+ }
1630
+ function safeGitOutput(args, cwd) {
1507
1631
  try {
1508
- execFileSync4("git", ["clean", "-fd"], {
1509
- cwd,
1510
- stdio: ["ignore", "pipe", "pipe"]
1511
- });
1632
+ return execFileSync4("git", args, { cwd, encoding: "utf8" });
1512
1633
  } catch {
1513
- }
1514
- checkoutBranch(cwd, baseBranch);
1515
- const current = currentBranch(cwd);
1516
- if (current !== baseBranch) {
1517
- throw new CliError(
1518
- CLI_EXIT_CODES.GENERIC_ERROR,
1519
- `Branch enforcement failed: expected "${baseBranch}", on "${current}"`,
1520
- `Run "git checkout ${baseBranch}" manually, then re-run task multi-work.`
1521
- );
1522
- }
1523
- if (!isWorkingTreeClean(cwd)) {
1524
- throw new CliError(
1525
- CLI_EXIT_CODES.GENERIC_ERROR,
1526
- "Branch enforcement failed: working tree is dirty after returning to base",
1527
- 'Inspect with "git status" and clean up before re-running task multi-work.'
1528
- );
1529
- }
1530
- if (opts.deleteBranch && opts.deleteBranch !== baseBranch) {
1531
- deleteLocalBranch(cwd, opts.deleteBranch);
1634
+ return "";
1532
1635
  }
1533
1636
  }
1534
- function branchSlug(sequenceNumber, title) {
1535
- const safeTitle = title.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
1536
- const slugBudget = 70;
1537
- const truncatedSlug = safeTitle.slice(0, slugBudget);
1538
- const tail = truncatedSlug.length > 0 ? `-${truncatedSlug}` : "";
1539
- return `task/${sequenceNumber}${tail}`.replace(/-+$/, "");
1637
+ function splitLines(text) {
1638
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
1540
1639
  }
1541
1640
 
1641
+ // src/commands/work.ts
1642
+ import { execFileSync as execFileSync6 } from "child_process";
1643
+
1542
1644
  // src/git/restore.ts
1543
1645
  import { execFileSync as execFileSync5 } from "child_process";
1544
1646
  function discardWorkingTreeChanges(cwd) {
@@ -1717,15 +1819,36 @@ async function processOneTicket(ctx, opts, ticketIdHint) {
1717
1819
  }
1718
1820
  const branchName = branchSlug(detail.sequence_number, detail.title);
1719
1821
  const testCommand = detail.project_cli_test_command ?? null;
1822
+ const ticketBaseBranch = detail.project_cli_base_branch || baseBranch;
1720
1823
  if (!silent) {
1721
1824
  process.stdout.write(`
1722
1825
  ${c.bold(`#${detail.sequence_number}: ${detail.title}`)}
1723
1826
  `);
1724
- process.stdout.write(c.dim(` base branch: ${baseBranch}
1827
+ process.stdout.write(c.dim(` base branch: ${ticketBaseBranch}
1725
1828
  `));
1726
1829
  process.stdout.write(c.dim(` ticket branch: ${branchName}
1727
1830
  `));
1728
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
+ }
1729
1852
  const runId = randomUUID();
1730
1853
  await apiCall("POST", "/api/v1/cli/me/runs", {
1731
1854
  body: {
@@ -1736,18 +1859,44 @@ ${c.bold(`#${detail.sequence_number}: ${detail.title}`)}
1736
1859
  }
1737
1860
  });
1738
1861
  try {
1739
- createTicketBranch(cwd, branchName, baseBranch);
1862
+ createTicketBranch(cwd, branchName, ticketBaseBranch);
1740
1863
  } catch (err) {
1741
- await apiCall("POST", "/api/v1/cli/me/runs", {
1742
- body: {
1743
- ticket_id: detail.id,
1744
- schedule_id: opts.scheduleId,
1745
- event: "branch_check_failed",
1746
- claude_session_id: runId,
1747
- output_excerpt: err.message.slice(0, 4e3)
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;
1748
1887
  }
1749
- });
1750
- throw err;
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
+ }
1751
1900
  }
1752
1901
  await apiCall("POST", "/api/v1/cli/me/runs", {
1753
1902
  body: {
@@ -2007,14 +2156,21 @@ Claude session: ${runId}
2007
2156
  process.stdout.write(`${c.ok("\u2713 Pushed")} ${branchName} (${commitSha.slice(0, 12)})
2008
2157
  `);
2009
2158
  const prTitle = `task #${detail.sequence_number}: ${detail.title}`.slice(0, 200);
2010
- const prBody = buildPrBody({ detail, runId, commitSha, branchName, baseBranch, testResult });
2159
+ const prBody = buildPrBody({
2160
+ detail,
2161
+ runId,
2162
+ commitSha,
2163
+ branchName,
2164
+ baseBranch: ticketBaseBranch,
2165
+ testResult
2166
+ });
2011
2167
  let prNumber;
2012
2168
  let prUrl;
2013
2169
  try {
2014
2170
  const prResp = await apiCallOrThrow("POST", `/api/v1/cli/me/tickets/${detail.id}/pull-requests`, {
2015
2171
  body: {
2016
2172
  source_branch: branchName,
2017
- base_branch: baseBranch,
2173
+ base_branch: ticketBaseBranch,
2018
2174
  title: prTitle,
2019
2175
  body: prBody
2020
2176
  }
@@ -2032,7 +2188,7 @@ Claude session: ${runId}
2032
2188
  });
2033
2189
  if (!silent) {
2034
2190
  process.stdout.write(
2035
- `${c.ok("\u2713 PR opened")} ${c.cyan(prResp.pr_url)} \u2192 ${baseBranch}
2191
+ `${c.ok("\u2713 PR opened")} ${c.cyan(prResp.pr_url)} \u2192 ${ticketBaseBranch}
2036
2192
  ` + (prResp.ticket_status_advanced ? c.dim(` Ticket status auto-advanced to 'git_review'.
2037
2193
  `) : "")
2038
2194
  );
@@ -2160,6 +2316,54 @@ ${c.bold("--reset will:")}
2160
2316
  }
2161
2317
  enforceBaseBranchClean(cwd, baseBranch);
2162
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
+ }
2163
2367
  async function pickNextEligible(projectId) {
2164
2368
  const result = await apiCall("GET", "/api/v1/cli/me/tickets", {
2165
2369
  query: { project_id: projectId, limit: 1 }
@@ -2586,6 +2790,114 @@ function buildResumePrBody(detail, branchName, baseBranch) {
2586
2790
  ].filter(Boolean).join("\n");
2587
2791
  }
2588
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
+
2589
2901
  // src/commands/scan.ts
2590
2902
  import { randomUUID as randomUUID2 } from "crypto";
2591
2903
  import ora2 from "ora";
@@ -4462,6 +4774,25 @@ function registerDoctor(program2) {
4462
4774
  detail: `${apiUrl}: ${err.message}`
4463
4775
  });
4464
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
+ }
4465
4796
  try {
4466
4797
  const dirty = execFileSync12("git", ["status", "--porcelain"], {
4467
4798
  cwd: root,
@@ -4613,11 +4944,12 @@ async function checkPrePushTest(root, configuredCommand, fix) {
4613
4944
  function resolveScriptName(argv) {
4614
4945
  const [exe, ...rest] = argv;
4615
4946
  if (!exe || rest.length === 0) return null;
4947
+ const hasAnyFlag = rest.some((tok) => tok.startsWith("-"));
4948
+ if (hasAnyFlag) return null;
4616
4949
  if (exe === "pnpm" || exe === "yarn" || exe === "bun") {
4617
4950
  const next = rest[0];
4618
4951
  if (!next) return null;
4619
4952
  if (next === "run") return rest[1] ?? null;
4620
- if (next.startsWith("-")) return null;
4621
4953
  return next;
4622
4954
  }
4623
4955
  if (exe === "npm") {
@@ -4643,7 +4975,7 @@ function checkBinary(name, command) {
4643
4975
  }
4644
4976
 
4645
4977
  // src/commands/version.ts
4646
- var CLI_VERSION = true ? "0.2.5" : "0.0.0-dev";
4978
+ var CLI_VERSION = true ? "0.2.7" : "0.0.0-dev";
4647
4979
  function registerVersion(program2) {
4648
4980
  program2.command("version").description("Print the CLI version").action(() => {
4649
4981
  process.stdout.write(CLI_VERSION + "\n");
@@ -4668,6 +5000,7 @@ registerTicket(program);
4668
5000
  registerWork(program);
4669
5001
  registerMultiWork(program);
4670
5002
  registerResume(program);
5003
+ registerReset(program);
4671
5004
  registerScan(program);
4672
5005
  registerPrTest(program);
4673
5006
  registerScheduledTask(program);