terminalhire 0.27.0 → 0.29.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.
@@ -285,6 +285,7 @@ function decrypt(blob, key) {
285
285
  var TERMINALHIRE_DIR3 = process.env.TERMINALHIRE_DIR || join3(homedir3(), ".terminalhire");
286
286
  var CLAIM_PUSH_AUTO_MARKER = join3(TERMINALHIRE_DIR3, "claim-push-auto.json");
287
287
  var CLAIM_PUSH_TOKEN_FILE = join3(TERMINALHIRE_DIR3, "claim-push-token.enc");
288
+ var CLAIM_PUSH_MANUAL_MARKER = join3(TERMINALHIRE_DIR3, "claim-push.json");
288
289
  var CLAIM_SYNC_BASE = "https://terminalhire.com";
289
290
  var AUTO_CONSENT_VERSION = 2;
290
291
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
@@ -352,6 +353,43 @@ function backgroundPushGate(params) {
352
353
  }
353
354
  return { push: true, reason: "ok" };
354
355
  }
356
+ function unpushedNudgeGate(params) {
357
+ const {
358
+ autoMarkerExists,
359
+ tokenFileExists,
360
+ manualMarkerExists,
361
+ lastSnapshotHash,
362
+ currentHash,
363
+ claimCount
364
+ } = params;
365
+ if (autoMarkerExists && tokenFileExists) return false;
366
+ if (!manualMarkerExists) return false;
367
+ if (!(claimCount > 0)) return false;
368
+ return lastSnapshotHash !== currentHash;
369
+ }
370
+ async function shouldNudgeUnpushed() {
371
+ try {
372
+ const { listClaims: listClaims2, toPushedClaim: toPushedClaim2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
373
+ const pushed = listClaims2().map((c) => toPushedClaim2(c));
374
+ const currentHash = computeSnapshotHash(pushed);
375
+ let manual = null;
376
+ try {
377
+ manual = existsSync3(CLAIM_PUSH_MANUAL_MARKER) ? JSON.parse(readFileSync3(CLAIM_PUSH_MANUAL_MARKER, "utf8")) : null;
378
+ } catch {
379
+ manual = null;
380
+ }
381
+ return unpushedNudgeGate({
382
+ autoMarkerExists: existsSync3(CLAIM_PUSH_AUTO_MARKER),
383
+ tokenFileExists: existsSync3(CLAIM_PUSH_TOKEN_FILE),
384
+ manualMarkerExists: !!manual,
385
+ lastSnapshotHash: manual?.lastSnapshotHash ?? null,
386
+ currentHash,
387
+ claimCount: pushed.length
388
+ });
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
355
393
  async function runBackgroundClaimPush({ now = Date.now() } = {}) {
356
394
  try {
357
395
  if (!existsSync3(CLAIM_PUSH_AUTO_MARKER) || !existsSync3(CLAIM_PUSH_TOKEN_FILE)) return;
@@ -396,6 +434,7 @@ export {
396
434
  AUTO_CONSENT_VERSION,
397
435
  AUTO_PUSH_THROTTLE_MS,
398
436
  CLAIM_PUSH_AUTO_MARKER,
437
+ CLAIM_PUSH_MANUAL_MARKER,
399
438
  CLAIM_PUSH_TOKEN_FILE,
400
439
  backgroundPushGate,
401
440
  clearAutoMarker,
@@ -404,6 +443,8 @@ export {
404
443
  readAutoMarker,
405
444
  readPushTokenEnc,
406
445
  runBackgroundClaimPush,
446
+ shouldNudgeUnpushed,
447
+ unpushedNudgeGate,
407
448
  writeAutoMarker,
408
449
  writePushTokenEnc
409
450
  };
@@ -8616,6 +8616,7 @@ function encrypt(plaintext, key) {
8616
8616
  var TERMINALHIRE_DIR3 = process.env.TERMINALHIRE_DIR || join4(homedir3(), ".terminalhire");
8617
8617
  var CLAIM_PUSH_AUTO_MARKER = join4(TERMINALHIRE_DIR3, "claim-push-auto.json");
8618
8618
  var CLAIM_PUSH_TOKEN_FILE = join4(TERMINALHIRE_DIR3, "claim-push-token.enc");
8619
+ var CLAIM_PUSH_MANUAL_MARKER = join4(TERMINALHIRE_DIR3, "claim-push.json");
8619
8620
  var AUTO_CONSENT_VERSION = 2;
8620
8621
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
8621
8622
  async function writePushTokenEnc(rawToken) {
@@ -8643,6 +8644,43 @@ function clearAutoMarker() {
8643
8644
  function computeSnapshotHash(pushed) {
8644
8645
  return createHash3("sha256").update(JSON.stringify(pushed)).digest("hex");
8645
8646
  }
8647
+ function unpushedNudgeGate(params) {
8648
+ const {
8649
+ autoMarkerExists,
8650
+ tokenFileExists,
8651
+ manualMarkerExists,
8652
+ lastSnapshotHash,
8653
+ currentHash,
8654
+ claimCount
8655
+ } = params;
8656
+ if (autoMarkerExists && tokenFileExists) return false;
8657
+ if (!manualMarkerExists) return false;
8658
+ if (!(claimCount > 0)) return false;
8659
+ return lastSnapshotHash !== currentHash;
8660
+ }
8661
+ async function shouldNudgeUnpushed() {
8662
+ try {
8663
+ const { listClaims: listClaims2, toPushedClaim: toPushedClaim2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
8664
+ const pushed = listClaims2().map((c) => toPushedClaim2(c));
8665
+ const currentHash = computeSnapshotHash(pushed);
8666
+ let manual = null;
8667
+ try {
8668
+ manual = existsSync3(CLAIM_PUSH_MANUAL_MARKER) ? JSON.parse(readFileSync4(CLAIM_PUSH_MANUAL_MARKER, "utf8")) : null;
8669
+ } catch {
8670
+ manual = null;
8671
+ }
8672
+ return unpushedNudgeGate({
8673
+ autoMarkerExists: existsSync3(CLAIM_PUSH_AUTO_MARKER),
8674
+ tokenFileExists: existsSync3(CLAIM_PUSH_TOKEN_FILE),
8675
+ manualMarkerExists: !!manual,
8676
+ lastSnapshotHash: manual?.lastSnapshotHash ?? null,
8677
+ currentHash,
8678
+ claimCount: pushed.length
8679
+ });
8680
+ } catch {
8681
+ return false;
8682
+ }
8683
+ }
8646
8684
 
8647
8685
  // bin/jpi-claim.js
8648
8686
  var TERMINALHIRE_DIR6 = process.env.TERMINALHIRE_DIR || join7(homedir6(), ".terminalhire");
@@ -8882,6 +8920,10 @@ ${p.body ?? ""}`)) {
8882
8920
  }
8883
8921
  return matched;
8884
8922
  }
8923
+ function selectCompetingPrs(prs, selfLogin) {
8924
+ if (!Array.isArray(prs)) return [];
8925
+ return prs.filter((pr) => pr && pr.author !== selfLogin);
8926
+ }
8885
8927
  async function listOpenPRsReferencingIssue(repoFullName, issueNumber) {
8886
8928
  try {
8887
8929
  const res = await fetch(`${GH_API2}/repos/${repoFullName}/pulls?state=open&per_page=100`, {
@@ -8947,6 +8989,67 @@ async function fetchIssue(repoFullName, issueNumber) {
8947
8989
  return null;
8948
8990
  }
8949
8991
  }
8992
+ var ASSIGNMENT_MARKER = "<!-- terminalhire:assignment-request -->";
8993
+ var TAKE_BOT_REPOS = /* @__PURE__ */ new Set(["paradedb/paradedb"]);
8994
+ function buildAssignmentComment(repoFullName) {
8995
+ const usesTakeBot = TAKE_BOT_REPOS.has(repoFullName.toLowerCase());
8996
+ const body = usesTakeBot ? "/take" : `I'd like to work on this \u2014 could I be assigned? Thanks!
8997
+
8998
+ ${ASSIGNMENT_MARKER}`;
8999
+ return { usesTakeBot, body };
9000
+ }
9001
+ async function hasPriorAssignmentRequest(repoFullName, issueNumber, login) {
9002
+ try {
9003
+ const res = await fetch(
9004
+ `${GH_API2}/repos/${repoFullName}/issues/${issueNumber}/comments?per_page=100&sort=created&direction=desc`,
9005
+ { headers: GH_HEADERS2, signal: AbortSignal.timeout(1e4) }
9006
+ );
9007
+ if (!res.ok) return false;
9008
+ const comments = await res.json();
9009
+ if (!Array.isArray(comments)) return false;
9010
+ return comments.some(
9011
+ (c) => c && c.user && c.user.login === login && typeof c.body === "string" && c.body.includes(ASSIGNMENT_MARKER)
9012
+ );
9013
+ } catch {
9014
+ return false;
9015
+ }
9016
+ }
9017
+ async function requestIssueAssignment(claim, flags = {}, ghUser) {
9018
+ if (flags["no-assign"]) {
9019
+ console.log(" (--no-assign \u2014 skipping the assignment request; request it manually before working)");
9020
+ return;
9021
+ }
9022
+ const parsed = parseGitHubUrl(claim.issueUrl);
9023
+ if (!parsed) return;
9024
+ const { repoFullName, number } = parsed;
9025
+ let login = ghUser;
9026
+ if (!login) {
9027
+ try {
9028
+ login = await sh("gh", ["api", "user", "-q", ".login"]);
9029
+ } catch {
9030
+ console.log(" (assignment not requested: 'gh' not authenticated \u2014 comment on the issue manually before working)");
9031
+ return;
9032
+ }
9033
+ }
9034
+ const issue = await fetchIssue(repoFullName, number);
9035
+ if (issue && issue.assignees.includes(login)) {
9036
+ console.log(` \u2713 Already assigned to @${login} on ${repoFullName}#${number}.`);
9037
+ return;
9038
+ }
9039
+ const { usesTakeBot, body } = buildAssignmentComment(repoFullName);
9040
+ if (!usesTakeBot && await hasPriorAssignmentRequest(repoFullName, number, login)) {
9041
+ console.log(` \u2713 Assignment already requested on ${repoFullName}#${number}.`);
9042
+ return;
9043
+ }
9044
+ try {
9045
+ await sh("gh", ["issue", "comment", String(number), "--repo", repoFullName, "--body", body]);
9046
+ console.log(
9047
+ usesTakeBot ? ` \u2713 Requested assignment via /take on ${repoFullName}#${number}.` : ` \u2713 Requested assignment on ${repoFullName}#${number}.`
9048
+ );
9049
+ } catch (err) {
9050
+ console.log(` (could not post the assignment request: ${err.stderr || err.message || err} \u2014 do it manually before working)`);
9051
+ }
9052
+ }
8950
9053
  async function pollPR(prUrl) {
8951
9054
  const p = parseGitHubUrl(prUrl);
8952
9055
  if (!p) return null;
@@ -9237,6 +9340,12 @@ ${list.length} ${active ? "active " : ""}claim${list.length === 1 ? "" : "s"}:
9237
9340
  console.log(` id: ${c.id}${pr}`);
9238
9341
  }
9239
9342
  printMetric(claims.acceptedPRRate());
9343
+ try {
9344
+ if (await shouldNudgeUnpushed()) {
9345
+ console.log("\n \u26A0 new claims not yet on your dashboard \u2014 run: terminalhire claim --push --keep-updated");
9346
+ }
9347
+ } catch {
9348
+ }
9240
9349
  }
9241
9350
  async function cmdStatus(id) {
9242
9351
  const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -9436,7 +9545,7 @@ When it's done: terminalhire claim submit ${id}`);
9436
9545
  }
9437
9546
  }
9438
9547
  if (flags.here) {
9439
- await cmdStartHere(claims, claim);
9548
+ await cmdStartHere(claims, claim, flags);
9440
9549
  return;
9441
9550
  }
9442
9551
  let ghUser;
@@ -9498,6 +9607,7 @@ terminalhire claim: not started \u2014 starting forks ${claim.repoFullName} to y
9498
9607
  await sh("git", ["-C", destDir, "checkout", "-b", branch]);
9499
9608
  const toplevel = await sh("git", ["-C", destDir, "rev-parse", "--show-toplevel"]);
9500
9609
  claims.updateClaim(id, { worktreePath: toplevel, branch, state: "working" });
9610
+ await requestIssueAssignment(claim, flags, ghUser);
9501
9611
  console.log(`
9502
9612
  \u2713 Started: ${claim.title}`);
9503
9613
  console.log(` fork: ${forkFullName}`);
@@ -9507,7 +9617,7 @@ terminalhire claim: not started \u2014 starting forks ${claim.repoFullName} to y
9507
9617
  console.log("\n When the work is done (the only step that pushes + opens the PR):");
9508
9618
  console.log(` terminalhire claim submit ${id}`);
9509
9619
  }
9510
- async function cmdStartHere(claims, claim) {
9620
+ async function cmdStartHere(claims, claim, flags = {}) {
9511
9621
  let toplevel;
9512
9622
  try {
9513
9623
  toplevel = await sh("git", ["-C", process.cwd(), "rev-parse", "--show-toplevel"]);
@@ -9537,6 +9647,7 @@ async function cmdStartHere(claims, claim) {
9537
9647
  const branch = startBranchFor(claim.repoFullName, issueNumber);
9538
9648
  await sh("git", ["-C", toplevel, "checkout", "-b", branch]);
9539
9649
  claims.updateClaim(claim.id, { worktreePath: toplevel, branch, state: "working" });
9650
+ await requestIssueAssignment(claim, flags);
9540
9651
  console.log(`
9541
9652
  \u2713 Started here: ${claim.title}`);
9542
9653
  console.log(` worktree: ${toplevel}`);
@@ -9768,10 +9879,22 @@ async function cmdSubmit(id, flags = {}) {
9768
9879
  const issueNo = (parseGitHubUrl(claim.issueUrl) || {}).number;
9769
9880
  const contention = issueNo ? await listOpenPRsReferencingIssue(claim.repoFullName, issueNo) : null;
9770
9881
  if (contention && contention.total > 0) {
9771
- console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
9772
9882
  const nowMs = Date.now();
9883
+ console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
9773
9884
  for (const pr of contention.prs) console.log(fmtContentionPr(pr, nowMs, false));
9774
9885
  console.log(CONTENTION_HINT);
9886
+ const competing = selectCompetingPrs(contention.prs, ghUser);
9887
+ if (competing.length > 0 && !flags["force-competing"]) {
9888
+ console.error(
9889
+ `
9890
+ terminalhire claim: refusing to submit \u2014 ${competing.length} open PR(s) by someone else already address ${claim.repoFullName}#${issueNo}:`
9891
+ );
9892
+ for (const pr of competing) console.error(fmtContentionPr(pr, nowMs, false));
9893
+ console.error(
9894
+ "\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."
9895
+ );
9896
+ process.exit(1);
9897
+ }
9775
9898
  }
9776
9899
  }
9777
9900
  let ok;
@@ -10039,7 +10162,13 @@ async function cmdPush({ keepUpdated = false } = {}) {
10039
10162
  pushToken = body?.pushToken || null;
10040
10163
  } catch {
10041
10164
  }
10042
- writeClaimPushMarker({ consentedAt, login, deleteToken });
10165
+ writeClaimPushMarker({
10166
+ consentedAt,
10167
+ login,
10168
+ deleteToken,
10169
+ lastPushedAt: consentedAt,
10170
+ lastSnapshotHash: computeSnapshotHash(pushed)
10171
+ });
10043
10172
  if (autoConsent && pushToken) {
10044
10173
  try {
10045
10174
  await writePushTokenEnc(pushToken);
@@ -10187,6 +10316,7 @@ export {
10187
10316
  AI_DISCLOSURE_NOTE,
10188
10317
  CLAIM_CONSENT_VERSION,
10189
10318
  backgroundEnableFailed,
10319
+ buildAssignmentComment,
10190
10320
  buildSubmitBody,
10191
10321
  cmdRecord,
10192
10322
  countOpenPRsReferencingIssue,
@@ -10207,6 +10337,7 @@ export {
10207
10337
  resolveSubmitWorktree,
10208
10338
  revokeFailureAction,
10209
10339
  run,
10340
+ selectCompetingPrs,
10210
10341
  selectPushRemote,
10211
10342
  startBranchFor,
10212
10343
  workDirFor
@@ -10811,6 +10811,7 @@ __export(claim_push_bg_exports, {
10811
10811
  AUTO_CONSENT_VERSION: () => AUTO_CONSENT_VERSION,
10812
10812
  AUTO_PUSH_THROTTLE_MS: () => AUTO_PUSH_THROTTLE_MS,
10813
10813
  CLAIM_PUSH_AUTO_MARKER: () => CLAIM_PUSH_AUTO_MARKER,
10814
+ CLAIM_PUSH_MANUAL_MARKER: () => CLAIM_PUSH_MANUAL_MARKER,
10814
10815
  CLAIM_PUSH_TOKEN_FILE: () => CLAIM_PUSH_TOKEN_FILE,
10815
10816
  backgroundPushGate: () => backgroundPushGate,
10816
10817
  clearAutoMarker: () => clearAutoMarker,
@@ -10819,6 +10820,8 @@ __export(claim_push_bg_exports, {
10819
10820
  readAutoMarker: () => readAutoMarker,
10820
10821
  readPushTokenEnc: () => readPushTokenEnc,
10821
10822
  runBackgroundClaimPush: () => runBackgroundClaimPush,
10823
+ shouldNudgeUnpushed: () => shouldNudgeUnpushed,
10824
+ unpushedNudgeGate: () => unpushedNudgeGate,
10822
10825
  writeAutoMarker: () => writeAutoMarker,
10823
10826
  writePushTokenEnc: () => writePushTokenEnc
10824
10827
  });
@@ -10890,6 +10893,43 @@ function backgroundPushGate(params) {
10890
10893
  }
10891
10894
  return { push: true, reason: "ok" };
10892
10895
  }
10896
+ function unpushedNudgeGate(params) {
10897
+ const {
10898
+ autoMarkerExists,
10899
+ tokenFileExists,
10900
+ manualMarkerExists,
10901
+ lastSnapshotHash,
10902
+ currentHash,
10903
+ claimCount
10904
+ } = params;
10905
+ if (autoMarkerExists && tokenFileExists) return false;
10906
+ if (!manualMarkerExists) return false;
10907
+ if (!(claimCount > 0)) return false;
10908
+ return lastSnapshotHash !== currentHash;
10909
+ }
10910
+ async function shouldNudgeUnpushed() {
10911
+ try {
10912
+ const { listClaims: listClaims2, toPushedClaim: toPushedClaim2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
10913
+ const pushed = listClaims2().map((c) => toPushedClaim2(c));
10914
+ const currentHash = computeSnapshotHash(pushed);
10915
+ let manual = null;
10916
+ try {
10917
+ manual = existsSync9(CLAIM_PUSH_MANUAL_MARKER) ? JSON.parse(readFileSync16(CLAIM_PUSH_MANUAL_MARKER, "utf8")) : null;
10918
+ } catch {
10919
+ manual = null;
10920
+ }
10921
+ return unpushedNudgeGate({
10922
+ autoMarkerExists: existsSync9(CLAIM_PUSH_AUTO_MARKER),
10923
+ tokenFileExists: existsSync9(CLAIM_PUSH_TOKEN_FILE),
10924
+ manualMarkerExists: !!manual,
10925
+ lastSnapshotHash: manual?.lastSnapshotHash ?? null,
10926
+ currentHash,
10927
+ claimCount: pushed.length
10928
+ });
10929
+ } catch {
10930
+ return false;
10931
+ }
10932
+ }
10893
10933
  async function runBackgroundClaimPush({ now = Date.now() } = {}) {
10894
10934
  try {
10895
10935
  if (!existsSync9(CLAIM_PUSH_AUTO_MARKER) || !existsSync9(CLAIM_PUSH_TOKEN_FILE)) return;
@@ -10930,7 +10970,7 @@ async function runBackgroundClaimPush({ now = Date.now() } = {}) {
10930
10970
  } catch {
10931
10971
  }
10932
10972
  }
10933
- var TERMINALHIRE_DIR12, CLAIM_PUSH_AUTO_MARKER, CLAIM_PUSH_TOKEN_FILE, CLAIM_SYNC_BASE, AUTO_CONSENT_VERSION, AUTO_PUSH_THROTTLE_MS;
10973
+ var TERMINALHIRE_DIR12, CLAIM_PUSH_AUTO_MARKER, CLAIM_PUSH_TOKEN_FILE, CLAIM_PUSH_MANUAL_MARKER, CLAIM_SYNC_BASE, AUTO_CONSENT_VERSION, AUTO_PUSH_THROTTLE_MS;
10934
10974
  var init_claim_push_bg = __esm({
10935
10975
  "bin/claim-push-bg.js"() {
10936
10976
  "use strict";
@@ -10938,6 +10978,7 @@ var init_claim_push_bg = __esm({
10938
10978
  TERMINALHIRE_DIR12 = process.env.TERMINALHIRE_DIR || join16(homedir14(), ".terminalhire");
10939
10979
  CLAIM_PUSH_AUTO_MARKER = join16(TERMINALHIRE_DIR12, "claim-push-auto.json");
10940
10980
  CLAIM_PUSH_TOKEN_FILE = join16(TERMINALHIRE_DIR12, "claim-push-token.enc");
10981
+ CLAIM_PUSH_MANUAL_MARKER = join16(TERMINALHIRE_DIR12, "claim-push.json");
10941
10982
  CLAIM_SYNC_BASE = "https://terminalhire.com";
10942
10983
  AUTO_CONSENT_VERSION = 2;
10943
10984
  AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
@@ -11035,6 +11076,7 @@ __export(jpi_claim_exports, {
11035
11076
  AI_DISCLOSURE_NOTE: () => AI_DISCLOSURE_NOTE,
11036
11077
  CLAIM_CONSENT_VERSION: () => CLAIM_CONSENT_VERSION,
11037
11078
  backgroundEnableFailed: () => backgroundEnableFailed,
11079
+ buildAssignmentComment: () => buildAssignmentComment,
11038
11080
  buildSubmitBody: () => buildSubmitBody,
11039
11081
  cmdRecord: () => cmdRecord,
11040
11082
  countOpenPRsReferencingIssue: () => countOpenPRsReferencingIssue,
@@ -11055,6 +11097,7 @@ __export(jpi_claim_exports, {
11055
11097
  resolveSubmitWorktree: () => resolveSubmitWorktree,
11056
11098
  revokeFailureAction: () => revokeFailureAction,
11057
11099
  run: () => run7,
11100
+ selectCompetingPrs: () => selectCompetingPrs,
11058
11101
  selectPushRemote: () => selectPushRemote,
11059
11102
  startBranchFor: () => startBranchFor,
11060
11103
  workDirFor: () => workDirFor
@@ -11288,6 +11331,10 @@ ${p.body ?? ""}`)) {
11288
11331
  }
11289
11332
  return matched;
11290
11333
  }
11334
+ function selectCompetingPrs(prs, selfLogin) {
11335
+ if (!Array.isArray(prs)) return [];
11336
+ return prs.filter((pr) => pr && pr.author !== selfLogin);
11337
+ }
11291
11338
  async function listOpenPRsReferencingIssue(repoFullName, issueNumber) {
11292
11339
  try {
11293
11340
  const res = await fetch(`${GH_API2}/repos/${repoFullName}/pulls?state=open&per_page=100`, {
@@ -11353,6 +11400,65 @@ async function fetchIssue(repoFullName, issueNumber) {
11353
11400
  return null;
11354
11401
  }
11355
11402
  }
11403
+ function buildAssignmentComment(repoFullName) {
11404
+ const usesTakeBot = TAKE_BOT_REPOS.has(repoFullName.toLowerCase());
11405
+ const body = usesTakeBot ? "/take" : `I'd like to work on this \u2014 could I be assigned? Thanks!
11406
+
11407
+ ${ASSIGNMENT_MARKER}`;
11408
+ return { usesTakeBot, body };
11409
+ }
11410
+ async function hasPriorAssignmentRequest(repoFullName, issueNumber, login) {
11411
+ try {
11412
+ const res = await fetch(
11413
+ `${GH_API2}/repos/${repoFullName}/issues/${issueNumber}/comments?per_page=100&sort=created&direction=desc`,
11414
+ { headers: GH_HEADERS2, signal: AbortSignal.timeout(1e4) }
11415
+ );
11416
+ if (!res.ok) return false;
11417
+ const comments = await res.json();
11418
+ if (!Array.isArray(comments)) return false;
11419
+ return comments.some(
11420
+ (c) => c && c.user && c.user.login === login && typeof c.body === "string" && c.body.includes(ASSIGNMENT_MARKER)
11421
+ );
11422
+ } catch {
11423
+ return false;
11424
+ }
11425
+ }
11426
+ async function requestIssueAssignment(claim, flags = {}, ghUser) {
11427
+ if (flags["no-assign"]) {
11428
+ console.log(" (--no-assign \u2014 skipping the assignment request; request it manually before working)");
11429
+ return;
11430
+ }
11431
+ const parsed = parseGitHubUrl(claim.issueUrl);
11432
+ if (!parsed) return;
11433
+ const { repoFullName, number: number3 } = parsed;
11434
+ let login = ghUser;
11435
+ if (!login) {
11436
+ try {
11437
+ login = await sh("gh", ["api", "user", "-q", ".login"]);
11438
+ } catch {
11439
+ console.log(" (assignment not requested: 'gh' not authenticated \u2014 comment on the issue manually before working)");
11440
+ return;
11441
+ }
11442
+ }
11443
+ const issue2 = await fetchIssue(repoFullName, number3);
11444
+ if (issue2 && issue2.assignees.includes(login)) {
11445
+ console.log(` \u2713 Already assigned to @${login} on ${repoFullName}#${number3}.`);
11446
+ return;
11447
+ }
11448
+ const { usesTakeBot, body } = buildAssignmentComment(repoFullName);
11449
+ if (!usesTakeBot && await hasPriorAssignmentRequest(repoFullName, number3, login)) {
11450
+ console.log(` \u2713 Assignment already requested on ${repoFullName}#${number3}.`);
11451
+ return;
11452
+ }
11453
+ try {
11454
+ await sh("gh", ["issue", "comment", String(number3), "--repo", repoFullName, "--body", body]);
11455
+ console.log(
11456
+ usesTakeBot ? ` \u2713 Requested assignment via /take on ${repoFullName}#${number3}.` : ` \u2713 Requested assignment on ${repoFullName}#${number3}.`
11457
+ );
11458
+ } catch (err) {
11459
+ console.log(` (could not post the assignment request: ${err.stderr || err.message || err} \u2014 do it manually before working)`);
11460
+ }
11461
+ }
11356
11462
  async function pollPR(prUrl) {
11357
11463
  const p = parseGitHubUrl(prUrl);
11358
11464
  if (!p) return null;
@@ -11643,6 +11749,12 @@ ${list.length} ${active ? "active " : ""}claim${list.length === 1 ? "" : "s"}:
11643
11749
  console.log(` id: ${c.id}${pr}`);
11644
11750
  }
11645
11751
  printMetric(claims.acceptedPRRate());
11752
+ try {
11753
+ if (await shouldNudgeUnpushed()) {
11754
+ console.log("\n \u26A0 new claims not yet on your dashboard \u2014 run: terminalhire claim --push --keep-updated");
11755
+ }
11756
+ } catch {
11757
+ }
11646
11758
  }
11647
11759
  async function cmdStatus(id) {
11648
11760
  const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -11842,7 +11954,7 @@ When it's done: terminalhire claim submit ${id}`);
11842
11954
  }
11843
11955
  }
11844
11956
  if (flags.here) {
11845
- await cmdStartHere(claims, claim);
11957
+ await cmdStartHere(claims, claim, flags);
11846
11958
  return;
11847
11959
  }
11848
11960
  let ghUser;
@@ -11904,6 +12016,7 @@ terminalhire claim: not started \u2014 starting forks ${claim.repoFullName} to y
11904
12016
  await sh("git", ["-C", destDir, "checkout", "-b", branch]);
11905
12017
  const toplevel = await sh("git", ["-C", destDir, "rev-parse", "--show-toplevel"]);
11906
12018
  claims.updateClaim(id, { worktreePath: toplevel, branch, state: "working" });
12019
+ await requestIssueAssignment(claim, flags, ghUser);
11907
12020
  console.log(`
11908
12021
  \u2713 Started: ${claim.title}`);
11909
12022
  console.log(` fork: ${forkFullName}`);
@@ -11913,7 +12026,7 @@ terminalhire claim: not started \u2014 starting forks ${claim.repoFullName} to y
11913
12026
  console.log("\n When the work is done (the only step that pushes + opens the PR):");
11914
12027
  console.log(` terminalhire claim submit ${id}`);
11915
12028
  }
11916
- async function cmdStartHere(claims, claim) {
12029
+ async function cmdStartHere(claims, claim, flags = {}) {
11917
12030
  let toplevel;
11918
12031
  try {
11919
12032
  toplevel = await sh("git", ["-C", process.cwd(), "rev-parse", "--show-toplevel"]);
@@ -11943,6 +12056,7 @@ async function cmdStartHere(claims, claim) {
11943
12056
  const branch = startBranchFor(claim.repoFullName, issueNumber);
11944
12057
  await sh("git", ["-C", toplevel, "checkout", "-b", branch]);
11945
12058
  claims.updateClaim(claim.id, { worktreePath: toplevel, branch, state: "working" });
12059
+ await requestIssueAssignment(claim, flags);
11946
12060
  console.log(`
11947
12061
  \u2713 Started here: ${claim.title}`);
11948
12062
  console.log(` worktree: ${toplevel}`);
@@ -12174,10 +12288,22 @@ async function cmdSubmit(id, flags = {}) {
12174
12288
  const issueNo = (parseGitHubUrl(claim.issueUrl) || {}).number;
12175
12289
  const contention = issueNo ? await listOpenPRsReferencingIssue(claim.repoFullName, issueNo) : null;
12176
12290
  if (contention && contention.total > 0) {
12177
- console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
12178
12291
  const nowMs = Date.now();
12292
+ console.log(` \u26A0 contention: ${contention.total} open PR(s) already reference this issue:`);
12179
12293
  for (const pr of contention.prs) console.log(fmtContentionPr(pr, nowMs, false));
12180
12294
  console.log(CONTENTION_HINT);
12295
+ const competing = selectCompetingPrs(contention.prs, ghUser);
12296
+ if (competing.length > 0 && !flags["force-competing"]) {
12297
+ console.error(
12298
+ `
12299
+ terminalhire claim: refusing to submit \u2014 ${competing.length} open PR(s) by someone else already address ${claim.repoFullName}#${issueNo}:`
12300
+ );
12301
+ for (const pr of competing) console.error(fmtContentionPr(pr, nowMs, false));
12302
+ console.error(
12303
+ "\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."
12304
+ );
12305
+ process.exit(1);
12306
+ }
12181
12307
  }
12182
12308
  }
12183
12309
  let ok;
@@ -12445,7 +12571,13 @@ async function cmdPush({ keepUpdated = false } = {}) {
12445
12571
  pushToken = body?.pushToken || null;
12446
12572
  } catch {
12447
12573
  }
12448
- writeClaimPushMarker({ consentedAt, login, deleteToken });
12574
+ writeClaimPushMarker({
12575
+ consentedAt,
12576
+ login,
12577
+ deleteToken,
12578
+ lastPushedAt: consentedAt,
12579
+ lastSnapshotHash: computeSnapshotHash(pushed)
12580
+ });
12449
12581
  if (autoConsent && pushToken) {
12450
12582
  try {
12451
12583
  await writePushTokenEnc(pushToken);
@@ -12589,7 +12721,7 @@ async function run7() {
12589
12721
  process.exit(1);
12590
12722
  }
12591
12723
  }
12592
- 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;
12724
+ 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;
12593
12725
  var init_jpi_claim = __esm({
12594
12726
  "bin/jpi-claim.js"() {
12595
12727
  "use strict";
@@ -12611,6 +12743,8 @@ var init_jpi_claim = __esm({
12611
12743
  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.";
12612
12744
  pExecFile = promisify(execFile);
12613
12745
  VALUE_FLAGS = /* @__PURE__ */ new Set(["worktree", "branch", "body-file", "title"]);
12746
+ ASSIGNMENT_MARKER = "<!-- terminalhire:assignment-request -->";
12747
+ TAKE_BOT_REPOS = /* @__PURE__ */ new Set(["paradedb/paradedb"]);
12614
12748
  }
12615
12749
  });
12616
12750
 
@@ -41110,6 +41244,9 @@ function buildContributeNudgeLine(contributeNudge) {
41110
41244
  function buildSessionStaleLine(sessionStale) {
41111
41245
  return sessionStale === true ? "\u26A0 terminalhire: linked session expired \u2014 run: terminalhire login" : null;
41112
41246
  }
41247
+ function buildUnpushedClaimsLine(unpushedClaims) {
41248
+ return unpushedClaims === true ? "\u26A0 new claims not yet on your dashboard \u2014 run: terminalhire claim --push --keep-updated" : null;
41249
+ }
41113
41250
  function buildSpinnerPool(topMatches, max = 6, opts = {}) {
41114
41251
  const {
41115
41252
  sessionTags,
@@ -41118,16 +41255,19 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
41118
41255
  incomingPending,
41119
41256
  sessionStale,
41120
41257
  contributeNudge,
41258
+ unpushedClaims,
41121
41259
  seenHistory
41122
41260
  } = opts;
41123
41261
  const staleLine = buildSessionStaleLine(sessionStale);
41124
41262
  const withStale = (pool2) => staleLine ? [staleLine, ...pool2] : pool2;
41125
41263
  const introLine = buildIncomingIntroLine(incomingPending);
41126
41264
  const contributeLine = buildContributeNudgeLine(contributeNudge);
41265
+ const unpushedLine = buildUnpushedClaimsLine(unpushedClaims);
41127
41266
  const ranked = filterFreshMatches(rankBySessionTags(topMatches, sessionTags), seenHistory);
41128
41267
  if (!Array.isArray(ranked) || ranked.length === 0) {
41129
41268
  if (introLine) return withStale([introLine]);
41130
41269
  if (contributeLine) return withStale([contributeLine]);
41270
+ if (unpushedLine) return withStale([unpushedLine]);
41131
41271
  const peerLine = buildPeerLine(topPeers);
41132
41272
  return withStale(peerLine ? [peerLine] : []);
41133
41273
  }
@@ -41137,6 +41277,7 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
41137
41277
  const pool = [...headers.slice(0, cap), ctaVerb()];
41138
41278
  if (introLine) pool.push(introLine);
41139
41279
  if (contributeLine) pool.push(contributeLine);
41280
+ if (unpushedLine) pool.push(unpushedLine);
41140
41281
  return withStale(pool);
41141
41282
  }
41142
41283
  var init_spinner_verbs = __esm({
@@ -41276,6 +41417,7 @@ function renderRefreshSurface(topMatches, sc, opts = {}) {
41276
41417
  incomingPending: opts.incomingPending,
41277
41418
  sessionStale: opts.sessionStale,
41278
41419
  contributeNudge: opts.contributeNudge,
41420
+ unpushedClaims: opts.unpushedClaims,
41279
41421
  seenHistory
41280
41422
  });
41281
41423
  if (verbs.length > 0) applySpinnerVerbs(verbs, sc.mode);
@@ -41315,6 +41457,7 @@ __export(spinner_exports, {
41315
41457
  buildSpinnerPool: () => buildSpinnerPool,
41316
41458
  buildTips: () => buildTips,
41317
41459
  buildTipsDetailed: () => buildTipsDetailed,
41460
+ buildUnpushedClaimsLine: () => buildUnpushedClaimsLine,
41318
41461
  clearSpinnerTips: () => clearSpinnerTips,
41319
41462
  clearSpinnerVerbs: () => clearSpinnerVerbs,
41320
41463
  ctaVerb: () => ctaVerb,
@@ -41339,6 +41482,7 @@ var init_spinner = __esm({
41339
41482
  init_spinner_verbs();
41340
41483
  init_spinner_verbs();
41341
41484
  init_spinner_verbs();
41485
+ init_spinner_verbs();
41342
41486
  init_spinner_select();
41343
41487
  init_spinner_select();
41344
41488
  init_spinner_select();
@@ -42501,6 +42645,12 @@ async function run22() {
42501
42645
  topMatches = filterFreshMatches2(topMatches, seenHistory);
42502
42646
  } catch {
42503
42647
  }
42648
+ let unpushedClaims = false;
42649
+ try {
42650
+ const { shouldNudgeUnpushed: shouldNudgeUnpushed2 } = await Promise.resolve().then(() => (init_claim_push_bg(), claim_push_bg_exports));
42651
+ unpushedClaims = await shouldNudgeUnpushed2();
42652
+ } catch {
42653
+ }
42504
42654
  const cacheEntry = {
42505
42655
  index,
42506
42656
  matchCount,
@@ -42509,6 +42659,7 @@ async function run22() {
42509
42659
  incomingPending,
42510
42660
  unreadChat,
42511
42661
  sessionStale,
42662
+ unpushedClaims,
42512
42663
  // In-process-only despite being persisted: the render pass receives it as
42513
42664
  // a function argument below. A future cache READER must not gate on this
42514
42665
  // field without re-checking isContributeEnabled/isContributePrompted at
@@ -42547,6 +42698,7 @@ async function run22() {
42547
42698
  incomingPending,
42548
42699
  sessionStale,
42549
42700
  contributeNudge,
42701
+ unpushedClaims,
42550
42702
  baseUrl: API_URL8,
42551
42703
  seenHistory,
42552
42704
  widen,
@@ -9045,6 +9045,9 @@ function buildContributeNudgeLine(contributeNudge) {
9045
9045
  function buildSessionStaleLine(sessionStale) {
9046
9046
  return sessionStale === true ? "\u26A0 terminalhire: linked session expired \u2014 run: terminalhire login" : null;
9047
9047
  }
9048
+ function buildUnpushedClaimsLine(unpushedClaims) {
9049
+ return unpushedClaims === true ? "\u26A0 new claims not yet on your dashboard \u2014 run: terminalhire claim --push --keep-updated" : null;
9050
+ }
9048
9051
  function buildSpinnerPool(topMatches, max = 6, opts = {}) {
9049
9052
  const {
9050
9053
  sessionTags,
@@ -9053,16 +9056,19 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
9053
9056
  incomingPending,
9054
9057
  sessionStale,
9055
9058
  contributeNudge,
9059
+ unpushedClaims,
9056
9060
  seenHistory
9057
9061
  } = opts;
9058
9062
  const staleLine = buildSessionStaleLine(sessionStale);
9059
9063
  const withStale = (pool2) => staleLine ? [staleLine, ...pool2] : pool2;
9060
9064
  const introLine = buildIncomingIntroLine(incomingPending);
9061
9065
  const contributeLine = buildContributeNudgeLine(contributeNudge);
9066
+ const unpushedLine = buildUnpushedClaimsLine(unpushedClaims);
9062
9067
  const ranked = filterFreshMatches(rankBySessionTags(topMatches, sessionTags), seenHistory);
9063
9068
  if (!Array.isArray(ranked) || ranked.length === 0) {
9064
9069
  if (introLine) return withStale([introLine]);
9065
9070
  if (contributeLine) return withStale([contributeLine]);
9071
+ if (unpushedLine) return withStale([unpushedLine]);
9066
9072
  const peerLine = buildPeerLine(topPeers);
9067
9073
  return withStale(peerLine ? [peerLine] : []);
9068
9074
  }
@@ -9072,6 +9078,7 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
9072
9078
  const pool = [...headers.slice(0, cap), ctaVerb()];
9073
9079
  if (introLine) pool.push(introLine);
9074
9080
  if (contributeLine) pool.push(contributeLine);
9081
+ if (unpushedLine) pool.push(unpushedLine);
9075
9082
  return withStale(pool);
9076
9083
  }
9077
9084
  var init_spinner_verbs = __esm({
@@ -9211,6 +9218,7 @@ function renderRefreshSurface(topMatches, sc, opts = {}) {
9211
9218
  incomingPending: opts.incomingPending,
9212
9219
  sessionStale: opts.sessionStale,
9213
9220
  contributeNudge: opts.contributeNudge,
9221
+ unpushedClaims: opts.unpushedClaims,
9214
9222
  seenHistory
9215
9223
  });
9216
9224
  if (verbs.length > 0) applySpinnerVerbs(verbs, sc.mode);
@@ -9250,6 +9258,7 @@ __export(spinner_exports, {
9250
9258
  buildSpinnerPool: () => buildSpinnerPool,
9251
9259
  buildTips: () => buildTips,
9252
9260
  buildTipsDetailed: () => buildTipsDetailed,
9261
+ buildUnpushedClaimsLine: () => buildUnpushedClaimsLine,
9253
9262
  clearSpinnerTips: () => clearSpinnerTips,
9254
9263
  clearSpinnerVerbs: () => clearSpinnerVerbs,
9255
9264
  ctaVerb: () => ctaVerb,
@@ -9274,6 +9283,7 @@ var init_spinner = __esm({
9274
9283
  init_spinner_verbs();
9275
9284
  init_spinner_verbs();
9276
9285
  init_spinner_verbs();
9286
+ init_spinner_verbs();
9277
9287
  init_spinner_select();
9278
9288
  init_spinner_select();
9279
9289
  init_spinner_select();
@@ -9288,137 +9298,6 @@ var init_spinner = __esm({
9288
9298
  }
9289
9299
  });
9290
9300
 
9291
- // bin/version-nudge.js
9292
- var version_nudge_exports = {};
9293
- __export(version_nudge_exports, {
9294
- buildStaleNudge: () => buildStaleNudge,
9295
- cachedStaleNudge: () => cachedStaleNudge,
9296
- compareVersions: () => compareVersions,
9297
- emitInteractiveNudge: () => emitInteractiveNudge,
9298
- parseVersion: () => parseVersion,
9299
- readLatestVersionFromCache: () => readLatestVersionFromCache,
9300
- readLocalVersion: () => readLocalVersion,
9301
- recordNag: () => recordNag,
9302
- shouldNag: () => shouldNag
9303
- });
9304
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync9, mkdirSync as mkdirSync9, existsSync as existsSync6 } from "fs";
9305
- import { join as join12 } from "path";
9306
- import { homedir as homedir9 } from "os";
9307
- import { fileURLToPath as fileURLToPath2 } from "url";
9308
- function stateDir() {
9309
- return process.env.TERMINALHIRE_DIR || join12(homedir9(), ".terminalhire");
9310
- }
9311
- function indexCacheFile() {
9312
- return join12(stateDir(), "index-cache.json");
9313
- }
9314
- function nudgeStateFile() {
9315
- return join12(stateDir(), "version-nudge.json");
9316
- }
9317
- function parseVersion(v) {
9318
- if (typeof v !== "string") return null;
9319
- const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
9320
- if (!m) return null;
9321
- return [Number(m[1]), Number(m[2]), Number(m[3])];
9322
- }
9323
- function compareVersions(a, b) {
9324
- const pa = parseVersion(a);
9325
- const pb = parseVersion(b);
9326
- if (!pa || !pb) return null;
9327
- for (let i = 0; i < 3; i++) {
9328
- if (pa[i] < pb[i]) return -1;
9329
- if (pa[i] > pb[i]) return 1;
9330
- }
9331
- return 0;
9332
- }
9333
- function buildStaleNudge(local, latest) {
9334
- if (compareVersions(local, latest) !== -1) return null;
9335
- return `your terminalhire CLI is behind (${local} \u2192 ${latest}) \u2014 npm i -g terminalhire@latest`;
9336
- }
9337
- function readLocalVersion() {
9338
- try {
9339
- const candidates = [
9340
- join12(__dirname, "..", "..", "package.json"),
9341
- join12(__dirname, "..", "package.json")
9342
- ];
9343
- for (const p of candidates) {
9344
- if (existsSync6(p)) {
9345
- const pkg = JSON.parse(readFileSync11(p, "utf8"));
9346
- if (pkg.version) return pkg.version;
9347
- }
9348
- }
9349
- } catch {
9350
- }
9351
- return null;
9352
- }
9353
- function readLatestVersionFromCache() {
9354
- try {
9355
- const cache = JSON.parse(readFileSync11(indexCacheFile(), "utf8"));
9356
- const v = cache?.index?.cliVersion;
9357
- return typeof v === "string" ? v : null;
9358
- } catch {
9359
- return null;
9360
- }
9361
- }
9362
- function cachedStaleNudge(localVersion) {
9363
- const local = localVersion ?? readLocalVersion();
9364
- if (!local) return null;
9365
- return buildStaleNudge(local, readLatestVersionFromCache());
9366
- }
9367
- function shouldNag(now = Date.now()) {
9368
- try {
9369
- const state = JSON.parse(readFileSync11(nudgeStateFile(), "utf8"));
9370
- const last = state?.lastNaggedAt;
9371
- if (typeof last !== "number" || !Number.isFinite(last)) return true;
9372
- return now - last >= NAG_INTERVAL_MS;
9373
- } catch {
9374
- return true;
9375
- }
9376
- }
9377
- function recordNag(now = Date.now()) {
9378
- try {
9379
- mkdirSync9(stateDir(), { recursive: true });
9380
- writeFileSync9(nudgeStateFile(), JSON.stringify({ lastNaggedAt: now }) + "\n", "utf8");
9381
- } catch {
9382
- }
9383
- }
9384
- function emitInteractiveNudge({ now = Date.now(), stream = process.stderr, localVersion } = {}) {
9385
- try {
9386
- const nudge = cachedStaleNudge(localVersion);
9387
- if (!nudge) return false;
9388
- if (!shouldNag(now)) return false;
9389
- stream.write(`${nudge}
9390
- `);
9391
- recordNag(now);
9392
- return true;
9393
- } catch {
9394
- return false;
9395
- }
9396
- }
9397
- var __dirname, NAG_INTERVAL_MS;
9398
- var init_version_nudge = __esm({
9399
- "bin/version-nudge.js"() {
9400
- "use strict";
9401
- __dirname = fileURLToPath2(new URL(".", import.meta.url));
9402
- NAG_INTERVAL_MS = 24 * 60 * 60 * 1e3;
9403
- }
9404
- });
9405
-
9406
- // bin/beta-nudge.js
9407
- var beta_nudge_exports = {};
9408
- __export(beta_nudge_exports, {
9409
- buildBetaNudge: () => buildBetaNudge
9410
- });
9411
- function buildBetaNudge(betaOpen, betaOptIn) {
9412
- if (betaOpen !== true) return null;
9413
- if (betaOptIn === true) return null;
9414
- return "\u{1F9EA} Beta's open \u2014 run `terminalhire beta` to join as a Founding Contributor";
9415
- }
9416
- var init_beta_nudge = __esm({
9417
- "bin/beta-nudge.js"() {
9418
- "use strict";
9419
- }
9420
- });
9421
-
9422
9301
  // src/github-auth.ts
9423
9302
  import {
9424
9303
  createCipheriv as createCipheriv2,
@@ -9426,14 +9305,14 @@ import {
9426
9305
  randomBytes as randomBytes4
9427
9306
  } from "crypto";
9428
9307
  import {
9429
- readFileSync as readFileSync12,
9430
- writeFileSync as writeFileSync10,
9431
- mkdirSync as mkdirSync10,
9432
- existsSync as existsSync7,
9308
+ readFileSync as readFileSync11,
9309
+ writeFileSync as writeFileSync9,
9310
+ mkdirSync as mkdirSync9,
9311
+ existsSync as existsSync6,
9433
9312
  rmSync as rmSync2
9434
9313
  } from "fs";
9435
- import { join as join13 } from "path";
9436
- import { homedir as homedir10 } from "os";
9314
+ import { join as join12 } from "path";
9315
+ import { homedir as homedir9 } from "os";
9437
9316
  async function loadKey2() {
9438
9317
  try {
9439
9318
  const kt = await Promise.resolve().then(() => __toESM(require_keytar2(), 1));
@@ -9444,12 +9323,12 @@ async function loadKey2() {
9444
9323
  return key2;
9445
9324
  } catch {
9446
9325
  }
9447
- mkdirSync10(TERMINALHIRE_DIR6, { recursive: true });
9448
- if (existsSync7(KEY_FILE2)) {
9449
- return Buffer.from(readFileSync12(KEY_FILE2, "utf8").trim(), "hex");
9326
+ mkdirSync9(TERMINALHIRE_DIR6, { recursive: true });
9327
+ if (existsSync6(KEY_FILE2)) {
9328
+ return Buffer.from(readFileSync11(KEY_FILE2, "utf8").trim(), "hex");
9450
9329
  }
9451
9330
  const key = randomBytes4(KEY_BYTES2);
9452
- writeFileSync10(KEY_FILE2, key.toString("hex"), { mode: 384, encoding: "utf8" });
9331
+ writeFileSync9(KEY_FILE2, key.toString("hex"), { mode: 384, encoding: "utf8" });
9453
9332
  return key;
9454
9333
  }
9455
9334
  function encrypt2(plaintext, key) {
@@ -9472,9 +9351,9 @@ var TERMINALHIRE_DIR6, TOKEN_FILE, KEY_FILE2, ALGO2, KEY_BYTES2, IV_BYTES2;
9472
9351
  var init_github_auth = __esm({
9473
9352
  "src/github-auth.ts"() {
9474
9353
  "use strict";
9475
- TERMINALHIRE_DIR6 = join13(homedir10(), ".terminalhire");
9476
- TOKEN_FILE = join13(TERMINALHIRE_DIR6, "github-token.enc");
9477
- KEY_FILE2 = join13(TERMINALHIRE_DIR6, "key");
9354
+ TERMINALHIRE_DIR6 = join12(homedir9(), ".terminalhire");
9355
+ TOKEN_FILE = join12(TERMINALHIRE_DIR6, "github-token.enc");
9356
+ KEY_FILE2 = join12(TERMINALHIRE_DIR6, "key");
9478
9357
  ALGO2 = "aes-256-gcm";
9479
9358
  KEY_BYTES2 = 32;
9480
9359
  IV_BYTES2 = 12;
@@ -9494,9 +9373,9 @@ __export(claims_exports, {
9494
9373
  toPushedClaim: () => toPushedClaim,
9495
9374
  updateClaim: () => updateClaim
9496
9375
  });
9497
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync11, mkdirSync as mkdirSync11, renameSync as renameSync6, existsSync as existsSync8 } from "fs";
9498
- import { join as join14 } from "path";
9499
- import { homedir as homedir11 } from "os";
9376
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync10, mkdirSync as mkdirSync10, renameSync as renameSync6, existsSync as existsSync7 } from "fs";
9377
+ import { join as join13 } from "path";
9378
+ import { homedir as homedir10 } from "os";
9500
9379
  function toPushedClaim(claim) {
9501
9380
  return {
9502
9381
  kind: claim.kind,
@@ -9516,8 +9395,8 @@ function normalizeClaim(c) {
9516
9395
  }
9517
9396
  function readClaims() {
9518
9397
  try {
9519
- if (!existsSync8(CLAIMS_FILE)) return [];
9520
- const data = JSON.parse(readFileSync13(CLAIMS_FILE, "utf8"));
9398
+ if (!existsSync7(CLAIMS_FILE)) return [];
9399
+ const data = JSON.parse(readFileSync12(CLAIMS_FILE, "utf8"));
9521
9400
  const claims = Array.isArray(data?.claims) ? data.claims : [];
9522
9401
  return claims.map(normalizeClaim);
9523
9402
  } catch {
@@ -9525,10 +9404,10 @@ function readClaims() {
9525
9404
  }
9526
9405
  }
9527
9406
  function writeClaims(claims) {
9528
- mkdirSync11(TERMINALHIRE_DIR7, { recursive: true });
9407
+ mkdirSync10(TERMINALHIRE_DIR7, { recursive: true });
9529
9408
  const tmp = `${CLAIMS_FILE}.tmp`;
9530
9409
  const payload = { claims };
9531
- writeFileSync11(tmp, JSON.stringify(payload, null, 2), "utf8");
9410
+ writeFileSync10(tmp, JSON.stringify(payload, null, 2), "utf8");
9532
9411
  renameSync6(tmp, CLAIMS_FILE);
9533
9412
  }
9534
9413
  function findClaim(id) {
@@ -9589,8 +9468,8 @@ var TERMINALHIRE_DIR7, CLAIMS_FILE, PUSHED_CLAIM_FIELDS, TERMINAL_STATES;
9589
9468
  var init_claims = __esm({
9590
9469
  "src/claims.ts"() {
9591
9470
  "use strict";
9592
- TERMINALHIRE_DIR7 = process.env.TERMINALHIRE_DIR || join14(homedir11(), ".terminalhire");
9593
- CLAIMS_FILE = join14(TERMINALHIRE_DIR7, "claims.json");
9471
+ TERMINALHIRE_DIR7 = process.env.TERMINALHIRE_DIR || join13(homedir10(), ".terminalhire");
9472
+ CLAIMS_FILE = join13(TERMINALHIRE_DIR7, "claims.json");
9594
9473
  PUSHED_CLAIM_FIELDS = [
9595
9474
  "kind",
9596
9475
  "repoFullName",
@@ -9610,6 +9489,7 @@ __export(claim_push_bg_exports, {
9610
9489
  AUTO_CONSENT_VERSION: () => AUTO_CONSENT_VERSION,
9611
9490
  AUTO_PUSH_THROTTLE_MS: () => AUTO_PUSH_THROTTLE_MS,
9612
9491
  CLAIM_PUSH_AUTO_MARKER: () => CLAIM_PUSH_AUTO_MARKER,
9492
+ CLAIM_PUSH_MANUAL_MARKER: () => CLAIM_PUSH_MANUAL_MARKER,
9613
9493
  CLAIM_PUSH_TOKEN_FILE: () => CLAIM_PUSH_TOKEN_FILE,
9614
9494
  backgroundPushGate: () => backgroundPushGate,
9615
9495
  clearAutoMarker: () => clearAutoMarker,
@@ -9618,24 +9498,26 @@ __export(claim_push_bg_exports, {
9618
9498
  readAutoMarker: () => readAutoMarker,
9619
9499
  readPushTokenEnc: () => readPushTokenEnc,
9620
9500
  runBackgroundClaimPush: () => runBackgroundClaimPush,
9501
+ shouldNudgeUnpushed: () => shouldNudgeUnpushed,
9502
+ unpushedNudgeGate: () => unpushedNudgeGate,
9621
9503
  writeAutoMarker: () => writeAutoMarker,
9622
9504
  writePushTokenEnc: () => writePushTokenEnc
9623
9505
  });
9624
9506
  import { createHash as createHash3 } from "crypto";
9625
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync12, mkdirSync as mkdirSync12, existsSync as existsSync9, rmSync as rmSync3 } from "fs";
9626
- import { join as join15 } from "path";
9627
- import { homedir as homedir12 } from "os";
9507
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync11, mkdirSync as mkdirSync11, existsSync as existsSync8, rmSync as rmSync3 } from "fs";
9508
+ import { join as join14 } from "path";
9509
+ import { homedir as homedir11 } from "os";
9628
9510
  async function writePushTokenEnc(rawToken) {
9629
- mkdirSync12(TERMINALHIRE_DIR8, { recursive: true });
9511
+ mkdirSync11(TERMINALHIRE_DIR8, { recursive: true });
9630
9512
  const key = await loadKey2();
9631
9513
  const blob = encrypt2(rawToken, key);
9632
- writeFileSync12(CLAIM_PUSH_TOKEN_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
9514
+ writeFileSync11(CLAIM_PUSH_TOKEN_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
9633
9515
  }
9634
9516
  async function readPushTokenEnc() {
9635
- if (!existsSync9(CLAIM_PUSH_TOKEN_FILE)) return void 0;
9517
+ if (!existsSync8(CLAIM_PUSH_TOKEN_FILE)) return void 0;
9636
9518
  try {
9637
9519
  const key = await loadKey2();
9638
- const blob = JSON.parse(readFileSync14(CLAIM_PUSH_TOKEN_FILE, "utf8"));
9520
+ const blob = JSON.parse(readFileSync13(CLAIM_PUSH_TOKEN_FILE, "utf8"));
9639
9521
  return decrypt2(blob, key);
9640
9522
  } catch {
9641
9523
  return void 0;
@@ -9649,14 +9531,14 @@ function clearPushTokenEnc() {
9649
9531
  }
9650
9532
  function readAutoMarker() {
9651
9533
  try {
9652
- return existsSync9(CLAIM_PUSH_AUTO_MARKER) ? JSON.parse(readFileSync14(CLAIM_PUSH_AUTO_MARKER, "utf8")) : null;
9534
+ return existsSync8(CLAIM_PUSH_AUTO_MARKER) ? JSON.parse(readFileSync13(CLAIM_PUSH_AUTO_MARKER, "utf8")) : null;
9653
9535
  } catch {
9654
9536
  return null;
9655
9537
  }
9656
9538
  }
9657
9539
  function writeAutoMarker(marker) {
9658
- mkdirSync12(TERMINALHIRE_DIR8, { recursive: true });
9659
- writeFileSync12(CLAIM_PUSH_AUTO_MARKER, JSON.stringify(marker, null, 2) + "\n", "utf8");
9540
+ mkdirSync11(TERMINALHIRE_DIR8, { recursive: true });
9541
+ writeFileSync11(CLAIM_PUSH_AUTO_MARKER, JSON.stringify(marker, null, 2) + "\n", "utf8");
9660
9542
  }
9661
9543
  function clearAutoMarker() {
9662
9544
  try {
@@ -9689,9 +9571,46 @@ function backgroundPushGate(params) {
9689
9571
  }
9690
9572
  return { push: true, reason: "ok" };
9691
9573
  }
9574
+ function unpushedNudgeGate(params) {
9575
+ const {
9576
+ autoMarkerExists,
9577
+ tokenFileExists,
9578
+ manualMarkerExists,
9579
+ lastSnapshotHash,
9580
+ currentHash,
9581
+ claimCount
9582
+ } = params;
9583
+ if (autoMarkerExists && tokenFileExists) return false;
9584
+ if (!manualMarkerExists) return false;
9585
+ if (!(claimCount > 0)) return false;
9586
+ return lastSnapshotHash !== currentHash;
9587
+ }
9588
+ async function shouldNudgeUnpushed() {
9589
+ try {
9590
+ const { listClaims: listClaims2, toPushedClaim: toPushedClaim2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
9591
+ const pushed = listClaims2().map((c) => toPushedClaim2(c));
9592
+ const currentHash = computeSnapshotHash(pushed);
9593
+ let manual = null;
9594
+ try {
9595
+ manual = existsSync8(CLAIM_PUSH_MANUAL_MARKER) ? JSON.parse(readFileSync13(CLAIM_PUSH_MANUAL_MARKER, "utf8")) : null;
9596
+ } catch {
9597
+ manual = null;
9598
+ }
9599
+ return unpushedNudgeGate({
9600
+ autoMarkerExists: existsSync8(CLAIM_PUSH_AUTO_MARKER),
9601
+ tokenFileExists: existsSync8(CLAIM_PUSH_TOKEN_FILE),
9602
+ manualMarkerExists: !!manual,
9603
+ lastSnapshotHash: manual?.lastSnapshotHash ?? null,
9604
+ currentHash,
9605
+ claimCount: pushed.length
9606
+ });
9607
+ } catch {
9608
+ return false;
9609
+ }
9610
+ }
9692
9611
  async function runBackgroundClaimPush({ now = Date.now() } = {}) {
9693
9612
  try {
9694
- if (!existsSync9(CLAIM_PUSH_AUTO_MARKER) || !existsSync9(CLAIM_PUSH_TOKEN_FILE)) return;
9613
+ if (!existsSync8(CLAIM_PUSH_AUTO_MARKER) || !existsSync8(CLAIM_PUSH_TOKEN_FILE)) return;
9695
9614
  const marker = readAutoMarker();
9696
9615
  if (!marker || !marker.autoConsentedAt) return;
9697
9616
  const { listClaims: listClaims2, toPushedClaim: toPushedClaim2, PUSHED_CLAIM_FIELDS: PUSHED_CLAIM_FIELDS2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -9729,20 +9648,152 @@ async function runBackgroundClaimPush({ now = Date.now() } = {}) {
9729
9648
  } catch {
9730
9649
  }
9731
9650
  }
9732
- var TERMINALHIRE_DIR8, CLAIM_PUSH_AUTO_MARKER, CLAIM_PUSH_TOKEN_FILE, CLAIM_SYNC_BASE, AUTO_CONSENT_VERSION, AUTO_PUSH_THROTTLE_MS;
9651
+ var TERMINALHIRE_DIR8, CLAIM_PUSH_AUTO_MARKER, CLAIM_PUSH_TOKEN_FILE, CLAIM_PUSH_MANUAL_MARKER, CLAIM_SYNC_BASE, AUTO_CONSENT_VERSION, AUTO_PUSH_THROTTLE_MS;
9733
9652
  var init_claim_push_bg = __esm({
9734
9653
  "bin/claim-push-bg.js"() {
9735
9654
  "use strict";
9736
9655
  init_github_auth();
9737
- TERMINALHIRE_DIR8 = process.env.TERMINALHIRE_DIR || join15(homedir12(), ".terminalhire");
9738
- CLAIM_PUSH_AUTO_MARKER = join15(TERMINALHIRE_DIR8, "claim-push-auto.json");
9739
- CLAIM_PUSH_TOKEN_FILE = join15(TERMINALHIRE_DIR8, "claim-push-token.enc");
9656
+ TERMINALHIRE_DIR8 = process.env.TERMINALHIRE_DIR || join14(homedir11(), ".terminalhire");
9657
+ CLAIM_PUSH_AUTO_MARKER = join14(TERMINALHIRE_DIR8, "claim-push-auto.json");
9658
+ CLAIM_PUSH_TOKEN_FILE = join14(TERMINALHIRE_DIR8, "claim-push-token.enc");
9659
+ CLAIM_PUSH_MANUAL_MARKER = join14(TERMINALHIRE_DIR8, "claim-push.json");
9740
9660
  CLAIM_SYNC_BASE = "https://terminalhire.com";
9741
9661
  AUTO_CONSENT_VERSION = 2;
9742
9662
  AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
9743
9663
  }
9744
9664
  });
9745
9665
 
9666
+ // bin/version-nudge.js
9667
+ var version_nudge_exports = {};
9668
+ __export(version_nudge_exports, {
9669
+ buildStaleNudge: () => buildStaleNudge,
9670
+ cachedStaleNudge: () => cachedStaleNudge,
9671
+ compareVersions: () => compareVersions,
9672
+ emitInteractiveNudge: () => emitInteractiveNudge,
9673
+ parseVersion: () => parseVersion,
9674
+ readLatestVersionFromCache: () => readLatestVersionFromCache,
9675
+ readLocalVersion: () => readLocalVersion,
9676
+ recordNag: () => recordNag,
9677
+ shouldNag: () => shouldNag
9678
+ });
9679
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync12, mkdirSync as mkdirSync12, existsSync as existsSync9 } from "fs";
9680
+ import { join as join15 } from "path";
9681
+ import { homedir as homedir12 } from "os";
9682
+ import { fileURLToPath as fileURLToPath2 } from "url";
9683
+ function stateDir() {
9684
+ return process.env.TERMINALHIRE_DIR || join15(homedir12(), ".terminalhire");
9685
+ }
9686
+ function indexCacheFile() {
9687
+ return join15(stateDir(), "index-cache.json");
9688
+ }
9689
+ function nudgeStateFile() {
9690
+ return join15(stateDir(), "version-nudge.json");
9691
+ }
9692
+ function parseVersion(v) {
9693
+ if (typeof v !== "string") return null;
9694
+ const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
9695
+ if (!m) return null;
9696
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
9697
+ }
9698
+ function compareVersions(a, b) {
9699
+ const pa = parseVersion(a);
9700
+ const pb = parseVersion(b);
9701
+ if (!pa || !pb) return null;
9702
+ for (let i = 0; i < 3; i++) {
9703
+ if (pa[i] < pb[i]) return -1;
9704
+ if (pa[i] > pb[i]) return 1;
9705
+ }
9706
+ return 0;
9707
+ }
9708
+ function buildStaleNudge(local, latest) {
9709
+ if (compareVersions(local, latest) !== -1) return null;
9710
+ return `your terminalhire CLI is behind (${local} \u2192 ${latest}) \u2014 npm i -g terminalhire@latest`;
9711
+ }
9712
+ function readLocalVersion() {
9713
+ try {
9714
+ const candidates = [
9715
+ join15(__dirname, "..", "..", "package.json"),
9716
+ join15(__dirname, "..", "package.json")
9717
+ ];
9718
+ for (const p of candidates) {
9719
+ if (existsSync9(p)) {
9720
+ const pkg = JSON.parse(readFileSync14(p, "utf8"));
9721
+ if (pkg.version) return pkg.version;
9722
+ }
9723
+ }
9724
+ } catch {
9725
+ }
9726
+ return null;
9727
+ }
9728
+ function readLatestVersionFromCache() {
9729
+ try {
9730
+ const cache = JSON.parse(readFileSync14(indexCacheFile(), "utf8"));
9731
+ const v = cache?.index?.cliVersion;
9732
+ return typeof v === "string" ? v : null;
9733
+ } catch {
9734
+ return null;
9735
+ }
9736
+ }
9737
+ function cachedStaleNudge(localVersion) {
9738
+ const local = localVersion ?? readLocalVersion();
9739
+ if (!local) return null;
9740
+ return buildStaleNudge(local, readLatestVersionFromCache());
9741
+ }
9742
+ function shouldNag(now = Date.now()) {
9743
+ try {
9744
+ const state = JSON.parse(readFileSync14(nudgeStateFile(), "utf8"));
9745
+ const last = state?.lastNaggedAt;
9746
+ if (typeof last !== "number" || !Number.isFinite(last)) return true;
9747
+ return now - last >= NAG_INTERVAL_MS;
9748
+ } catch {
9749
+ return true;
9750
+ }
9751
+ }
9752
+ function recordNag(now = Date.now()) {
9753
+ try {
9754
+ mkdirSync12(stateDir(), { recursive: true });
9755
+ writeFileSync12(nudgeStateFile(), JSON.stringify({ lastNaggedAt: now }) + "\n", "utf8");
9756
+ } catch {
9757
+ }
9758
+ }
9759
+ function emitInteractiveNudge({ now = Date.now(), stream = process.stderr, localVersion } = {}) {
9760
+ try {
9761
+ const nudge = cachedStaleNudge(localVersion);
9762
+ if (!nudge) return false;
9763
+ if (!shouldNag(now)) return false;
9764
+ stream.write(`${nudge}
9765
+ `);
9766
+ recordNag(now);
9767
+ return true;
9768
+ } catch {
9769
+ return false;
9770
+ }
9771
+ }
9772
+ var __dirname, NAG_INTERVAL_MS;
9773
+ var init_version_nudge = __esm({
9774
+ "bin/version-nudge.js"() {
9775
+ "use strict";
9776
+ __dirname = fileURLToPath2(new URL(".", import.meta.url));
9777
+ NAG_INTERVAL_MS = 24 * 60 * 60 * 1e3;
9778
+ }
9779
+ });
9780
+
9781
+ // bin/beta-nudge.js
9782
+ var beta_nudge_exports = {};
9783
+ __export(beta_nudge_exports, {
9784
+ buildBetaNudge: () => buildBetaNudge
9785
+ });
9786
+ function buildBetaNudge(betaOpen, betaOptIn) {
9787
+ if (betaOpen !== true) return null;
9788
+ if (betaOptIn === true) return null;
9789
+ return "\u{1F9EA} Beta's open \u2014 run `terminalhire beta` to join as a Founding Contributor";
9790
+ }
9791
+ var init_beta_nudge = __esm({
9792
+ "bin/beta-nudge.js"() {
9793
+ "use strict";
9794
+ }
9795
+ });
9796
+
9746
9797
  // bin/jpi-refresh.js
9747
9798
  import { fileURLToPath as fileURLToPath3 } from "url";
9748
9799
 
@@ -10218,6 +10269,12 @@ async function run() {
10218
10269
  topMatches = filterFreshMatches2(topMatches, seenHistory);
10219
10270
  } catch {
10220
10271
  }
10272
+ let unpushedClaims = false;
10273
+ try {
10274
+ const { shouldNudgeUnpushed: shouldNudgeUnpushed2 } = await Promise.resolve().then(() => (init_claim_push_bg(), claim_push_bg_exports));
10275
+ unpushedClaims = await shouldNudgeUnpushed2();
10276
+ } catch {
10277
+ }
10221
10278
  const cacheEntry = {
10222
10279
  index,
10223
10280
  matchCount,
@@ -10226,6 +10283,7 @@ async function run() {
10226
10283
  incomingPending,
10227
10284
  unreadChat,
10228
10285
  sessionStale,
10286
+ unpushedClaims,
10229
10287
  // In-process-only despite being persisted: the render pass receives it as
10230
10288
  // a function argument below. A future cache READER must not gate on this
10231
10289
  // field without re-checking isContributeEnabled/isContributePrompted at
@@ -10264,6 +10322,7 @@ async function run() {
10264
10322
  incomingPending,
10265
10323
  sessionStale,
10266
10324
  contributeNudge,
10325
+ unpushedClaims,
10267
10326
  baseUrl: API_URL2,
10268
10327
  seenHistory,
10269
10328
  widen,
@@ -249,6 +249,9 @@ function buildContributeNudgeLine(contributeNudge) {
249
249
  function buildSessionStaleLine(sessionStale) {
250
250
  return sessionStale === true ? "\u26A0 terminalhire: linked session expired \u2014 run: terminalhire login" : null;
251
251
  }
252
+ function buildUnpushedClaimsLine(unpushedClaims) {
253
+ return unpushedClaims === true ? "\u26A0 new claims not yet on your dashboard \u2014 run: terminalhire claim --push --keep-updated" : null;
254
+ }
252
255
  function buildSpinnerPool(topMatches, max = 6, opts = {}) {
253
256
  const {
254
257
  sessionTags,
@@ -257,16 +260,19 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
257
260
  incomingPending,
258
261
  sessionStale,
259
262
  contributeNudge,
263
+ unpushedClaims,
260
264
  seenHistory
261
265
  } = opts;
262
266
  const staleLine = buildSessionStaleLine(sessionStale);
263
267
  const withStale = (pool2) => staleLine ? [staleLine, ...pool2] : pool2;
264
268
  const introLine = buildIncomingIntroLine(incomingPending);
265
269
  const contributeLine = buildContributeNudgeLine(contributeNudge);
270
+ const unpushedLine = buildUnpushedClaimsLine(unpushedClaims);
266
271
  const ranked = filterFreshMatches(rankBySessionTags(topMatches, sessionTags), seenHistory);
267
272
  if (!Array.isArray(ranked) || ranked.length === 0) {
268
273
  if (introLine) return withStale([introLine]);
269
274
  if (contributeLine) return withStale([contributeLine]);
275
+ if (unpushedLine) return withStale([unpushedLine]);
270
276
  const peerLine = buildPeerLine(topPeers);
271
277
  return withStale(peerLine ? [peerLine] : []);
272
278
  }
@@ -276,6 +282,7 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
276
282
  const pool = [...headers.slice(0, cap), ctaVerb()];
277
283
  if (introLine) pool.push(introLine);
278
284
  if (contributeLine) pool.push(contributeLine);
285
+ if (unpushedLine) pool.push(unpushedLine);
279
286
  return withStale(pool);
280
287
  }
281
288
 
@@ -346,6 +346,9 @@ function buildContributeNudgeLine(contributeNudge) {
346
346
  function buildSessionStaleLine(sessionStale) {
347
347
  return sessionStale === true ? "\u26A0 terminalhire: linked session expired \u2014 run: terminalhire login" : null;
348
348
  }
349
+ function buildUnpushedClaimsLine(unpushedClaims) {
350
+ return unpushedClaims === true ? "\u26A0 new claims not yet on your dashboard \u2014 run: terminalhire claim --push --keep-updated" : null;
351
+ }
349
352
  function buildSpinnerPool(topMatches, max = 6, opts = {}) {
350
353
  const {
351
354
  sessionTags,
@@ -354,16 +357,19 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
354
357
  incomingPending,
355
358
  sessionStale,
356
359
  contributeNudge,
360
+ unpushedClaims,
357
361
  seenHistory
358
362
  } = opts;
359
363
  const staleLine = buildSessionStaleLine(sessionStale);
360
364
  const withStale = (pool2) => staleLine ? [staleLine, ...pool2] : pool2;
361
365
  const introLine = buildIncomingIntroLine(incomingPending);
362
366
  const contributeLine = buildContributeNudgeLine(contributeNudge);
367
+ const unpushedLine = buildUnpushedClaimsLine(unpushedClaims);
363
368
  const ranked = filterFreshMatches(rankBySessionTags(topMatches, sessionTags), seenHistory);
364
369
  if (!Array.isArray(ranked) || ranked.length === 0) {
365
370
  if (introLine) return withStale([introLine]);
366
371
  if (contributeLine) return withStale([contributeLine]);
372
+ if (unpushedLine) return withStale([unpushedLine]);
367
373
  const peerLine = buildPeerLine(topPeers);
368
374
  return withStale(peerLine ? [peerLine] : []);
369
375
  }
@@ -373,6 +379,7 @@ function buildSpinnerPool(topMatches, max = 6, opts = {}) {
373
379
  const pool = [...headers.slice(0, cap), ctaVerb()];
374
380
  if (introLine) pool.push(introLine);
375
381
  if (contributeLine) pool.push(contributeLine);
382
+ if (unpushedLine) pool.push(unpushedLine);
376
383
  return withStale(pool);
377
384
  }
378
385
 
@@ -1057,6 +1064,7 @@ function renderRefreshSurface(topMatches, sc, opts = {}) {
1057
1064
  incomingPending: opts.incomingPending,
1058
1065
  sessionStale: opts.sessionStale,
1059
1066
  contributeNudge: opts.contributeNudge,
1067
+ unpushedClaims: opts.unpushedClaims,
1060
1068
  seenHistory
1061
1069
  });
1062
1070
  if (verbs.length > 0) applySpinnerVerbs(verbs, sc.mode);
@@ -1083,6 +1091,7 @@ export {
1083
1091
  buildSpinnerPool,
1084
1092
  buildTips,
1085
1093
  buildTipsDetailed,
1094
+ buildUnpushedClaimsLine,
1086
1095
  clearSpinnerTips,
1087
1096
  clearSpinnerVerbs,
1088
1097
  ctaVerb,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminalhire",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",