terminalhire 0.26.2 → 0.28.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.
@@ -8882,6 +8882,10 @@ ${p.body ?? ""}`)) {
8882
8882
  }
8883
8883
  return matched;
8884
8884
  }
8885
+ function selectCompetingPrs(prs, selfLogin) {
8886
+ if (!Array.isArray(prs)) return [];
8887
+ return prs.filter((pr) => pr && pr.author !== selfLogin);
8888
+ }
8885
8889
  async function listOpenPRsReferencingIssue(repoFullName, issueNumber) {
8886
8890
  try {
8887
8891
  const res = await fetch(`${GH_API2}/repos/${repoFullName}/pulls?state=open&per_page=100`, {
@@ -8947,6 +8951,67 @@ async function fetchIssue(repoFullName, issueNumber) {
8947
8951
  return null;
8948
8952
  }
8949
8953
  }
8954
+ var ASSIGNMENT_MARKER = "<!-- terminalhire:assignment-request -->";
8955
+ var TAKE_BOT_REPOS = /* @__PURE__ */ new Set(["paradedb/paradedb"]);
8956
+ function buildAssignmentComment(repoFullName) {
8957
+ const usesTakeBot = TAKE_BOT_REPOS.has(repoFullName.toLowerCase());
8958
+ const body = usesTakeBot ? "/take" : `I'd like to work on this \u2014 could I be assigned? Thanks!
8959
+
8960
+ ${ASSIGNMENT_MARKER}`;
8961
+ return { usesTakeBot, body };
8962
+ }
8963
+ async function hasPriorAssignmentRequest(repoFullName, issueNumber, login) {
8964
+ try {
8965
+ const res = await fetch(
8966
+ `${GH_API2}/repos/${repoFullName}/issues/${issueNumber}/comments?per_page=100&sort=created&direction=desc`,
8967
+ { headers: GH_HEADERS2, signal: AbortSignal.timeout(1e4) }
8968
+ );
8969
+ if (!res.ok) return false;
8970
+ const comments = await res.json();
8971
+ if (!Array.isArray(comments)) return false;
8972
+ return comments.some(
8973
+ (c) => c && c.user && c.user.login === login && typeof c.body === "string" && c.body.includes(ASSIGNMENT_MARKER)
8974
+ );
8975
+ } catch {
8976
+ return false;
8977
+ }
8978
+ }
8979
+ async function requestIssueAssignment(claim, flags = {}, ghUser) {
8980
+ if (flags["no-assign"]) {
8981
+ console.log(" (--no-assign \u2014 skipping the assignment request; request it manually before working)");
8982
+ return;
8983
+ }
8984
+ const parsed = parseGitHubUrl(claim.issueUrl);
8985
+ if (!parsed) return;
8986
+ const { repoFullName, number } = parsed;
8987
+ let login = ghUser;
8988
+ if (!login) {
8989
+ try {
8990
+ login = await sh("gh", ["api", "user", "-q", ".login"]);
8991
+ } catch {
8992
+ console.log(" (assignment not requested: 'gh' not authenticated \u2014 comment on the issue manually before working)");
8993
+ return;
8994
+ }
8995
+ }
8996
+ const issue = await fetchIssue(repoFullName, number);
8997
+ if (issue && issue.assignees.includes(login)) {
8998
+ console.log(` \u2713 Already assigned to @${login} on ${repoFullName}#${number}.`);
8999
+ return;
9000
+ }
9001
+ const { usesTakeBot, body } = buildAssignmentComment(repoFullName);
9002
+ if (!usesTakeBot && await hasPriorAssignmentRequest(repoFullName, number, login)) {
9003
+ console.log(` \u2713 Assignment already requested on ${repoFullName}#${number}.`);
9004
+ return;
9005
+ }
9006
+ try {
9007
+ await sh("gh", ["issue", "comment", String(number), "--repo", repoFullName, "--body", body]);
9008
+ console.log(
9009
+ usesTakeBot ? ` \u2713 Requested assignment via /take on ${repoFullName}#${number}.` : ` \u2713 Requested assignment on ${repoFullName}#${number}.`
9010
+ );
9011
+ } catch (err) {
9012
+ console.log(` (could not post the assignment request: ${err.stderr || err.message || err} \u2014 do it manually before working)`);
9013
+ }
9014
+ }
8950
9015
  async function pollPR(prUrl) {
8951
9016
  const p = parseGitHubUrl(prUrl);
8952
9017
  if (!p) return null;
@@ -9162,10 +9227,19 @@ terminalhire claim: refusing to record \u2014 read ${b.repoFullName}'s contribut
9162
9227
  console.log(" \u2022 MUST NOT `git push` or `gh pr` \u2014 pushing happens only via `terminalhire submit`");
9163
9228
  console.log(" \u2022 clone + static analysis + patch only; NO test/build execution without explicit approval");
9164
9229
  console.log(" \u2022 no access to ~/.terminalhire (the executor never needs your profile)");
9165
- console.log("\n Next:");
9166
- console.log(" 1. record the worktree: terminalhire claim attach " + claim.id + " --worktree <path> --branch <branch>");
9167
- console.log(" 2. do the work + review, then mark it cleared: terminalhire claim update " + claim.id + " ready");
9168
- console.log(" 3. publish (pushes to your fork + opens the PR): terminalhire claim submit " + claim.id);
9230
+ console.log("\n Next \u2014 start work (forks + clones into an isolated worktree; your terminal stays put):");
9231
+ console.log(" terminalhire claim start " + claim.id);
9232
+ console.log(" Then publish when it is done (the only step that pushes + opens the PR):");
9233
+ console.log(" terminalhire claim submit " + claim.id);
9234
+ if (flags.start) {
9235
+ await cmdStart(claim.id, { start: true });
9236
+ } else if (process.stdin.isTTY && !flags["no-start"]) {
9237
+ const go = await confirm(`
9238
+ Fork ${claim.repoFullName} and start now? (y/N) `);
9239
+ if (go) await cmdStart(claim.id, { start: true });
9240
+ else console.log(`
9241
+ Saved. Start anytime: terminalhire claim start ${claim.id}`);
9242
+ }
9169
9243
  }
