@tamsensedev/runner 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync5 } from "fs";
4
+ import { existsSync as existsSync7 } from "fs";
5
5
  import { homedir as homedir3 } from "os";
6
- import process5 from "process";
6
+ import process7 from "process";
7
7
 
8
8
  // src/api.ts
9
9
  var DEFAULT_SERVER = "https://my.tamsense.com";
@@ -187,7 +187,7 @@ function findNpm(nodePath, env, exists) {
187
187
  }
188
188
  return null;
189
189
  }
190
- var runProcess = (command, args, options) => new Promise((resolve3, reject) => {
190
+ var runProcess = (command, args, options) => new Promise((resolve5, reject) => {
191
191
  const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
192
192
  const tail = [];
193
193
  for (const stream of [child.stdout, child.stderr]) {
@@ -201,7 +201,7 @@ var runProcess = (command, args, options) => new Promise((resolve3, reject) => {
201
201
  });
202
202
  }
203
203
  child.on("error", reject);
204
- child.on("close", (code) => resolve3({ code: code ?? 1, output: tail.join("\n") }));
204
+ child.on("close", (code) => resolve5({ code: code ?? 1, output: tail.join("\n") }));
205
205
  });
206
206
  function failureText(what, result) {
207
207
  if (OFFLINE_PATTERN.test(result.output)) {
@@ -314,8 +314,8 @@ async function probeCapabilities(stop) {
314
314
  stop?.throwIfAborted();
315
315
  let release = () => {
316
316
  };
317
- const released = new Promise((resolve3) => {
318
- release = resolve3;
317
+ const released = new Promise((resolve5) => {
318
+ release = resolve5;
319
319
  });
320
320
  const nothing = {
321
321
  [Symbol.asyncIterator]: () => ({
@@ -450,6 +450,10 @@ var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
450
450
  var IDENTITY_PATH = "/api/runner/me";
451
451
  var NEXT_RUN_PATH = "/api/runner/runs/next";
452
452
  var CAPABILITIES_PATH = "/api/runner/me/capabilities";
453
+ var FOLDER_CHECKS_PATH = "/api/runner/folders/checks";
454
+ function folderCheckPath(folderId) {
455
+ return `/api/runner/folders/${folderId}`;
456
+ }
453
457
  function runPath(runId, action) {
454
458
  return `/api/runner/runs/${runId}/${action}`;
455
459
  }
@@ -758,8 +762,8 @@ function inputChannel(options = {}) {
758
762
  }
759
763
  const next = queue.shift();
760
764
  if (!next) {
761
- await new Promise((resolve3) => {
762
- wake = resolve3;
765
+ await new Promise((resolve5) => {
766
+ wake = resolve5;
763
767
  });
764
768
  continue;
765
769
  }
@@ -819,9 +823,9 @@ function inputChannel(options = {}) {
819
823
  onSwitch(apply) {
820
824
  applySwitch2 = apply;
821
825
  if (waitingSwitch) {
822
- const { change, resolve: resolve3 } = waitingSwitch;
826
+ const { change, resolve: resolve5 } = waitingSwitch;
823
827
  waitingSwitch = null;
824
- void apply(change).then(() => resolve3(true), () => resolve3(false));
828
+ void apply(change).then(() => resolve5(true), () => resolve5(false));
825
829
  }
826
830
  },
827
831
  onStopHelper(stop) {
@@ -882,8 +886,8 @@ function inputChannel(options = {}) {
882
886
  const earlier = waitingSwitch;
883
887
  earlier?.resolve(false);
884
888
  const merged = { model: change.model ?? earlier?.change.model ?? null, effort: change.effort ?? earlier?.change.effort ?? null };
885
- return new Promise((resolve3) => {
886
- waitingSwitch = { change: merged, resolve: resolve3 };
889
+ return new Promise((resolve5) => {
890
+ waitingSwitch = { change: merged, resolve: resolve5 };
887
891
  });
888
892
  }
889
893
  };
@@ -1087,9 +1091,515 @@ function askPermission(tools, lastWords, folder) {
1087
1091
  }
1088
1092
  var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
1089
1093
 
1094
+ // src/localWorkspace.ts
1095
+ import { existsSync as existsSync4 } from "fs";
1096
+ import { join as join4, resolve as resolve3 } from "path";
1097
+
1098
+ // src/folders.ts
1099
+ import { existsSync as existsSync2, realpathSync, statSync } from "fs";
1100
+ import { basename, isAbsolute, join as join2, relative, resolve } from "path";
1101
+ import process3 from "process";
1102
+
1103
+ // src/git.ts
1104
+ import { Buffer as Buffer2 } from "buffer";
1105
+ import { spawn as spawn2 } from "child_process";
1106
+ import process2 from "process";
1107
+ var GITHUB_ORIGIN = "https://github.com/";
1108
+ function githubAuthEnv(token) {
1109
+ const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
1110
+ return {
1111
+ GIT_CONFIG_COUNT: "1",
1112
+ GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
1113
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
1114
+ GIT_TERMINAL_PROMPT: "0"
1115
+ };
1116
+ }
1117
+ function personalGitEnv(env = process2.env) {
1118
+ return { GIT_TERMINAL_PROMPT: "0", ...env.GIT_SSH_COMMAND ? {} : { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } };
1119
+ }
1120
+ function runGit(args, options) {
1121
+ const env = { ...process2.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
1122
+ return new Promise((resolve5, reject) => {
1123
+ const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"], ...options.timeoutMs ? { timeout: options.timeoutMs } : {} });
1124
+ let stdout = "";
1125
+ let stderr = "";
1126
+ child.stdout.on("data", (chunk) => {
1127
+ stdout += chunk.toString();
1128
+ });
1129
+ child.stderr.on("data", (chunk) => {
1130
+ stderr += chunk.toString();
1131
+ });
1132
+ child.on("error", reject);
1133
+ child.on("close", (code) => resolve5({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
1134
+ });
1135
+ }
1136
+ async function git(args, options) {
1137
+ const result = await runGit(args, options);
1138
+ if (result.code !== 0) {
1139
+ throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
1140
+ }
1141
+ return result.stdout;
1142
+ }
1143
+
1144
+ // src/folders.ts
1145
+ var FIRST_REMOTE = "origin";
1146
+ var USUAL_BASES = ["main", "master"];
1147
+ var CHECKS_INTERVAL_MS = 5e3;
1148
+ var WEB_REMOTE = /^[a-z][\w+.-]*:\/\//i;
1149
+ var NETWORK_REMOTE = /^(?!file:)[a-z][\w+.-]*:\/\/|^[^/@]+@[^/:]+:/i;
1150
+ function problem(text2) {
1151
+ return { ok: false, problem: text2 };
1152
+ }
1153
+ async function gitText(args, cwd) {
1154
+ const result = await runGit(args, { cwd });
1155
+ return result.code === 0 && result.stdout ? result.stdout : null;
1156
+ }
1157
+ function expandHome(path, home) {
1158
+ const trimmed = path.trim();
1159
+ return trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\") ? join2(home, trimmed.slice(1)) : trimmed;
1160
+ }
1161
+ function remoteWithoutSecrets(url) {
1162
+ if (!WEB_REMOTE.test(url)) {
1163
+ return url;
1164
+ }
1165
+ try {
1166
+ const parsed = new URL(url);
1167
+ if (parsed.username || parsed.password) {
1168
+ parsed.username = "";
1169
+ parsed.password = "";
1170
+ }
1171
+ return parsed.toString().replace(/\/$/, "");
1172
+ } catch {
1173
+ return url;
1174
+ }
1175
+ }
1176
+ function repositoryName(remoteUrl, folder) {
1177
+ const parts = remoteUrl && NETWORK_REMOTE.test(remoteUrl) ? remoteUrl.replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).filter(Boolean) : [];
1178
+ return parts.length >= 3 ? `${parts.at(-2)}/${parts.at(-1)}` : basename(folder);
1179
+ }
1180
+ async function remoteOf(folder) {
1181
+ const remotes = (await gitText(["remote"], folder))?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
1182
+ return remotes.includes(FIRST_REMOTE) ? FIRST_REMOTE : remotes[0] ?? null;
1183
+ }
1184
+ async function hasRef(folder, ref) {
1185
+ return (await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: folder })).code === 0;
1186
+ }
1187
+ async function baseBranchOf(folder, remote, current) {
1188
+ const remoteHead = remote ? await gitText(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], folder) : null;
1189
+ if (remoteHead?.startsWith(`${remote}/`)) {
1190
+ return remoteHead.slice(remote.length + 1);
1191
+ }
1192
+ for (const name of USUAL_BASES) {
1193
+ if (await hasRef(folder, `refs/heads/${name}`) || remote && await hasRef(folder, `refs/remotes/${remote}/${name}`)) {
1194
+ return name;
1195
+ }
1196
+ }
1197
+ return current;
1198
+ }
1199
+ function samePath(first, second) {
1200
+ const [a, b] = [realpathSync.native(first), realpathSync.native(second)];
1201
+ return process3.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
1202
+ }
1203
+ function isInside(path, folder) {
1204
+ const between = relative(resolve(folder), resolve(path));
1205
+ return between === "" || !between.startsWith("..") && !isAbsolute(between);
1206
+ }
1207
+ async function inspectFolder(path, home) {
1208
+ const folder = expandHome(path, home);
1209
+ if (!isAbsolute(folder)) {
1210
+ return problem("Enter the full path of the folder, for example /Users/anna/code/shop or C:\\code\\shop.");
1211
+ }
1212
+ if (!existsSync2(folder) || !statSync(folder).isDirectory()) {
1213
+ return problem(`There is no folder ${folder} on this computer.`);
1214
+ }
1215
+ if (isInside(folder, join2(home, ".tamsense"))) {
1216
+ return problem("This is a TAMsense work folder. Choose your own copy of the repository.");
1217
+ }
1218
+ const top = await gitText(["rev-parse", "--show-toplevel"], folder);
1219
+ if (!top) {
1220
+ return problem(`${folder} is not a git repository. Choose the folder of a cloned repository, or run git init there.`);
1221
+ }
1222
+ if (!samePath(top, folder)) {
1223
+ return problem(`${folder} is inside the repository ${resolve(top)}. Add the top folder of the repository instead.`);
1224
+ }
1225
+ if (!await hasRef(folder, "HEAD")) {
1226
+ return problem("The repository has no commits yet. Make the first commit, then check the folder again.");
1227
+ }
1228
+ const remote = await remoteOf(folder);
1229
+ const remoteUrl = remote ? await gitText(["remote", "get-url", remote], folder) : null;
1230
+ const currentBranch = await gitText(["symbolic-ref", "--quiet", "--short", "HEAD"], folder);
1231
+ const defaultBranch = await baseBranchOf(folder, remote, currentBranch);
1232
+ if (!defaultBranch) {
1233
+ return problem("Could not tell the main branch of the repository. Check out the main branch in this folder, then check it again.");
1234
+ }
1235
+ const safeUrl = remoteUrl && remoteWithoutSecrets(remoteUrl);
1236
+ return { ok: true, repository: repositoryName(safeUrl, folder), currentBranch, defaultBranch, remoteUrl: safeUrl };
1237
+ }
1238
+ function reportLine(path, report) {
1239
+ return report.ok ? `Checked the folder ${path}: ${report.repository}, main branch ${report.defaultBranch}.` : `The folder ${path} cannot be used: ${report.problem}`;
1240
+ }
1241
+ async function checkFolders(tools, stop) {
1242
+ let complained = false;
1243
+ while (!stop.aborted) {
1244
+ try {
1245
+ const { checks } = await tools.api.request("POST", FOLDER_CHECKS_PATH);
1246
+ for (const check of checks) {
1247
+ const report = await tools.inspect(check.path).catch((error) => problem(error instanceof Error ? error.message : String(error)));
1248
+ await tools.api.request("POST", folderCheckPath(check.id), report);
1249
+ tools.print(reportLine(check.path, report));
1250
+ }
1251
+ complained = false;
1252
+ } catch (error) {
1253
+ if (error instanceof ApiError && error.status === 404) {
1254
+ return;
1255
+ }
1256
+ if (!complained && !stop.aborted) {
1257
+ tools.print(`Could not check the folders for TAMsense: ${error instanceof Error ? error.message : String(error)}`);
1258
+ complained = true;
1259
+ }
1260
+ }
1261
+ await tools.sleep(CHECKS_INTERVAL_MS, stop);
1262
+ }
1263
+ }
1264
+
1265
+ // src/lock.ts
1266
+ function keyedLock() {
1267
+ const tails = /* @__PURE__ */ new Map();
1268
+ return {
1269
+ async run(key, work2) {
1270
+ const previous = tails.get(key) ?? Promise.resolve();
1271
+ let release = () => {
1272
+ };
1273
+ const done2 = new Promise((resolve5) => {
1274
+ release = resolve5;
1275
+ });
1276
+ const tail = previous.then(() => done2);
1277
+ tails.set(key, tail);
1278
+ await previous;
1279
+ try {
1280
+ return await work2();
1281
+ } finally {
1282
+ release();
1283
+ if (tails.get(key) === tail) {
1284
+ tails.delete(key);
1285
+ }
1286
+ }
1287
+ }
1288
+ };
1289
+ }
1290
+
1291
+ // src/workspace.ts
1292
+ import { existsSync as existsSync3 } from "fs";
1293
+ import { rm as rm2 } from "fs/promises";
1294
+ import { join as join3, resolve as resolve2 } from "path";
1295
+ var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
1296
+ var cacheLock = keyedLock();
1297
+ function githubRemoteUrl(fullName) {
1298
+ return `https://github.com/${fullName}.git`;
1299
+ }
1300
+ function tamsenseFolder(home, ...parts) {
1301
+ return join3(home, ".tamsense", ...parts);
1302
+ }
1303
+ function folderNames(repositories) {
1304
+ const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
1305
+ const counts = /* @__PURE__ */ new Map();
1306
+ for (const repository of repositories) {
1307
+ counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
1308
+ }
1309
+ return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
1310
+ }
1311
+ async function refreshCache(cache, repository, token, tools) {
1312
+ if (existsSync3(join3(cache, ".git"))) {
1313
+ await git(["fetch", "--prune", "origin"], { cwd: cache, token });
1314
+ return;
1315
+ }
1316
+ await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
1317
+ }
1318
+ async function removeWorktree(cache, path) {
1319
+ await runGit(["worktree", "remove", "--force", path], { cwd: cache });
1320
+ await rm2(path, { recursive: true, force: true });
1321
+ await git(["worktree", "prune"], { cwd: cache });
1322
+ }
1323
+ function stepFolder(job) {
1324
+ const task = `task-${job.taskId}`;
1325
+ return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
1326
+ }
1327
+ function isReadOnlyCheck(job) {
1328
+ return job.stage.kind === "review" || job.stage.kind === "test";
1329
+ }
1330
+ function branchHolders(porcelain, branch) {
1331
+ const holders = [];
1332
+ let path = null;
1333
+ for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
1334
+ if (line.startsWith("worktree ")) {
1335
+ path = line.slice("worktree ".length);
1336
+ } else if (path && line === `branch refs/heads/${branch}`) {
1337
+ holders.push(path);
1338
+ }
1339
+ }
1340
+ return holders;
1341
+ }
1342
+ async function releaseBranch(cache, branch, keep) {
1343
+ const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
1344
+ for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
1345
+ await removeWorktree(cache, path);
1346
+ }
1347
+ }
1348
+ async function baseRef(cache, job, repository) {
1349
+ const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
1350
+ return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
1351
+ }
1352
+ async function keptBase(cache, path, job, repository) {
1353
+ if (!job.keepWorkspace || !existsSync3(join3(path, ".git"))) {
1354
+ return null;
1355
+ }
1356
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
1357
+ if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
1358
+ return null;
1359
+ }
1360
+ return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
1361
+ }
1362
+ function taskFolder(home, job) {
1363
+ return tamsenseFolder(home, "work", ...stepFolder(job));
1364
+ }
1365
+ async function prepareWorkspace(job, token, tools) {
1366
+ const root = taskFolder(tools.home, job);
1367
+ const folders = folderNames(job.repositories);
1368
+ const repositories = [];
1369
+ for (const [index, repository] of job.repositories.entries()) {
1370
+ const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
1371
+ const folder = folders[index] ?? repository.fullName;
1372
+ const path = join3(root, folder);
1373
+ const baseSha = await cacheLock.run(repository.fullName, async () => {
1374
+ await refreshCache(cache, repository, token, tools);
1375
+ const kept = await keptBase(cache, path, job, repository);
1376
+ if (kept) {
1377
+ return kept;
1378
+ }
1379
+ await removeWorktree(cache, path);
1380
+ const base = await baseRef(cache, job, repository);
1381
+ if (isReadOnlyCheck(job)) {
1382
+ await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
1383
+ } else {
1384
+ await releaseBranch(cache, job.branch, path);
1385
+ await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
1386
+ }
1387
+ return git(["rev-parse", "HEAD"], { cwd: path });
1388
+ });
1389
+ repositories.push({ fullName: repository.fullName, folder, path, baseSha });
1390
+ }
1391
+ return { root, repositories };
1392
+ }
1393
+ async function commitIdentityEnv(home) {
1394
+ const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
1395
+ if (configured.code === 0 && configured.stdout) {
1396
+ return {};
1397
+ }
1398
+ return {
1399
+ GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
1400
+ GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
1401
+ GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
1402
+ GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
1403
+ };
1404
+ }
1405
+ async function publishWorkspace(workspace, job, token, identity) {
1406
+ const pushed = [];
1407
+ for (const repository of workspace.repositories) {
1408
+ const options = { cwd: repository.path, env: identity };
1409
+ const sha = await cacheLock.run(repository.fullName, async () => {
1410
+ await git(["add", "--all"], options);
1411
+ const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
1412
+ if (hasStaged) {
1413
+ await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
1414
+ }
1415
+ const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
1416
+ if (newCommits === 0) {
1417
+ return null;
1418
+ }
1419
+ await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
1420
+ return git(["rev-parse", "HEAD"], options);
1421
+ });
1422
+ if (sha) {
1423
+ pushed.push({ repository: repository.fullName, branch: job.branch, sha });
1424
+ }
1425
+ }
1426
+ return pushed;
1427
+ }
1428
+
1429
+ // src/localWorkspace.ts
1430
+ var NETWORK_TIMEOUT_MS = 12e4;
1431
+ var sourceLock = keyedLock();
1432
+ function isLocalJob(job) {
1433
+ return job.workspace === "local";
1434
+ }
1435
+ function localBranchLine(branch) {
1436
+ const local = branch.local;
1437
+ if (!local) {
1438
+ return `Pushed ${branch.branch} in ${branch.repository}.`;
1439
+ }
1440
+ const where = `Branch ${branch.branch} has ${local.commits} commit(s) in ${local.folder}`;
1441
+ if (local.pushProblem) {
1442
+ return `${where}. It was not pushed: ${local.pushProblem}`;
1443
+ }
1444
+ return local.remoteUrl ? `${where}, pushed to ${local.remoteUrl}.` : `${where}.`;
1445
+ }
1446
+ function readsOnly(job) {
1447
+ return isReadOnlyCheck(job) || job.stage.kind === "interview" || job.stage.kind === "spec";
1448
+ }
1449
+ async function isAncestor(folder, older, newer) {
1450
+ return (await runGit(["merge-base", "--is-ancestor", older, newer], { cwd: folder })).code === 0;
1451
+ }
1452
+ async function fetchRemote(source, remote, print2) {
1453
+ const fetched = await runGit(["fetch", "--prune", remote], { cwd: source, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
1454
+ if (fetched.code !== 0) {
1455
+ print2(`Could not fetch from ${remote} in ${source}, going on with what the folder has: ${fetched.stderr || `exit code ${fetched.code}`}`);
1456
+ }
1457
+ }
1458
+ async function baseBranchRef(source, branch, remote) {
1459
+ const own = await hasRef(source, `refs/heads/${branch}`) ? branch : null;
1460
+ const fetched = remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
1461
+ if (own && fetched) {
1462
+ return await isAncestor(source, own, fetched) ? fetched : own;
1463
+ }
1464
+ const found = own ?? fetched;
1465
+ if (!found) {
1466
+ throw new Error(`The main branch ${branch} is not in ${source}. Check the folder again in the board settings, on the Code tab.`);
1467
+ }
1468
+ return found;
1469
+ }
1470
+ async function taskBranchRef(source, branch, remote, base) {
1471
+ if (await hasRef(source, `refs/heads/${branch}`) && Number(await git(["rev-list", "--count", `${base}..${branch}`], { cwd: source })) > 0) {
1472
+ return branch;
1473
+ }
1474
+ return remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
1475
+ }
1476
+ async function startRef(source, job, local) {
1477
+ const base = await baseBranchRef(source, local.baseBranch, local.remote);
1478
+ const task = await taskBranchRef(source, job.branch, local.remote, base);
1479
+ if (task || !isReadOnlyCheck(job)) {
1480
+ return task ?? base;
1481
+ }
1482
+ const where = local.remote ? `${source} or on its remote ${local.remote}` : source;
1483
+ throw new Error(`Branch ${job.branch} is not in ${where}. It was made on another computer: push it to a remote this folder can fetch, or run this check on that computer.`);
1484
+ }
1485
+ async function releaseBranch2(source, branch, keep, home) {
1486
+ const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: source });
1487
+ const work2 = tamsenseFolder(home, "work");
1488
+ for (const holder of branchHolders(listed.stdout, branch).filter((path) => resolve3(path) !== resolve3(keep))) {
1489
+ if (!isInside(holder, work2)) {
1490
+ throw new Error(`Branch ${branch} is checked out in ${resolve3(holder)}. Switch that folder to another branch, then start the step again.`);
1491
+ }
1492
+ await removeWorktree(source, holder);
1493
+ }
1494
+ }
1495
+ async function keptBase2(path, job) {
1496
+ if (!job.keepWorkspace || !existsSync4(join4(path, ".git"))) {
1497
+ return null;
1498
+ }
1499
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
1500
+ if (branch.code !== 0 || branch.stdout !== (readsOnly(job) ? "HEAD" : job.branch)) {
1501
+ return null;
1502
+ }
1503
+ return git(["rev-parse", "HEAD"], { cwd: path });
1504
+ }
1505
+ function sourceOf(repository) {
1506
+ if (!repository.folder) {
1507
+ throw new Error(`TAMsense did not say which folder holds ${repository.fullName} on this computer.`);
1508
+ }
1509
+ return repository.folder;
1510
+ }
1511
+ async function prepareRepository(job, repository, path, tools) {
1512
+ const source = sourceOf(repository);
1513
+ if ((await runGit(["rev-parse", "--show-toplevel"], { cwd: source }).catch(() => null))?.code !== 0) {
1514
+ throw new Error(`${source} is no longer a git repository on this computer. Check the folder again in the board settings, on the Code tab.`);
1515
+ }
1516
+ const local = { source, remote: await remoteOf(source), baseBranch: repository.defaultBranch };
1517
+ if (local.remote) {
1518
+ await fetchRemote(source, local.remote, tools.print);
1519
+ }
1520
+ const kept = await keptBase2(path, job);
1521
+ if (kept) {
1522
+ return { baseSha: kept, local };
1523
+ }
1524
+ await runGit(["worktree", "prune"], { cwd: source });
1525
+ if (existsSync4(path)) {
1526
+ await removeWorktree(source, path);
1527
+ }
1528
+ const start = await startRef(source, job, local);
1529
+ if (readsOnly(job)) {
1530
+ await git(["worktree", "add", "--detach", path, start], { cwd: source });
1531
+ } else {
1532
+ await releaseBranch2(source, job.branch, path, tools.home);
1533
+ await git(["worktree", "add", "-B", job.branch, path, start], { cwd: source });
1534
+ }
1535
+ return { baseSha: await git(["rev-parse", "HEAD"], { cwd: path }), local };
1536
+ }
1537
+ async function prepareLocalWorkspace(job, tools) {
1538
+ const root = taskFolder(tools.home, job);
1539
+ const folders = folderNames(job.repositories);
1540
+ const repositories = [];
1541
+ for (const [index, repository] of job.repositories.entries()) {
1542
+ const folder = folders[index] ?? repository.fullName;
1543
+ const path = join4(root, folder);
1544
+ const prepared = await sourceLock.run(resolve3(sourceOf(repository)), () => prepareRepository(job, repository, path, tools));
1545
+ repositories.push({ fullName: repository.fullName, folder, path, baseSha: prepared.baseSha, local: prepared.local });
1546
+ }
1547
+ return { root, repositories };
1548
+ }
1549
+ async function pushBranch(path, local, branch) {
1550
+ if (!local.remote) {
1551
+ return { remoteUrl: null, pushProblem: null };
1552
+ }
1553
+ if (branch === local.baseBranch) {
1554
+ return { remoteUrl: null, pushProblem: `TAMsense never pushes the main branch ${branch}.` };
1555
+ }
1556
+ const pushed = await runGit(["push", local.remote, `HEAD:refs/heads/${branch}`], { cwd: path, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
1557
+ if (pushed.code !== 0) {
1558
+ return { remoteUrl: null, pushProblem: pushed.stderr || `exit code ${pushed.code}` };
1559
+ }
1560
+ return { remoteUrl: await git(["remote", "get-url", local.remote], { cwd: path }), pushProblem: null };
1561
+ }
1562
+ async function commitAndPush(repository, local, job, identity) {
1563
+ const options = { cwd: repository.path, env: identity };
1564
+ await git(["add", "--all"], options);
1565
+ if ((await runGit(["diff", "--cached", "--quiet"], options)).code === 1) {
1566
+ await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
1567
+ }
1568
+ if (Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options)) === 0) {
1569
+ return null;
1570
+ }
1571
+ const base = await baseBranchRef(repository.path, local.baseBranch, local.remote);
1572
+ const commits = Number(await git(["rev-list", "--count", `${base}..HEAD`], options));
1573
+ const sent2 = await pushBranch(repository.path, local, job.branch);
1574
+ return { sha: await git(["rev-parse", "HEAD"], options), commits, ...sent2 };
1575
+ }
1576
+ async function publishLocalWorkspace(workspace, job, identity) {
1577
+ const pushed = [];
1578
+ for (const repository of workspace.repositories) {
1579
+ const local = repository.local;
1580
+ if (!local) {
1581
+ continue;
1582
+ }
1583
+ const branch = await sourceLock.run(resolve3(local.source), async () => {
1584
+ const published = await commitAndPush(repository, local, job, identity);
1585
+ await runGit(["checkout", "--quiet", "--detach"], { cwd: repository.path });
1586
+ return published;
1587
+ });
1588
+ if (branch) {
1589
+ pushed.push({
1590
+ repository: repository.fullName,
1591
+ branch: job.branch,
1592
+ sha: branch.sha,
1593
+ local: { folder: local.source, baseBranch: local.baseBranch, commits: branch.commits, remoteUrl: branch.remoteUrl && remoteWithoutSecrets(branch.remoteUrl), pushProblem: branch.pushProblem }
1594
+ });
1595
+ }
1596
+ }
1597
+ return pushed;
1598
+ }
1599
+
1090
1600
  // src/results.ts
1091
- import { mkdir as mkdir2, readFile as readFile2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
1092
- import { basename, extname, isAbsolute, join as join2, resolve } from "path";
1601
+ import { mkdir as mkdir2, readFile as readFile2, rm as rm3, stat, writeFile as writeFile2 } from "fs/promises";
1602
+ import { basename as basename2, extname, isAbsolute as isAbsolute2, join as join5, resolve as resolve4 } from "path";
1093
1603
  import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
1094
1604
  import { z as z2 } from "zod";
1095
1605
  var SAVE_DOCUMENT_TOOL_NAME = "save_result_document";
@@ -1151,7 +1661,7 @@ function linksText(links) {
1151
1661
  }
1152
1662
  async function writeResults(folder, job, api) {
1153
1663
  const results = job.results ?? [];
1154
- await rm2(folder, { recursive: true, force: true });
1664
+ await rm3(folder, { recursive: true, force: true });
1155
1665
  if (results.length === 0) {
1156
1666
  return [];
1157
1667
  }
@@ -1164,24 +1674,24 @@ async function writeResults(folder, job, api) {
1164
1674
  if (result.kind === "file") {
1165
1675
  try {
1166
1676
  const file = await api.request("GET", resultFilePath(job.runId, result.id));
1167
- await writeFile2(join2(folder, name), Buffer.from(file.data, "base64"));
1677
+ await writeFile2(join5(folder, name), Buffer.from(file.data, "base64"));
1168
1678
  } catch (error) {
1169
1679
  missing.push(`${result.title}: ${error instanceof Error ? error.message : String(error)}`);
1170
1680
  continue;
1171
1681
  }
1172
1682
  } else {
1173
- await writeFile2(join2(folder, name), result.content ?? "");
1683
+ await writeFile2(join5(folder, name), result.content ?? "");
1174
1684
  }
1175
1685
  lines.push(`${RESULTS_FOLDER}/${name}: ${result.title}${result.version ? `, version ${result.version}` : ""}`);
1176
1686
  }
1177
1687
  const links = results.filter((item) => item.kind === "link");
1178
1688
  if (links.length > 0) {
1179
- await writeFile2(join2(folder, "links.md"), `${linksText(links)}
1689
+ await writeFile2(join5(folder, "links.md"), `${linksText(links)}
1180
1690
  `);
1181
1691
  lines.push(`${RESULTS_FOLDER}/links.md: ${links.length} link(s)`);
1182
1692
  }
1183
1693
  if (missing.length > 0) {
1184
- await writeFile2(join2(folder, "notes.md"), `These files could not be downloaded:
1694
+ await writeFile2(join5(folder, "notes.md"), `These files could not be downloaded:
1185
1695
  ${missing.map((line) => `- ${line}`).join("\n")}
1186
1696
  `);
1187
1697
  }
@@ -1189,13 +1699,13 @@ ${missing.map((line) => `- ${line}`).join("\n")}
1189
1699
  }
1190
1700
  async function prepareFolder(job, root, api) {
1191
1701
  if (!job.keepWorkspace) {
1192
- await rm2(root, { recursive: true, force: true });
1702
+ await rm3(root, { recursive: true, force: true });
1193
1703
  }
1194
1704
  await mkdir2(root, { recursive: true });
1195
- return { root, repositories: [], results: await writeResults(join2(root, RESULTS_FOLDER), job, api) };
1705
+ return { root, repositories: [], results: await writeResults(join5(root, RESULTS_FOLDER), job, api) };
1196
1706
  }
1197
1707
  async function readResultFile(root, path, title) {
1198
- const full = isAbsolute(path) ? path : resolve(root, path);
1708
+ const full = isAbsolute2(path) ? path : resolve4(root, path);
1199
1709
  const info = await stat(full).catch(() => null);
1200
1710
  if (!info?.isFile()) {
1201
1711
  return `There is no file at ${full}.`;
@@ -1203,7 +1713,7 @@ async function readResultFile(root, path, title) {
1203
1713
  if (info.size > MAX_RESULT_FILE_BYTES) {
1204
1714
  return `The file is larger than ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB. Attach a smaller one or split it.`;
1205
1715
  }
1206
- const name = basename(full);
1716
+ const name = basename2(full);
1207
1717
  return { name, title: title.trim() || name, contentType: TYPES_BY_EXTENSION[extname(full).toLowerCase()] ?? "application/octet-stream", data: (await readFile2(full)).toString("base64") };
1208
1718
  }
1209
1719
  function reply(text2, isError = false) {
@@ -1376,209 +1886,6 @@ function specPromptText(spec) {
1376
1886
  ].filter(Boolean).join("\n\n");
1377
1887
  }
1378
1888
 
1379
- // src/workspace.ts
1380
- import { existsSync as existsSync2 } from "fs";
1381
- import { rm as rm3 } from "fs/promises";
1382
- import { join as join3, resolve as resolve2 } from "path";
1383
-
1384
- // src/git.ts
1385
- import { Buffer as Buffer2 } from "buffer";
1386
- import { spawn as spawn2 } from "child_process";
1387
- var GITHUB_ORIGIN = "https://github.com/";
1388
- function githubAuthEnv(token) {
1389
- const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
1390
- return {
1391
- GIT_CONFIG_COUNT: "1",
1392
- GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
1393
- GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
1394
- GIT_TERMINAL_PROMPT: "0"
1395
- };
1396
- }
1397
- function runGit(args, options) {
1398
- const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
1399
- return new Promise((resolve3, reject) => {
1400
- const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1401
- let stdout = "";
1402
- let stderr = "";
1403
- child.stdout.on("data", (chunk) => {
1404
- stdout += chunk.toString();
1405
- });
1406
- child.stderr.on("data", (chunk) => {
1407
- stderr += chunk.toString();
1408
- });
1409
- child.on("error", reject);
1410
- child.on("close", (code) => resolve3({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
1411
- });
1412
- }
1413
- async function git(args, options) {
1414
- const result = await runGit(args, options);
1415
- if (result.code !== 0) {
1416
- throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
1417
- }
1418
- return result.stdout;
1419
- }
1420
-
1421
- // src/lock.ts
1422
- function keyedLock() {
1423
- const tails = /* @__PURE__ */ new Map();
1424
- return {
1425
- async run(key, work2) {
1426
- const previous = tails.get(key) ?? Promise.resolve();
1427
- let release = () => {
1428
- };
1429
- const done2 = new Promise((resolve3) => {
1430
- release = resolve3;
1431
- });
1432
- const tail = previous.then(() => done2);
1433
- tails.set(key, tail);
1434
- await previous;
1435
- try {
1436
- return await work2();
1437
- } finally {
1438
- release();
1439
- if (tails.get(key) === tail) {
1440
- tails.delete(key);
1441
- }
1442
- }
1443
- }
1444
- };
1445
- }
1446
-
1447
- // src/workspace.ts
1448
- var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
1449
- var cacheLock = keyedLock();
1450
- function githubRemoteUrl(fullName) {
1451
- return `https://github.com/${fullName}.git`;
1452
- }
1453
- function tamsenseFolder(home, ...parts) {
1454
- return join3(home, ".tamsense", ...parts);
1455
- }
1456
- function folderNames(repositories) {
1457
- const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
1458
- const counts = /* @__PURE__ */ new Map();
1459
- for (const repository of repositories) {
1460
- counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
1461
- }
1462
- return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
1463
- }
1464
- async function refreshCache(cache, repository, token, tools) {
1465
- if (existsSync2(join3(cache, ".git"))) {
1466
- await git(["fetch", "--prune", "origin"], { cwd: cache, token });
1467
- return;
1468
- }
1469
- await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
1470
- }
1471
- async function removeWorktree(cache, path) {
1472
- await runGit(["worktree", "remove", "--force", path], { cwd: cache });
1473
- await rm3(path, { recursive: true, force: true });
1474
- await git(["worktree", "prune"], { cwd: cache });
1475
- }
1476
- function stepFolder(job) {
1477
- const task = `task-${job.taskId}`;
1478
- return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
1479
- }
1480
- function isReadOnlyCheck(job) {
1481
- return job.stage.kind === "review" || job.stage.kind === "test";
1482
- }
1483
- function branchHolders(porcelain, branch) {
1484
- const holders = [];
1485
- let path = null;
1486
- for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
1487
- if (line.startsWith("worktree ")) {
1488
- path = line.slice("worktree ".length);
1489
- } else if (path && line === `branch refs/heads/${branch}`) {
1490
- holders.push(path);
1491
- }
1492
- }
1493
- return holders;
1494
- }
1495
- async function releaseBranch(cache, branch, keep) {
1496
- const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
1497
- for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
1498
- await removeWorktree(cache, path);
1499
- }
1500
- }
1501
- async function baseRef(cache, job, repository) {
1502
- const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
1503
- return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
1504
- }
1505
- async function keptBase(cache, path, job, repository) {
1506
- if (!job.keepWorkspace || !existsSync2(join3(path, ".git"))) {
1507
- return null;
1508
- }
1509
- const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
1510
- if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
1511
- return null;
1512
- }
1513
- return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
1514
- }
1515
- function taskFolder(home, job) {
1516
- return tamsenseFolder(home, "work", ...stepFolder(job));
1517
- }
1518
- async function prepareWorkspace(job, token, tools) {
1519
- const root = taskFolder(tools.home, job);
1520
- const folders = folderNames(job.repositories);
1521
- const repositories = [];
1522
- for (const [index, repository] of job.repositories.entries()) {
1523
- const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
1524
- const folder = folders[index] ?? repository.fullName;
1525
- const path = join3(root, folder);
1526
- const baseSha = await cacheLock.run(repository.fullName, async () => {
1527
- await refreshCache(cache, repository, token, tools);
1528
- const kept = await keptBase(cache, path, job, repository);
1529
- if (kept) {
1530
- return kept;
1531
- }
1532
- await removeWorktree(cache, path);
1533
- const base = await baseRef(cache, job, repository);
1534
- if (isReadOnlyCheck(job)) {
1535
- await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
1536
- } else {
1537
- await releaseBranch(cache, job.branch, path);
1538
- await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
1539
- }
1540
- return git(["rev-parse", "HEAD"], { cwd: path });
1541
- });
1542
- repositories.push({ fullName: repository.fullName, folder, path, baseSha });
1543
- }
1544
- return { root, repositories };
1545
- }
1546
- async function commitIdentityEnv(home) {
1547
- const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
1548
- if (configured.code === 0 && configured.stdout) {
1549
- return {};
1550
- }
1551
- return {
1552
- GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
1553
- GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
1554
- GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
1555
- GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
1556
- };
1557
- }
1558
- async function publishWorkspace(workspace, job, token, identity) {
1559
- const pushed = [];
1560
- for (const repository of workspace.repositories) {
1561
- const options = { cwd: repository.path, env: identity };
1562
- const sha = await cacheLock.run(repository.fullName, async () => {
1563
- await git(["add", "--all"], options);
1564
- const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
1565
- if (hasStaged) {
1566
- await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
1567
- }
1568
- const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
1569
- if (newCommits === 0) {
1570
- return null;
1571
- }
1572
- await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
1573
- return git(["rev-parse", "HEAD"], options);
1574
- });
1575
- if (sha) {
1576
- pushed.push({ repository: repository.fullName, branch: job.branch, sha });
1577
- }
1578
- }
1579
- return pushed;
1580
- }
1581
-
1582
1889
  // src/prompt.ts
1583
1890
  var VERDICT_TOOL_NAME = "submit_verdict";
1584
1891
  var SCREENSHOT_TOOL_NAME = "attach_screenshot";
@@ -1599,6 +1906,8 @@ var CODE_RULES = [
1599
1906
  "Check your work with the project's own tools: run the tests, type checks and linters that exist, and fix what you broke.",
1600
1907
  "Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch and opens the pull request."
1601
1908
  ];
1909
+ var LOCAL_COMMIT_RULE = "Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch when the folder has a remote, and the person merges it.";
1910
+ var LOCAL_FOLDERS_RULE = "Each repository here is a git worktree of the person's own folder on this computer. Work only here: never open, change or check out branches in the person's original folder.";
1602
1911
  var REWORK_RULE = "This is a rework. The branch already has the earlier work and maybe the reviewer's own commits: treat the current branch as the source of truth, keep the reviewer's changes unless a comment asks otherwise, and address every comment.";
1603
1912
  var CODE_REPORT_RULE = "End with a short report for the reviewer: what you changed, how you checked it, and anything left open.";
1604
1913
  var READ_ONLY_CODE_RULES = [
@@ -1708,13 +2017,17 @@ function bulletList(lines) {
1708
2017
  return lines.map((line) => `- ${line}`).join("\n");
1709
2018
  }
1710
2019
  function repositoryLines(job, workspace) {
1711
- const sourceOf = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
2020
+ const sourceOf2 = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
1712
2021
  return workspace.repositories.map((repository) => {
1713
- const source = sourceOf.get(repository.fullName);
2022
+ const source = sourceOf2.get(repository.fullName);
1714
2023
  const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
1715
- return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
2024
+ const base = repository.local ? repository.local.baseBranch : `origin/${source?.defaultBranch ?? "main"}`;
2025
+ return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch ${base}${pull}`;
1716
2026
  });
1717
2027
  }
2028
+ function codeRules(job) {
2029
+ return isLocalJob(job) ? [...CODE_RULES.slice(0, -1), LOCAL_COMMIT_RULE] : CODE_RULES;
2030
+ }
1718
2031
  function stepLine(stage) {
1719
2032
  return `This step of the board is "${stage.name}"${stage.helper ? `, and you work as the ${stage.helper}` : ""}.`;
1720
2033
  }
@@ -1873,6 +2186,7 @@ ${JSON.stringify(job.spec)}` : null,
1873
2186
  contextText(job.projectContext),
1874
2187
  `The repositories for this task are checked out in the current folder, one subfolder each:
1875
2188
  ${bulletList(repositoryLines(job, workspace))}`,
2189
+ isLocalJob(job) && LOCAL_FOLDERS_RULE,
1876
2190
  READ_ONLY_RULE,
1877
2191
  `How to work:
1878
2192
  ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
@@ -1948,7 +2262,7 @@ ${job.review}`,
1948
2262
  ].filter(Boolean).join("\n\n");
1949
2263
  const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
1950
2264
  const localApp = localAppRule(job, isCheck, canAsk);
1951
- const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
2265
+ const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...codeRules(job), ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
1952
2266
  const instructions = [
1953
2267
  canAsk ? ASKING_RULE : UNATTENDED_RULE,
1954
2268
  MACHINE_RULE,
@@ -1958,6 +2272,7 @@ ${job.review}`,
1958
2272
  contextText(job.projectContext),
1959
2273
  `The repositories for this task are checked out in the current folder, one subfolder each, ${isCheck ? "at the latest commit of branch" : "on branch"} ${job.branch}:
1960
2274
  ${bulletList(repositoryLines(job, workspace))}`,
2275
+ isLocalJob(job) && LOCAL_FOLDERS_RULE,
1961
2276
  `How to work:
1962
2277
  ${bulletList(rules)}`,
1963
2278
  safetyText(job),
@@ -2526,11 +2841,11 @@ var runClaude = claudeRunner(query2);
2526
2841
  // src/config.ts
2527
2842
  import { mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
2528
2843
  import { homedir as homedir2 } from "os";
2529
- import { dirname as dirname2, join as join4 } from "path";
2844
+ import { dirname as dirname2, join as join6 } from "path";
2530
2845
  var OWNER_ONLY_FILE = 384;
2531
2846
  var OWNER_ONLY_FOLDER = 448;
2532
2847
  function configPath(home = homedir2()) {
2533
- return join4(home, ".tamsense", "runner.json");
2848
+ return join6(home, ".tamsense", "runner.json");
2534
2849
  }
2535
2850
  function isRecord2(value) {
2536
2851
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -2594,15 +2909,15 @@ async function login(tools, request) {
2594
2909
  }
2595
2910
 
2596
2911
  // src/recorderEnv.ts
2597
- import { existsSync as existsSync3 } from "fs";
2598
- import { join as join6 } from "path";
2912
+ import { existsSync as existsSync5 } from "fs";
2913
+ import { join as join8 } from "path";
2599
2914
  import { fileURLToPath } from "url";
2600
2915
 
2601
2916
  // src/recorder.ts
2602
2917
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2603
2918
  import Module, { createRequire } from "module";
2604
- import { basename as basename2, dirname as dirname3, join as join5 } from "path";
2605
- import process2 from "process";
2919
+ import { basename as basename3, dirname as dirname3, join as join7 } from "path";
2920
+ import process4 from "process";
2606
2921
  var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
2607
2922
  var SERVER_ENV = "TAMSENSE_SERVER";
2608
2923
  var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
@@ -2626,14 +2941,14 @@ function state() {
2626
2941
  return holder[STATE_KEY];
2627
2942
  }
2628
2943
  function recordingsDir() {
2629
- const dir = process2.env[RECORDINGS_DIR_ENV];
2944
+ const dir = process4.env[RECORDINGS_DIR_ENV];
2630
2945
  if (!dir) {
2631
2946
  throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
2632
2947
  }
2633
2948
  return dir;
2634
2949
  }
2635
2950
  function warn(text2) {
2636
- process2.stderr.write(`[tamsense recorder] ${text2}
2951
+ process4.stderr.write(`[tamsense recorder] ${text2}
2637
2952
  `);
2638
2953
  }
2639
2954
  function isTrackerIngest(url, tamsenseOrigins) {
@@ -2649,7 +2964,7 @@ function isLocalHost(hostname2) {
2649
2964
  function isExternalWrite(method, url) {
2650
2965
  return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
2651
2966
  }
2652
- function trackerOrigins(env = process2.env) {
2967
+ function trackerOrigins(env = process4.env) {
2653
2968
  const origins = new Set(DEFAULT_TRACKER_ORIGINS);
2654
2969
  try {
2655
2970
  if (env[SERVER_ENV]) {
@@ -2673,8 +2988,8 @@ function checkCaption(caption) {
2673
2988
  function clip2(text2, max) {
2674
2989
  return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
2675
2990
  }
2676
- function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
2677
- const from = script2 ? `, opened by ${basename2(script2)}` : "";
2991
+ function automaticCaption(env = process4.env, script2 = process4.argv[1]) {
2992
+ const from = script2 ? `, opened by ${basename3(script2)}` : "";
2678
2993
  return {
2679
2994
  title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
2680
2995
  purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
@@ -2682,7 +2997,7 @@ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
2682
2997
  }
2683
2998
  function rrwebSource() {
2684
2999
  const entry = createRequire(import.meta.url).resolve("rrweb");
2685
- return readFileSync(join5(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
3000
+ return readFileSync(join7(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
2686
3001
  }
2687
3002
  function pageScript(rrweb) {
2688
3003
  return `(() => {
@@ -2733,8 +3048,8 @@ var Session = class {
2733
3048
  bytes = 0;
2734
3049
  constructor(dir, recording, url) {
2735
3050
  const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
2736
- this.file = join5(dir, `${id}${REPLAY_SUFFIX}`);
2737
- this.metaFile = join5(dir, `${id}${META_SUFFIX}`);
3051
+ this.file = join7(dir, `${id}${REPLAY_SUFFIX}`);
3052
+ this.metaFile = join7(dir, `${id}${META_SUFFIX}`);
2738
3053
  this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic };
2739
3054
  writeFileSync(this.file, "");
2740
3055
  this.save();
@@ -2899,7 +3214,7 @@ function installAutoRecording() {
2899
3214
  return exports;
2900
3215
  };
2901
3216
  }
2902
- if (process2.env[RECORDINGS_DIR_ENV]) {
3217
+ if (process4.env[RECORDINGS_DIR_ENV]) {
2903
3218
  installAutoRecording();
2904
3219
  }
2905
3220
 
@@ -2907,10 +3222,10 @@ if (process2.env[RECORDINGS_DIR_ENV]) {
2907
3222
  var RECORDER_ENV = "TAMSENSE_RECORDER";
2908
3223
  function bundledRecorder() {
2909
3224
  const path = fileURLToPath(new URL("./recorder.cjs", import.meta.url));
2910
- return existsSync3(path) ? path : null;
3225
+ return existsSync5(path) ? path : null;
2911
3226
  }
2912
3227
  function recordingsFolder(home, runId) {
2913
- return join6(home, ".tamsense", "recordings", `run-${runId}`);
3228
+ return join8(home, ".tamsense", "recordings", `run-${runId}`);
2914
3229
  }
2915
3230
  function nodeOptionsWithRecorder(recorder, nodeOptions) {
2916
3231
  return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
@@ -2983,7 +3298,7 @@ async function sleep(ms, signal) {
2983
3298
  // src/update.ts
2984
3299
  import { spawn as spawn4 } from "child_process";
2985
3300
  import { readFileSync as readFileSync2 } from "fs";
2986
- import process3 from "process";
3301
+ import process5 from "process";
2987
3302
  var PACKAGE_NAME = "@tamsensedev/runner";
2988
3303
  var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
2989
3304
  var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
@@ -3035,19 +3350,19 @@ function relaunchArgs(argv) {
3035
3350
  return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
3036
3351
  }
3037
3352
  function relaunch(argv, env) {
3038
- const isWindows = process3.platform === "win32";
3039
- return new Promise((resolve3, reject) => {
3353
+ const isWindows = process5.platform === "win32";
3354
+ return new Promise((resolve5, reject) => {
3040
3355
  const child = spawn4(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
3041
3356
  const ignoreInterrupt = () => {
3042
3357
  };
3043
- process3.on("SIGINT", ignoreInterrupt);
3358
+ process5.on("SIGINT", ignoreInterrupt);
3044
3359
  child.on("error", (error) => {
3045
- process3.off("SIGINT", ignoreInterrupt);
3360
+ process5.off("SIGINT", ignoreInterrupt);
3046
3361
  reject(error);
3047
3362
  });
3048
3363
  child.on("close", (code) => {
3049
- process3.off("SIGINT", ignoreInterrupt);
3050
- resolve3(code ?? 1);
3364
+ process5.off("SIGINT", ignoreInterrupt);
3365
+ resolve5(code ?? 1);
3051
3366
  });
3052
3367
  });
3053
3368
  }
@@ -3055,7 +3370,7 @@ function relaunch(argv, env) {
3055
3370
  // src/worker.ts
3056
3371
  import { rm as rm6 } from "fs/promises";
3057
3372
  import { dirname as dirname5 } from "path";
3058
- import process4 from "process";
3373
+ import process6 from "process";
3059
3374
 
3060
3375
  // src/feedQueue.ts
3061
3376
  var MAX_BATCH = 200;
@@ -3094,9 +3409,9 @@ function feedQueue(send) {
3094
3409
  }
3095
3410
 
3096
3411
  // src/library.ts
3097
- import { existsSync as existsSync4 } from "fs";
3412
+ import { existsSync as existsSync6 } from "fs";
3098
3413
  import { cp, mkdir as mkdir4, rm as rm5 } from "fs/promises";
3099
- import { join as join7 } from "path";
3414
+ import { join as join9 } from "path";
3100
3415
  import { fileURLToPath as fileURLToPath2 } from "url";
3101
3416
  var LIBRARY_PATH = fileURLToPath2(new URL("../library", import.meta.url));
3102
3417
  var SKILL_NAME = /^[a-z0-9-]+$/;
@@ -3109,18 +3424,18 @@ function allowedSkills(stage) {
3109
3424
  return skills && [.../* @__PURE__ */ new Set([...skills, ...STAGE_SKILLS[stage.kind] ?? []])].filter((name) => SKILL_NAME.test(name));
3110
3425
  }
3111
3426
  function roleLibraryFolder(home, runId) {
3112
- return join7(home, ".tamsense", "libraries", `run-${runId}`);
3427
+ return join9(home, ".tamsense", "libraries", `run-${runId}`);
3113
3428
  }
3114
3429
  async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
3115
3430
  if (skills === null) {
3116
3431
  return source;
3117
3432
  }
3118
3433
  await rm5(folder, { recursive: true, force: true });
3119
- await mkdir4(join7(folder, "skills"), { recursive: true });
3120
- await cp(join7(source, ".claude-plugin"), join7(folder, ".claude-plugin"), { recursive: true });
3121
- await cp(join7(source, "agents"), join7(folder, "agents"), { recursive: true });
3122
- for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync4(join7(source, "skills", name)))) {
3123
- await cp(join7(source, "skills", skill), join7(folder, "skills", skill), { recursive: true });
3434
+ await mkdir4(join9(folder, "skills"), { recursive: true });
3435
+ await cp(join9(source, ".claude-plugin"), join9(folder, ".claude-plugin"), { recursive: true });
3436
+ await cp(join9(source, "agents"), join9(folder, "agents"), { recursive: true });
3437
+ for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync6(join9(source, "skills", name)))) {
3438
+ await cp(join9(source, "skills", skill), join9(folder, "skills", skill), { recursive: true });
3124
3439
  }
3125
3440
  return folder;
3126
3441
  }
@@ -3129,7 +3444,7 @@ async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
3129
3444
  import { randomUUID } from "crypto";
3130
3445
  import { createReadStream } from "fs";
3131
3446
  import { readdir, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
3132
- import { basename as basename3, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute2, join as join8 } from "path";
3447
+ import { basename as basename4, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute3, join as join10 } from "path";
3133
3448
  import { createInterface as createInterface2 } from "readline";
3134
3449
  import { Readable } from "stream";
3135
3450
  import { promisify } from "util";
@@ -3190,7 +3505,7 @@ function marksFrom(marks, startTs) {
3190
3505
  return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
3191
3506
  }
3192
3507
  function sessionIdOf(file) {
3193
- return basename3(file).replace(REPLAY_SUFFIX, "");
3508
+ return basename4(file).replace(REPLAY_SUFFIX, "");
3194
3509
  }
3195
3510
  async function readMeta(file) {
3196
3511
  try {
@@ -3201,12 +3516,12 @@ async function readMeta(file) {
3201
3516
  }
3202
3517
  async function metaFor(kind, file) {
3203
3518
  if (kind === "replay") {
3204
- return readMeta(join8(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
3519
+ return readMeta(join10(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
3205
3520
  }
3206
- const folder = basename3(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
3521
+ const folder = basename4(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
3207
3522
  for (const name of await readdir(folder).catch(() => [])) {
3208
3523
  if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
3209
- const meta = await readMeta(join8(folder, name));
3524
+ const meta = await readMeta(join10(folder, name));
3210
3525
  if (meta?.video === file) {
3211
3526
  return meta;
3212
3527
  }
@@ -3216,7 +3531,7 @@ async function metaFor(kind, file) {
3216
3531
  }
3217
3532
  async function ledger(dir) {
3218
3533
  try {
3219
- return new Set(JSON.parse(await readFile4(join8(dir, LEDGER_FILE), "utf8")));
3534
+ return new Set(JSON.parse(await readFile4(join10(dir, LEDGER_FILE), "utf8")));
3220
3535
  } catch {
3221
3536
  return /* @__PURE__ */ new Set();
3222
3537
  }
@@ -3224,7 +3539,7 @@ async function ledger(dir) {
3224
3539
  async function remember(dir, file) {
3225
3540
  const done2 = await ledger(dir);
3226
3541
  done2.add(file);
3227
- await writeFile4(join8(dir, LEDGER_FILE), JSON.stringify([...done2]));
3542
+ await writeFile4(join10(dir, LEDGER_FILE), JSON.stringify([...done2]));
3228
3543
  }
3229
3544
  async function nonEmpty(file) {
3230
3545
  return file !== null && ((await stat2(file).catch(() => null))?.size ?? 0) > 0;
@@ -3237,8 +3552,8 @@ async function pendingRecordings(dir) {
3237
3552
  if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
3238
3553
  continue;
3239
3554
  }
3240
- const meta = await readMeta(join8(dir, name));
3241
- const replay = join8(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
3555
+ const meta = await readMeta(join10(dir, name));
3556
+ const replay = join10(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
3242
3557
  if (!done2.has(replay) && await nonEmpty(replay)) {
3243
3558
  pending.push({ kind: "replay", file: replay, meta });
3244
3559
  }
@@ -3258,7 +3573,7 @@ async function uploadReplay(upload, file, caption) {
3258
3573
  if (!scan.snapshot) {
3259
3574
  throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
3260
3575
  }
3261
- return finish(upload, { kind: "replay", upload: id, name: basename3(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
3576
+ return finish(upload, { kind: "replay", upload: id, name: basename4(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
3262
3577
  }
3263
3578
  async function uploadVideo(upload, file, caption, meta) {
3264
3579
  const contentType = VIDEO_TYPES_BY_EXTENSION[extname2(file).toLowerCase()];
@@ -3273,13 +3588,13 @@ async function uploadVideo(upload, file, caption, meta) {
3273
3588
  const endedAt = meta?.endedAt ?? info.mtimeMs;
3274
3589
  const durationMs = Math.max(0, Math.round(endedAt - startedAt));
3275
3590
  const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
3276
- return finish(upload, { kind: "video", upload: id, name: basename3(file), ...caption, contentType, durationMs, marks });
3591
+ return finish(upload, { kind: "video", upload: id, name: basename4(file), ...caption, contentType, durationMs, marks });
3277
3592
  }
3278
3593
  async function finish(upload, body) {
3279
3594
  return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
3280
3595
  }
3281
3596
  async function attachRecording(api, runId, recordingsDir2, request) {
3282
- if (!isAbsolute2(request.path)) {
3597
+ if (!isAbsolute3(request.path)) {
3283
3598
  return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
3284
3599
  }
3285
3600
  const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname2(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
@@ -3348,7 +3663,7 @@ async function uploadPendingRecordings(api, runId, dir) {
3348
3663
 
3349
3664
  // src/screenshot.ts
3350
3665
  import { readFile as readFile5, stat as stat3 } from "fs/promises";
3351
- import { basename as basename4, extname as extname3, isAbsolute as isAbsolute3 } from "path";
3666
+ import { basename as basename5, extname as extname3, isAbsolute as isAbsolute4 } from "path";
3352
3667
  var TYPES_BY_EXTENSION2 = {
3353
3668
  ".png": "image/png",
3354
3669
  ".jpg": "image/jpeg",
@@ -3356,7 +3671,7 @@ var TYPES_BY_EXTENSION2 = {
3356
3671
  ".webp": "image/webp"
3357
3672
  };
3358
3673
  async function readScreenshot(request) {
3359
- if (!isAbsolute3(request.path)) {
3674
+ if (!isAbsolute4(request.path)) {
3360
3675
  return "Give the absolute path of the image file.";
3361
3676
  }
3362
3677
  const contentType = TYPES_BY_EXTENSION2[extname3(request.path).toLowerCase()];
@@ -3377,7 +3692,7 @@ async function readScreenshot(request) {
3377
3692
  return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
3378
3693
  }
3379
3694
  const data = (await readFile5(request.path)).toString("base64");
3380
- return { name: basename4(request.path), ...caption, contentType, data };
3695
+ return { name: basename5(request.path), ...caption, contentType, data };
3381
3696
  }
3382
3697
 
3383
3698
  // src/worker.ts
@@ -3577,10 +3892,18 @@ async function prepare(job, tools) {
3577
3892
  const { results, ...workspace2 } = await prepareFolder(job, taskFolder(tools.home, job), tools.api);
3578
3893
  return { workspace: workspace2, results, identity: {} };
3579
3894
  }
3895
+ if (isLocalJob(job)) {
3896
+ tools.print("Preparing the code from the folders on this computer\u2026");
3897
+ const workspace2 = await prepareLocalWorkspace(job, tools);
3898
+ return { workspace: workspace2, results: null, identity: await commitIdentityEnv(job.repositories[0]?.folder ?? tools.home) };
3899
+ }
3580
3900
  tools.print("Preparing the code\u2026");
3581
3901
  const workspace = await prepareWorkspace(job, await gitToken(tools.api, job), tools);
3582
3902
  return { workspace, results: null, identity: await commitIdentityEnv(tools.home) };
3583
3903
  }
3904
+ async function publish(workspace, job, api, identity) {
3905
+ return isLocalJob(job) ? await publishLocalWorkspace(workspace, job, identity) : await publishWorkspace(workspace, job, await gitToken(api, job), identity);
3906
+ }
3584
3907
  async function executeJob(job, tools, signal, input, record) {
3585
3908
  const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
3586
3909
  const { workspace, results, identity } = await prepare(job, tools);
@@ -3594,7 +3917,7 @@ async function executeJob(job, tools, signal, input, record) {
3594
3917
  const attached = { count: 0 };
3595
3918
  const session = sessionReporter(tools.api, job, tools.print);
3596
3919
  const recordings = recordingsFolder(tools.home, job.runId);
3597
- const baseEnv = withNodeOnPath(process4.env, dirname5(process4.execPath));
3920
+ const baseEnv = withNodeOnPath(process6.env, dirname5(process6.execPath));
3598
3921
  const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
3599
3922
  const outcome = await tools.runAgent({
3600
3923
  cwd: workspace.root,
@@ -3622,7 +3945,7 @@ async function executeJob(job, tools, signal, input, record) {
3622
3945
  signal.throwIfAborted();
3623
3946
  const waitingFor = outcome.waitingFor ?? null;
3624
3947
  const shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null;
3625
- const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
3948
+ const pushed = shouldPublish ? await publish(workspace, job, tools.api, identity) : [];
3626
3949
  return {
3627
3950
  outcome: outcome.completed ? "succeeded" : "failed",
3628
3951
  report: outcome.report,
@@ -3642,7 +3965,7 @@ function clockOf(iso) {
3642
3965
  const time = new Date(iso);
3643
3966
  return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
3644
3967
  }
3645
- function resultLine(finish2, folder = false) {
3968
+ function resultLine(finish2, workspace = "repositories") {
3646
3969
  if (finish2.pausedUntil) {
3647
3970
  return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
3648
3971
  }
@@ -3664,7 +3987,13 @@ function resultLine(finish2, folder = false) {
3664
3987
  if (finish2.outcome !== "succeeded") {
3665
3988
  return `Failed: ${finish2.report}`;
3666
3989
  }
3667
- return folder ? "Done. The result is on the task on TAMsense." : `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.`;
3990
+ if (workspace === "folder") {
3991
+ return "Done. The result is on the task on TAMsense.";
3992
+ }
3993
+ if (workspace === "local") {
3994
+ return finish2.pushed.length > 0 ? `Done. ${finish2.pushed.map(localBranchLine).join(" ")}` : "Done. Nothing new to commit.";
3995
+ }
3996
+ return `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.`;
3668
3997
  }
3669
3998
  async function sendRecordings(job, tools) {
3670
3999
  const folder = recordingsFolder(tools.home, job.runId);
@@ -3727,7 +4056,7 @@ async function runJob(job, tools, stop) {
3727
4056
  await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
3728
4057
  try {
3729
4058
  await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
3730
- tools.print(resultLine(finish2, isFolderJob(job)));
4059
+ tools.print(resultLine(finish2, job.workspace));
3731
4060
  } catch (error) {
3732
4061
  tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
3733
4062
  }
@@ -3765,6 +4094,7 @@ async function work(tools, stop, parallel = 1) {
3765
4094
  const running = /* @__PURE__ */ new Set();
3766
4095
  const folderLock = keyedLock();
3767
4096
  const reporting = reportCapabilities(tools, stopAll);
4097
+ const checking = tools.inspectFolder ? checkFolders({ ...tools, inspect: tools.inspectFolder }, stopAll) : Promise.resolve();
3768
4098
  const start = (job) => {
3769
4099
  const runTools = jobTools(job, tools, parallel);
3770
4100
  const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
@@ -3795,7 +4125,7 @@ async function work(tools, stop, parallel = 1) {
3795
4125
  }
3796
4126
  }
3797
4127
  } finally {
3798
- await Promise.allSettled([...running, reporting]);
4128
+ await Promise.allSettled([...running, reporting, checking]);
3799
4129
  }
3800
4130
  }
3801
4131
 
@@ -3806,11 +4136,11 @@ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the
3806
4136
  var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
3807
4137
  var INTERRUPTED_EXIT_CODE = 130;
3808
4138
  function print(line) {
3809
- process5.stdout.write(`${line}
4139
+ process7.stdout.write(`${line}
3810
4140
  `);
3811
4141
  }
3812
4142
  function serverFor(flag, saved) {
3813
- return serverOrigin(flag ?? process5.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
4143
+ return serverOrigin(flag ?? process7.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
3814
4144
  }
3815
4145
  function accessProblem(error) {
3816
4146
  return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
@@ -3836,15 +4166,15 @@ async function runLogin(server, deviceName) {
3836
4166
  }
3837
4167
  function stopOnInterrupt() {
3838
4168
  const stop = new AbortController();
3839
- process5.once("SIGINT", () => {
4169
+ process7.once("SIGINT", () => {
3840
4170
  print("Stopping\u2026 Press Ctrl+C again to quit at once.");
3841
4171
  stop.abort();
3842
- process5.once("SIGINT", () => process5.exit(INTERRUPTED_EXIT_CODE));
4172
+ process7.once("SIGINT", () => process7.exit(INTERRUPTED_EXIT_CODE));
3843
4173
  });
3844
4174
  return stop.signal;
3845
4175
  }
3846
4176
  function installTools(home) {
3847
- return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync5 };
4177
+ return { home, nodePath: process7.execPath, env: process7.env, print, run: runProcess, exists: existsSync7 };
3848
4178
  }
3849
4179
  async function browserForStart(home, install) {
3850
4180
  const installed = await installedBrowser(home);
@@ -3854,13 +4184,13 @@ async function browserForStart(home, install) {
3854
4184
  return ensureBrowser({ print, install: () => installBrowser(installTools(home)) });
3855
4185
  }
3856
4186
  async function startedNewVersion() {
3857
- const update = await updateToRun(process5.env, currentVersion(), () => latestVersion());
4187
+ const update = await updateToRun(process7.env, currentVersion(), () => latestVersion());
3858
4188
  if (!update) {
3859
4189
  return false;
3860
4190
  }
3861
4191
  print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
3862
4192
  try {
3863
- process5.exitCode = await relaunch(process5.argv.slice(2), process5.env);
4193
+ process7.exitCode = await relaunch(process7.argv.slice(2), process7.env);
3864
4194
  return true;
3865
4195
  } catch {
3866
4196
  print("Could not start the new version. Going on with this one.");
@@ -3878,14 +4208,14 @@ async function runStart(parallel, installsBrowser) {
3878
4208
  }
3879
4209
  const api = createApi(saved.server, saved.token);
3880
4210
  const identity = await api.request("GET", IDENTITY_PATH);
3881
- const problem = teamKeyProblem(identity.device.team, process5.env);
3882
- if (problem) {
3883
- throw new Error(problem);
4211
+ const problem2 = teamKeyProblem(identity.device.team, process7.env);
4212
+ if (problem2) {
4213
+ throw new Error(problem2);
3884
4214
  }
3885
4215
  print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
3886
4216
  const home = homedir3();
3887
4217
  const browser = await browserForStart(home, installsBrowser);
3888
- await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
4218
+ await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder(), inspectFolder: (path) => inspectFolder(path, home) }, stopOnInterrupt(), parallel);
3889
4219
  }
3890
4220
  async function runBrowser(remove) {
3891
4221
  const home = homedir3();
@@ -3937,10 +4267,10 @@ async function run(command) {
3937
4267
  }
3938
4268
  }
3939
4269
  try {
3940
- await run(parseCommand(process5.argv.slice(2), process5.env));
4270
+ await run(parseCommand(process7.argv.slice(2), process7.env));
3941
4271
  } catch (error) {
3942
- process5.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
4272
+ process7.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
3943
4273
  `);
3944
- process5.exitCode = 1;
4274
+ process7.exitCode = 1;
3945
4275
  }
3946
4276
  //# sourceMappingURL=cli.js.map