9170
9244
  async function cmdPreview(arg, { json } = {}) {
9171
9245
  if (!arg) {
@@ -9362,6 +9436,181 @@ async function cmdAttach(id, worktree, branch) {
9362
9436
  claims.updateClaim(id, { worktreePath: toplevel, branch });
9363
9437
  console.log(`Attached ${id}: worktree=${toplevel} branch=${branch}`);
9364
9438
  }
9439
+ function workDirFor(repoFullName, issueNumber) {
9440
+ const [owner, repo] = String(repoFullName).split("/");
9441
+ const suffix = issueNumber ? `-${issueNumber}` : "";
9442
+ return join7(homedir6(), "terminalhire", "work", `${owner}-${repo}${suffix}`);
9443
+ }
9444
+ function startBranchFor(repoFullName, issueNumber) {
9445
+ const repo = String(repoFullName).split("/")[1] || "claim";
9446
+ return `th/${repo}-${issueNumber || "work"}`;
9447
+ }
9448
+ function explicitForkConsent(flags = {}) {
9449
+ return Boolean(flags.start) || Boolean(flags.fork);
9450
+ }
9451
+ async function ensureForkExists(repoFullName, ghUser) {
9452
+ const repoShort = repoFullName.split("/")[1];
9453
+ const forkFullName = `${ghUser}/${repoShort}`;
9454
+ await sh("gh", ["repo", "fork", repoFullName, "--clone=false"]);
9455
+ let isFork = false;
9456
+ try {
9457
+ isFork = await sh("gh", ["api", `repos/${forkFullName}`, "--jq", ".fork"]) === "true";
9458
+ } catch {
9459
+ isFork = false;
9460
+ }
9461
+ if (!isFork) throw new Error(`fork ${forkFullName} created but could not be verified as a fork`);
9462
+ return forkFullName;
9463
+ }
9464
+ async function cmdStart(id, flags = {}) {
9465
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
9466
+ if (!id) {
9467
+ const startable = claims.listClaims({ active: true }).filter((c) => c.state === "claimed");
9468
+ if (startable.length === 0) {
9469
+ console.log("No claims ready to start. Claim one first: terminalhire claim <ref>");
9470
+ return;
9471
+ }
9472
+ console.log(`
9473
+ ${startable.length} claim${startable.length === 1 ? "" : "s"} ready to start:
9474
+ `);
9475
+ for (const c of startable) {
9476
+ console.log(` ${c.title}`);
9477
+ console.log(` terminalhire claim start ${c.id}`);
9478
+ }
9479
+ console.log("\nRun the command under the one you want to start.");
9480
+ return;
9481
+ }
9482
+ const claim = claims.findClaim(id);
9483
+ if (!claim) {
9484
+ console.error(`terminalhire claim: no claim with id '${id}'.`);
9485
+ process.exit(1);
9486
+ }
9487
+ if (claim.worktreePath) {
9488
+ let stillThere = false;
9489
+ try {
9490
+ stillThere = await sh("git", ["-C", claim.worktreePath, "rev-parse", "--show-toplevel"]) === claim.worktreePath;
9491
+ } catch {
9492
+ stillThere = false;
9493
+ }
9494
+ if (stillThere) {
9495
+ console.log("Already started \u2014 your worktree is ready (your terminal stays put):");
9496
+ console.log(` cd ${claim.worktreePath}`);
9497
+ if (claim.branch) console.log(` branch: ${claim.branch}`);
9498
+ console.log(`
9499
+ When it's done: terminalhire claim submit ${id}`);
9500
+ return;
9501
+ }
9502
+ }
9503
+ if (flags.here) {
9504
+ await cmdStartHere(claims, claim, flags);
9505
+ return;
9506
+ }
9507
+ let ghUser;
9508
+ try {
9509
+ ghUser = await sh("gh", ["api", "user", "-q", ".login"]);
9510
+ } catch {
9511
+ console.error("terminalhire claim: 'gh' CLI not available or not authenticated. Run 'gh auth login'.");
9512
+ process.exit(1);
9513
+ }
9514
+ let consented = explicitForkConsent(flags);
9515
+ if (!consented && process.stdin.isTTY) {
9516
+ consented = await confirm(`
9517
+ Fork ${claim.repoFullName} to @${ghUser} and clone it to start? (y/N) `);
9518
+ }
9519
+ if (!consented) {
9520
+ console.error(
9521
+ `
9522
+ terminalhire claim: not started \u2014 starting forks ${claim.repoFullName} to your GitHub account (a network write).
9523
+ Re-run in a terminal to confirm interactively, or pass --fork to consent non-interactively.`
9524
+ );
9525
+ process.exit(1);
9526
+ }
9527
+ const issueNumber = (parseGitHubUrl(claim.issueUrl) || {}).number;
9528
+ const destDir = workDirFor(claim.repoFullName, issueNumber);
9529
+ if (existsSync6(destDir)) {
9530
+ console.error(
9531
+ `terminalhire claim: ${destDir} already exists \u2014 refusing to clobber it.
9532
+ Remove it and retry, or attach it: terminalhire claim attach ${id} --worktree ${destDir} --branch <branch>`
9533
+ );
9534
+ process.exit(1);
9535
+ }
9536
+ mkdirSync6(join7(homedir6(), "terminalhire", "work"), { recursive: true });
9537
+ let forkFullName;
9538
+ try {
9539
+ forkFullName = await ensureForkExists(claim.repoFullName, ghUser);
9540
+ } catch (err) {
9541
+ console.error(`terminalhire claim: could not create your fork of ${claim.repoFullName}. ${err.message ?? err}`);
9542
+ process.exit(1);
9543
+ }
9544
+ try {
9545
+ await sh("gh", ["repo", "clone", forkFullName, destDir]);
9546
+ } catch (err) {
9547
+ try {
9548
+ rmSync3(destDir, { recursive: true, force: true });
9549
+ } catch {
9550
+ }
9551
+ console.error(`terminalhire claim: clone of ${forkFullName} failed. ${err.stderr || err.message || err}`);
9552
+ process.exit(1);
9553
+ }
9554
+ try {
9555
+ await sh("git", ["-C", destDir, "remote", "get-url", "upstream"]);
9556
+ } catch {
9557
+ try {
9558
+ await sh("git", ["-C", destDir, "remote", "add", "upstream", `https://github.com/${claim.repoFullName}.git`]);
9559
+ } catch {
9560
+ }
9561
+ }
9562
+ const branch = startBranchFor(claim.repoFullName, issueNumber);
9563
+ await sh("git", ["-C", destDir, "checkout", "-b", branch]);
9564
+ const toplevel = await sh("git", ["-C", destDir, "rev-parse", "--show-toplevel"]);
9565
+ claims.updateClaim(id, { worktreePath: toplevel, branch, state: "working" });
9566
+ await requestIssueAssignment(claim, flags, ghUser);
9567
+ console.log(`
9568
+ \u2713 Started: ${claim.title}`);
9569
+ console.log(` fork: ${forkFullName}`);
9570
+ console.log(` branch: ${branch}`);
9571
+ console.log("\n Your isolated worktree is ready \u2014 your current terminal is untouched:");
9572
+ console.log(` cd ${toplevel}`);
9573
+ console.log("\n When the work is done (the only step that pushes + opens the PR):");
9574
+ console.log(` terminalhire claim submit ${id}`);
9575
+ }
9576
+ async function cmdStartHere(claims, claim, flags = {}) {
9577
+ let toplevel;
9578
+ try {
9579
+ toplevel = await sh("git", ["-C", process.cwd(), "rev-parse", "--show-toplevel"]);
9580
+ } catch {
9581
+ console.error("terminalhire claim: --here must be run inside a git repository.");
9582
+ process.exit(1);
9583
+ }
9584
+ const remotesOut = await sh("git", ["-C", toplevel, "remote", "-v"]).catch(() => "");
9585
+ const repos = new Set(
9586
+ remotesOut.split("\n").map((l) => parseRepoFromRemote(l.split(/\s+/)[1])).filter(Boolean).map((r) => r.toLowerCase())
9587
+ );
9588
+ if (!repos.has(claim.repoFullName.toLowerCase())) {
9589
+ console.error(
9590
+ `terminalhire claim: --here expects a clone of ${claim.repoFullName}, but no remote here points there.
9591
+ Use \`terminalhire claim start ${claim.id}\` (no --here) to fork + clone it into a fresh worktree.`
9592
+ );
9593
+ process.exit(1);
9594
+ }
9595
+ const dirty = await sh("git", ["-C", toplevel, "status", "--porcelain", "--untracked-files=no"]).catch(() => "");
9596
+ if (dirty) {
9597
+ console.error(
9598
+ "terminalhire claim: --here needs a clean working tree (claim work must not mix with your current changes).\n Commit or stash first, or use `terminalhire claim start` for an isolated worktree."
9599
+ );
9600
+ process.exit(1);
9601
+ }
9602
+ const issueNumber = (parseGitHubUrl(claim.issueUrl) || {}).number;
9603
+ const branch = startBranchFor(claim.repoFullName, issueNumber);
9604
+ await sh("git", ["-C", toplevel, "checkout", "-b", branch]);
9605
+ claims.updateClaim(claim.id, { worktreePath: toplevel, branch, state: "working" });
9606
+ await requestIssueAssignment(claim, flags);
9607
+ console.log(`
9608
+ \u2713 Started here: ${claim.title}`);
9609
+ console.log(` worktree: ${toplevel}`);
9610
+ console.log(` branch: ${branch}`);
9611
+ console.log(`
9612
+ When the work is done: terminalhire claim submit ${claim.id}`);
9613
+ }
9365
9614
  async function cmdSubmit(id, flags = {}) {
9366
9615
  const worktreeOverride = flags.worktree;
9367
9616
  const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -9376,10 +9625,10 @@ async function cmdSubmit(id, flags = {}) {
9376
9625
  console.error(`terminalhire claim: no claim with id '${id}'.`);
9377
9626
  process.exit(1);
9378
9627
  }
9379
- if (claim.state !== "ready") {
9628
+ if (claim.state !== "working" && claim.state !== "ready") {
9380
9629
  console.error(
9381
- `terminalhire claim: ${id} is '${claim.state}', not 'ready'. Submit only runs after the review gate clears it:
9382
- terminalhire claim update ${id} ready`
9630
+ `terminalhire claim: ${id} is '${claim.state}'. Submit runs once work has started ('working', via 'claim start') or the review gate cleared it ('ready'). Start it first:
9631
+ terminalhire claim start ${id}`
9383
9632
  );
9384
9633
  process.exit(1);
9385
9634
  }
@@ -9586,10 +9835,22 @@ async function cmdSubmit(id, flags = {}) {
9586
9835
  const issueNo = (parseGitHubUrl(claim.issueUrl) || {}).number;
9587
9836
  const contention = issueNo ? await listOpenPRsReferencingIssue(claim.repoFullName, issueNo) : null;
9588
9837
  if (contention && contention.total > 0) {
9589
- console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
9590
9838
  const nowMs = Date.now();
9839
+ console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
9591
9840
  for (const pr of contention.prs) console.log(fmtContentionPr(pr, nowMs, false));
9592
9841
  console.log(CONTENTION_HINT);
9842
+ const competing = selectCompetingPrs(contention.prs, ghUser);
9843
+ if (competing.length > 0 && !flags["force-competing"]) {
9844
+ console.error(
9845
+ `
9846
+ terminalhire claim: refusing to submit \u2014 ${competing.length} open PR(s) by someone else already address ${claim.repoFullName}#${issueNo}:`
9847
+ );
9848
+ for (const pr of competing) console.error(fmtContentionPr(pr, nowMs, false));
9849
+ console.error(
9850
+ "\n Opening a competing PR reads as low-effort duplication and burns maintainer goodwill.\n Stand down, or add value on the existing PR instead (a review, a test, a comment).\n If you are certain yours should still go up, re-run with --force-competing."
9851
+ );
9852
+ process.exit(1);
9853
+ }
9593
9854
  }
9594
9855
  }
9595
9856
  let ok;
@@ -9971,6 +10232,9 @@ async function run() {
9971
10232
  case "record":
9972
10233
  await cmdRecord(positional[0], flags);
9973
10234
  break;
10235
+ case "start":
10236
+ await cmdStart(positional[0], flags);
10237
+ break;
9974
10238
  case "list":
9975
10239
  await cmdList(active);
9976
10240
  break;
@@ -9990,7 +10254,7 @@ async function run() {
9990
10254
  await cmdRelease(positional[0]);
9991
10255
  break;
9992
10256
  default:
9993
- console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: preview | record | attach | list | status | update | submit | release`);
10257
+ console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: preview | record | start | attach | list | status | update | submit | release`);
9994
10258
  process.exit(1);
9995
10259
  }
9996
10260
  } catch (err) {
@@ -10002,10 +10266,12 @@ export {
10002
10266
  AI_DISCLOSURE_NOTE,
10003
10267
  CLAIM_CONSENT_VERSION,
10004
10268
  backgroundEnableFailed,
10269
+ buildAssignmentComment,
10005
10270
  buildSubmitBody,
10006
10271
  cmdRecord,
10007
10272
  countOpenPRsReferencingIssue,
10008
10273
  diffContention,
10274
+ explicitForkConsent,
10009
10275
  findClaimableByShortRef,
10010
10276
  findClaimableInCache,
10011
10277
  fmtAge,
@@ -10021,7 +10287,10 @@ export {
10021
10287
  resolveSubmitWorktree,
10022
10288
  revokeFailureAction,
10023
10289
  run,
10024
- selectPushRemote
10290
+ selectCompetingPrs,
10291
+ selectPushRemote,
10292
+ startBranchFor,
10293
+ workDirFor
10025
10294
  };
10026
10295
  /*! Bundled license information:
10027
10296
 
@@ -11035,10 +11035,12 @@ __export(jpi_claim_exports, {
11035
11035
  AI_DISCLOSURE_NOTE: () => AI_DISCLOSURE_NOTE,
11036
11036
  CLAIM_CONSENT_VERSION: () => CLAIM_CONSENT_VERSION,
11037
11037
  backgroundEnableFailed: () => backgroundEnableFailed,
11038
+ buildAssignmentComment: () => buildAssignmentComment,
11038
11039
  buildSubmitBody: () => buildSubmitBody,
11039
11040
  cmdRecord: () => cmdRecord,
11040
11041
  countOpenPRsReferencingIssue: () => countOpenPRsReferencingIssue,
11041
11042
  diffContention: () => diffContention,
11043
+ explicitForkConsent: () => explicitForkConsent,
11042
11044
  findClaimableByShortRef: () => findClaimableByShortRef,
11043
11045
  findClaimableInCache: () => findClaimableInCache,
11044
11046
  fmtAge: () => fmtAge,
@@ -11054,7 +11056,10 @@ __export(jpi_claim_exports, {
11054
11056
  resolveSubmitWorktree: () => resolveSubmitWorktree,
11055
11057
  revokeFailureAction: () => revokeFailureAction,
11056
11058
  run: () => run7,
11057
- selectPushRemote: () => selectPushRemote
11059
+ selectCompetingPrs: () => selectCompetingPrs,
11060
+ selectPushRemote: () => selectPushRemote,
11061
+ startBranchFor: () => startBranchFor,
11062
+ workDirFor: () => workDirFor
11058
11063
  });
11059
11064
  import { readFileSync as readFileSync17, writeFileSync as writeFileSync11, mkdirSync as mkdirSync11, existsSync as existsSync10, rmSync as rmSync4 } from "fs";
11060
11065
  import { join as join17 } from "path";
@@ -11285,6 +11290,10 @@ ${p.body ?? ""}`)) {
11285
11290
  }
11286
11291
  return matched;
11287
11292
  }
11293
+ function selectCompetingPrs(prs, selfLogin) {
11294
+ if (!Array.isArray(prs)) return [];
11295
+ return prs.filter((pr) => pr && pr.author !== selfLogin);
11296
+ }
11288
11297
  async function listOpenPRsReferencingIssue(repoFullName, issueNumber) {
11289
11298
  try {
11290
11299
  const res = await fetch(`${GH_API2}/repos/${repoFullName}/pulls?state=open&per_page=100`, {
@@ -11350,6 +11359,65 @@ async function fetchIssue(repoFullName, issueNumber) {
11350
11359
  return null;
11351
11360
  }
11352
11361
  }
11362
+ function buildAssignmentComment(repoFullName) {
11363
+ const usesTakeBot = TAKE_BOT_REPOS.has(repoFullName.toLowerCase());
11364
+ const body = usesTakeBot ? "/take" : `I'd like to work on this \u2014 could I be assigned? Thanks!
11365
+
11366
+ ${ASSIGNMENT_MARKER}`;
11367
+ return { usesTakeBot, body };
11368
+ }
11369
+ async function hasPriorAssignmentRequest(repoFullName, issueNumber, login) {
11370
+ try {
11371
+ const res = await fetch(
11372
+ `${GH_API2}/repos/${repoFullName}/issues/${issueNumber}/comments?per_page=100&sort=created&direction=desc`,
11373
+ { headers: GH_HEADERS2, signal: AbortSignal.timeout(1e4) }
11374
+ );
11375
+ if (!res.ok) return false;
11376
+ const comments = await res.json();
11377
+ if (!Array.isArray(comments)) return false;
11378
+ return comments.some(
11379
+ (c) => c && c.user && c.user.login === login && typeof c.body === "string" && c.body.includes(ASSIGNMENT_MARKER)
11380
+ );
11381
+ } catch {
11382
+ return false;
11383
+ }
11384
+ }
11385
+ async function requestIssueAssignment(claim, flags = {}, ghUser) {
11386
+ if (flags["no-assign"]) {
11387
+ console.log(" (--no-assign \u2014 skipping the assignment request; request it manually before working)");
11388
+ return;
11389
+ }
11390
+ const parsed = parseGitHubUrl(claim.issueUrl);
11391
+ if (!parsed) return;
11392
+ const { repoFullName, number: number3 } = parsed;
11393
+ let login = ghUser;
11394
+ if (!login) {
11395
+ try {
11396
+ login = await sh("gh", ["api", "user", "-q", ".login"]);
11397
+ } catch {
11398
+ console.log(" (assignment not requested: 'gh' not authenticated \u2014 comment on the issue manually before working)");
11399
+ return;
11400
+ }
11401
+ }
11402
+ const issue2 = await fetchIssue(repoFullName, number3);
11403
+ if (issue2 && issue2.assignees.includes(login)) {
11404
+ console.log(` \u2713 Already assigned to @${login} on ${repoFullName}#${number3}.`);
11405
+ return;
11406
+ }
11407
+ const { usesTakeBot, body } = buildAssignmentComment(repoFullName);
11408
+ if (!usesTakeBot && await hasPriorAssignmentRequest(repoFullName, number3, login)) {
11409
+ console.log(` \u2713 Assignment already requested on ${repoFullName}#${number3}.`);
11410
+ return;
11411
+ }
11412
+ try {
11413
+ await sh("gh", ["issue", "comment", String(number3), "--repo", repoFullName, "--body", body]);
11414
+ console.log(
11415
+ usesTakeBot ? ` \u2713 Requested assignment via /take on ${repoFullName}#${number3}.` : ` \u2713 Requested assignment on ${repoFullName}#${number3}.`
11416
+ );
11417
+ } catch (err) {
11418
+ console.log(` (could not post the assignment request: ${err.stderr || err.message || err} \u2014 do it manually before working)`);
11419
+ }
11420
+ }
11353
11421
  async function pollPR(prUrl) {
11354
11422
  const p = parseGitHubUrl(prUrl);
11355
11423
  if (!p) return null;
@@ -11565,10 +11633,19 @@ terminalhire claim: refusing to record \u2014 read ${b.repoFullName}'s contribut
11565
11633
  console.log(" \u2022 MUST NOT `git push` or `gh pr` \u2014 pushing happens only via `terminalhire submit`");
11566
11634
  console.log(" \u2022 clone + static analysis + patch only; NO test/build execution without explicit approval");
11567
11635
  console.log(" \u2022 no access to ~/.terminalhire (the executor never needs your profile)");
11568
- console.log("\n Next:");
11569
- console.log(" 1. record the worktree: terminalhire claim attach " + claim.id + " --worktree <path> --branch <branch>");
11570
- console.log(" 2. do the work + review, then mark it cleared: terminalhire claim update " + claim.id + " ready");
11571
- console.log(" 3. publish (pushes to your fork + opens the PR): terminalhire claim submit " + claim.id);
11636
+ console.log("\n Next \u2014 start work (forks + clones into an isolated worktree; your terminal stays put):");
11637
+ console.log(" terminalhire claim start " + claim.id);
11638
+ console.log(" Then publish when it is done (the only step that pushes + opens the PR):");
11639
+ console.log(" terminalhire claim submit " + claim.id);
11640
+ if (flags.start) {
11641
+ await cmdStart(claim.id, { start: true });
11642
+ } else if (process.stdin.isTTY && !flags["no-start"]) {
11643
+ const go = await confirm(`
11644
+ Fork ${claim.repoFullName} and start now? (y/N) `);
11645
+ if (go) await cmdStart(claim.id, { start: true });
11646
+ else console.log(`
11647
+ Saved. Start anytime: terminalhire claim start ${claim.id}`);
11648
+ }
11572
11649
  }
11573
11650
  async function cmdPreview(arg, { json } = {}) {
11574
11651
  if (!arg) {
@@ -11765,6 +11842,181 @@ async function cmdAttach(id, worktree, branch) {
11765
11842
  claims.updateClaim(id, { worktreePath: toplevel, branch });
11766
11843
  console.log(`Attached ${id}: worktree=${toplevel} branch=${branch}`);
11767
11844
  }
11845
+ function workDirFor(repoFullName, issueNumber) {
11846
+ const [owner, repo] = String(repoFullName).split("/");
11847
+ const suffix = issueNumber ? `-${issueNumber}` : "";
11848
+ return join17(homedir15(), "terminalhire", "work", `${owner}-${repo}${suffix}`);
11849
+ }
11850
+ function startBranchFor(repoFullName, issueNumber) {
11851
+ const repo = String(repoFullName).split("/")[1] || "claim";
11852
+ return `th/${repo}-${issueNumber || "work"}`;
11853
+ }
11854
+ function explicitForkConsent(flags = {}) {
11855
+ return Boolean(flags.start) || Boolean(flags.fork);
11856
+ }
11857
+ async function ensureForkExists(repoFullName, ghUser) {
11858
+ const repoShort = repoFullName.split("/")[1];
11859
+ const forkFullName = `${ghUser}/${repoShort}`;
11860
+ await sh("gh", ["repo", "fork", repoFullName, "--clone=false"]);
11861
+ let isFork = false;
11862
+ try {
11863
+ isFork = await sh("gh", ["api", `repos/${forkFullName}`, "--jq", ".fork"]) === "true";
11864
+ } catch {
11865
+ isFork = false;
11866
+ }
11867
+ if (!isFork) throw new Error(`fork ${forkFullName} created but could not be verified as a fork`);
11868
+ return forkFullName;
11869
+ }
11870
+ async function cmdStart(id, flags = {}) {
11871
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
11872
+ if (!id) {
11873
+ const startable = claims.listClaims({ active: true }).filter((c) => c.state === "claimed");
11874
+ if (startable.length === 0) {
11875
+ console.log("No claims ready to start. Claim one first: terminalhire claim <ref>");
11876
+ return;
11877
+ }
11878
+ console.log(`
11879
+ ${startable.length} claim${startable.length === 1 ? "" : "s"} ready to start:
11880
+ `);
11881
+ for (const c of startable) {
11882
+ console.log(` ${c.title}`);
11883
+ console.log(` terminalhire claim start ${c.id}`);
11884
+ }
11885
+ console.log("\nRun the command under the one you want to start.");
11886
+ return;
11887
+ }
11888
+ const claim = claims.findClaim(id);
11889
+ if (!claim) {
11890
+ console.error(`terminalhire claim: no claim with id '${id}'.`);
11891
+ process.exit(1);
11892
+ }
11893
+ if (claim.worktreePath) {
11894
+ let stillThere = false;
11895
+ try {
11896
+ stillThere = await sh("git", ["-C", claim.worktreePath, "rev-parse", "--show-toplevel"]) === claim.worktreePath;
11897
+ } catch {
11898
+ stillThere = false;
11899
+ }
11900
+ if (stillThere) {
11901
+ console.log("Already started \u2014 your worktree is ready (your terminal stays put):");
11902
+ console.log(` cd ${claim.worktreePath}`);
11903
+ if (claim.branch) console.log(` branch: ${claim.branch}`);
11904
+ console.log(`
11905
+ When it's done: terminalhire claim submit ${id}`);
11906
+ return;
11907
+ }
11908
+ }
11909
+ if (flags.here) {
11910
+ await cmdStartHere(claims, claim, flags);
11911
+ return;
11912
+ }
11913
+ let ghUser;
11914
+ try {
11915
+ ghUser = await sh("gh", ["api", "user", "-q", ".login"]);
11916
+ } catch {
11917
+ console.error("terminalhire claim: 'gh' CLI not available or not authenticated. Run 'gh auth login'.");
11918
+ process.exit(1);
11919
+ }
11920
+ let consented = explicitForkConsent(flags);
11921
+ if (!consented && process.stdin.isTTY) {
11922
+ consented = await confirm(`
11923
+ Fork ${claim.repoFullName} to @${ghUser} and clone it to start? (y/N) `);
11924
+ }
11925
+ if (!consented) {
11926
+ console.error(
11927
+ `
11928
+ terminalhire claim: not started \u2014 starting forks ${claim.repoFullName} to your GitHub account (a network write).
11929
+ Re-run in a terminal to confirm interactively, or pass --fork to consent non-interactively.`
11930
+ );
11931
+ process.exit(1);
11932
+ }
11933
+ const issueNumber = (parseGitHubUrl(claim.issueUrl) || {}).number;
11934
+ const destDir = workDirFor(claim.repoFullName, issueNumber);
11935
+ if (existsSync10(destDir)) {
11936
+ console.error(
11937
+ `terminalhire claim: ${destDir} already exists \u2014 refusing to clobber it.
11938
+ Remove it and retry, or attach it: terminalhire claim attach ${id} --worktree ${destDir} --branch <branch>`
11939
+ );
11940
+ process.exit(1);
11941
+ }
11942
+ mkdirSync11(join17(homedir15(), "terminalhire", "work"), { recursive: true });
11943
+ let forkFullName;
11944
+ try {
11945
+ forkFullName = await ensureForkExists(claim.repoFullName, ghUser);
11946
+ } catch (err) {
11947
+ console.error(`terminalhire claim: could not create your fork of ${claim.repoFullName}. ${err.message ?? err}`);
11948
+ process.exit(1);
11949
+ }
11950
+ try {
11951
+ await sh("gh", ["repo", "clone", forkFullName, destDir]);
11952
+ } catch (err) {
11953
+ try {
11954
+ rmSync4(destDir, { recursive: true, force: true });
11955
+ } catch {
11956
+ }
11957
+ console.error(`terminalhire claim: clone of ${forkFullName} failed. ${err.stderr || err.message || err}`);
11958
+ process.exit(1);
11959
+ }
11960
+ try {
11961
+ await sh("git", ["-C", destDir, "remote", "get-url", "upstream"]);
11962
+ } catch {
11963
+ try {
11964
+ await sh("git", ["-C", destDir, "remote", "add", "upstream", `https://github.com/${claim.repoFullName}.git`]);
11965
+ } catch {
11966
+ }
11967
+ }
11968
+ const branch = startBranchFor(claim.repoFullName, issueNumber);
11969
+ await sh("git", ["-C", destDir, "checkout", "-b", branch]);
11970
+ const toplevel = await sh("git", ["-C", destDir, "rev-parse", "--show-toplevel"]);
11971
+ claims.updateClaim(id, { worktreePath: toplevel, branch, state: "working" });
11972
+ await requestIssueAssignment(claim, flags, ghUser);
11973
+ console.log(`
11974
+ \u2713 Started: ${claim.title}`);
11975
+ console.log(` fork: ${forkFullName}`);
11976
+ console.log(` branch: ${branch}`);
11977
+ console.log("\n Your isolated worktree is ready \u2014 your current terminal is untouched:");
11978
+ console.log(` cd ${toplevel}`);
11979
+ console.log("\n When the work is done (the only step that pushes + opens the PR):");
11980
+ console.log(` terminalhire claim submit ${id}`);
11981
+ }
11982
+ async function cmdStartHere(claims, claim, flags = {}) {
11983
+ let toplevel;
11984
+ try {
11985
+ toplevel = await sh("git", ["-C", process.cwd(), "rev-parse", "--show-toplevel"]);
11986
+ } catch {
11987
+ console.error("terminalhire claim: --here must be run inside a git repository.");
11988
+ process.exit(1);
11989
+ }
11990
+ const remotesOut = await sh("git", ["-C", toplevel, "remote", "-v"]).catch(() => "");
11991
+ const repos = new Set(
11992
+ remotesOut.split("\n").map((l) => parseRepoFromRemote(l.split(/\s+/)[1])).filter(Boolean).map((r) => r.toLowerCase())
11993
+ );
11994
+ if (!repos.has(claim.repoFullName.toLowerCase())) {
11995
+ console.error(
11996
+ `terminalhire claim: --here expects a clone of ${claim.repoFullName}, but no remote here points there.
11997
+ Use \`terminalhire claim start ${claim.id}\` (no --here) to fork + clone it into a fresh worktree.`
11998
+ );
11999
+ process.exit(1);
12000
+ }
12001
+ const dirty = await sh("git", ["-C", toplevel, "status", "--porcelain", "--untracked-files=no"]).catch(() => "");
12002
+ if (dirty) {
12003
+ console.error(
12004
+ "terminalhire claim: --here needs a clean working tree (claim work must not mix with your current changes).\n Commit or stash first, or use `terminalhire claim start` for an isolated worktree."
12005
+ );
12006
+ process.exit(1);
12007
+ }
12008
+ const issueNumber = (parseGitHubUrl(claim.issueUrl) || {}).number;
12009
+ const branch = startBranchFor(claim.repoFullName, issueNumber);
12010
+ await sh("git", ["-C", toplevel, "checkout", "-b", branch]);
12011
+ claims.updateClaim(claim.id, { worktreePath: toplevel, branch, state: "working" });
12012
+ await requestIssueAssignment(claim, flags);
12013
+ console.log(`
12014
+ \u2713 Started here: ${claim.title}`);
12015
+ console.log(` worktree: ${toplevel}`);
12016
+ console.log(` branch: ${branch}`);
12017
+ console.log(`
12018
+ When the work is done: terminalhire claim submit ${claim.id}`);
12019
+ }
11768
12020
  async function cmdSubmit(id, flags = {}) {
11769
12021
  const worktreeOverride = flags.worktree;
11770
12022
  const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -11779,10 +12031,10 @@ async function cmdSubmit(id, flags = {}) {
11779
12031
  console.error(`terminalhire claim: no claim with id '${id}'.`);
11780
12032
  process.exit(1);
11781
12033
  }
11782
- if (claim.state !== "ready") {
12034
+ if (claim.state !== "working" && claim.state !== "ready") {
11783
12035
  console.error(
11784
- `terminalhire claim: ${id} is '${claim.state}', not 'ready'. Submit only runs after the review gate clears it:
11785
- terminalhire claim update ${id} ready`
12036
+ `terminalhire claim: ${id} is '${claim.state}'. Submit runs once work has started ('working', via 'claim start') or the review gate cleared it ('ready'). Start it first:
12037
+ terminalhire claim start ${id}`
11786
12038
  );
11787
12039
  process.exit(1);
11788
12040
  }
@@ -11989,10 +12241,22 @@ async function cmdSubmit(id, flags = {}) {
11989
12241
  const issueNo = (parseGitHubUrl(claim.issueUrl) || {}).number;
11990
12242
  const contention = issueNo ? await listOpenPRsReferencingIssue(claim.repoFullName, issueNo) : null;
11991
12243
  if (contention && contention.total > 0) {
11992
- console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
11993
12244
  const nowMs = Date.now();
12245
+ console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
11994
12246
  for (const pr of contention.prs) console.log(fmtContentionPr(pr, nowMs, false));
11995
12247
  console.log(CONTENTION_HINT);
12248
+ const competing = selectCompetingPrs(contention.prs, ghUser);
12249
+ if (competing.length > 0 && !flags["force-competing"]) {
12250
+ console.error(
12251
+ `
12252
+ terminalhire claim: refusing to submit \u2014 ${competing.length} open PR(s) by someone else already address ${claim.repoFullName}#${issueNo}:`
12253
+ );
12254
+ for (const pr of competing) console.error(fmtContentionPr(pr, nowMs, false));
12255
+ console.error(
12256
+ "\n Opening a competing PR reads as low-effort duplication and burns maintainer goodwill.\n Stand down, or add value on the existing PR instead (a review, a test, a comment).\n If you are certain yours should still go up, re-run with --force-competing."
12257
+ );
12258
+ process.exit(1);
12259
+ }
11996
12260
  }
11997
12261
  }
11998
12262
  let ok;
@@ -12374,6 +12638,9 @@ async function run7() {
12374
12638
  case "record":
12375
12639
  await cmdRecord(positional[0], flags);
12376
12640
  break;
12641
+ case "start":
12642
+ await cmdStart(positional[0], flags);
12643
+ break;
12377
12644
  case "list":
12378
12645
  await cmdList(active);
12379
12646
  break;
@@ -12393,7 +12660,7 @@ async function run7() {
12393
12660
  await cmdRelease(positional[0]);
12394
12661
  break;
12395
12662
  default:
12396
- console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: preview | record | attach | list | status | update | submit | release`);
12663
+ console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: preview | record | start | attach | list | status | update | submit | release`);
12397
12664
  process.exit(1);
12398
12665
  }
12399
12666
  } catch (err) {
@@ -12401,7 +12668,7 @@ async function run7() {
12401
12668
  process.exit(1);
12402
12669
  }
12403
12670
  }
12404
- var TERMINALHIRE_DIR13, INDEX_CACHE_FILE5, CLAIM_PUSH_MARKER, API_URL6, CLAIM_SYNC_BASE2, CLAIM_CONSENT_VERSION, CLAIM_POLL_INTERVAL_MS, CLAIM_POLL_TIMEOUT_MS, GH_API2, GH_HEADERS2, CONTENTION_HINT, AI_DISCLOSURE_NOTE, pExecFile, VALUE_FLAGS;
12671
+ var TERMINALHIRE_DIR13, INDEX_CACHE_FILE5, CLAIM_PUSH_MARKER, API_URL6, CLAIM_SYNC_BASE2, CLAIM_CONSENT_VERSION, CLAIM_POLL_INTERVAL_MS, CLAIM_POLL_TIMEOUT_MS, GH_API2, GH_HEADERS2, CONTENTION_HINT, AI_DISCLOSURE_NOTE, pExecFile, VALUE_FLAGS, ASSIGNMENT_MARKER, TAKE_BOT_REPOS;
12405
12672
  var init_jpi_claim = __esm({
12406
12673
  "bin/jpi-claim.js"() {
12407
12674
  "use strict";
@@ -12423,6 +12690,8 @@ var init_jpi_claim = __esm({
12423
12690
  AI_DISCLOSURE_NOTE = "---\nThis contribution was developed with AI assistance via [terminalhire](https://terminalhire.com). The author has reviewed the change and takes responsibility for its content.";
12424
12691
  pExecFile = promisify(execFile);
12425
12692
  VALUE_FLAGS = /* @__PURE__ */ new Set(["worktree", "branch", "body-file", "title"]);
12693
+ ASSIGNMENT_MARKER = "<!-- terminalhire:assignment-request -->";
12694
+ TAKE_BOT_REPOS = /* @__PURE__ */ new Set(["paradedb/paradedb"]);
12426
12695
  }
12427
12696
  });
12428
12697
 
@@ -42869,6 +43138,7 @@ if (!firstArg || firstArg === "help" || firstArg === "--help" || firstArg === "-
42869
43138
  console.log(" terminalhire bounties --priced Only bounties with a known $ amount");
42870
43139
  console.log(" terminalhire contribute Open issues where a merged PR counts toward your r\xE9sum\xE9");
42871
43140
  console.log(" terminalhire claim record <id|issueUrl> Claim a bounty locally + print the executor brief");
43141
+ console.log(" terminalhire claim start [<id>] Fork + clone + branch into an isolated worktree (never cd's you)");
42872
43142
  console.log(" terminalhire claim list [--active] List your claims + accepted-PR rate");
42873
43143
  console.log(" terminalhire claim status [<id>] Poll source PR merge state (updates the metric)");
42874
43144
  console.log(" terminalhire claim --push Opt-in: show your claims on your dashboard (typed-yes + browser confirm)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminalhire",
3
- "version": "0.26.2",
3
+ "version": "0.28.0",
4
4
  "description": "Local-first job matching for developers — ambient job matches in the Claude Code spinner. Matching runs on your machine; your profile never leaves it.",
5
5
  "repository": {
6
6
  "type": "git",