terminalhire 0.5.0 → 0.7.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.
@@ -680,6 +680,52 @@ function githubToFingerprint(p) {
680
680
  const seniorityBand = inferSeniority(p);
681
681
  return { skillTags, seniorityBand };
682
682
  }
683
+ async function fetchOwnedRepoTraction(login, token) {
684
+ const computedAt = (/* @__PURE__ */ new Date()).toISOString();
685
+ const empty = (status) => ({
686
+ status,
687
+ totalStars: 0,
688
+ totalForks: 0,
689
+ reposWithStars: 0,
690
+ top: [],
691
+ computedAt
692
+ });
693
+ let repos;
694
+ try {
695
+ repos = await ghFetch(
696
+ `/users/${login}/repos?sort=pushed&per_page=100`,
697
+ token
698
+ );
699
+ } catch (err) {
700
+ const msg = err instanceof Error ? err.message : String(err);
701
+ const status = /HTTP 403|HTTP 429|rate limit/i.test(msg) ? "rate-limited" : "failed";
702
+ console.warn(`[github] ${login}: traction fetch failed (${status}) \u2014`, msg);
703
+ return empty(status);
704
+ }
705
+ if (!Array.isArray(repos)) return empty("failed");
706
+ const owned = repos.filter((r) => r && !r.fork && !r.archived);
707
+ let totalStars = 0;
708
+ let totalForks = 0;
709
+ let reposWithStars = 0;
710
+ const ranked = [];
711
+ for (const r of owned) {
712
+ const stars = typeof r.stargazers_count === "number" ? r.stargazers_count : 0;
713
+ const forks = typeof r.forks_count === "number" ? r.forks_count : 0;
714
+ totalStars += stars;
715
+ totalForks += forks;
716
+ if (stars >= 1) reposWithStars++;
717
+ ranked.push({ name: r.name, stars, forks });
718
+ }
719
+ ranked.sort((a, b) => b.stars - a.stars || b.forks - a.forks);
720
+ return {
721
+ status: "ok",
722
+ totalStars,
723
+ totalForks,
724
+ reposWithStars,
725
+ top: ranked.slice(0, TRACTION_TOP_N),
726
+ computedAt
727
+ };
728
+ }
683
729
  async function ghFetchRaw(path, token) {
684
730
  return fetch(`https://api.github.com${path}`, { headers: ghHeaders(token) });
685
731
  }
@@ -888,11 +934,12 @@ function deriveResumeTrend(cred, repoRecency, now = Date.now()) {
888
934
  }
889
935
  return scored.sort((a, b) => b.weight - a.weight).slice(0, 12).map((s) => s.t);
890
936
  }
891
- var MIN_STARS, MIN_CONTRIBUTORS, CANDIDATE_PR_PAGE, TRIVIAL_PR_TITLE, RESUME_DECAY_HALF_LIFE_MS, RESUME_MIN_SCORE;
937
+ var TRACTION_TOP_N, MIN_STARS, MIN_CONTRIBUTORS, CANDIDATE_PR_PAGE, TRIVIAL_PR_TITLE, RESUME_DECAY_HALF_LIFE_MS, RESUME_MIN_SCORE;
892
938
  var init_github = __esm({
893
939
  "../../packages/core/src/github.ts"() {
894
940
  "use strict";
895
941
  init_vocabulary();
942
+ TRACTION_TOP_N = 6;
896
943
  MIN_STARS = 50;
897
944
  MIN_CONTRIBUTORS = 10;
898
945
  CANDIDATE_PR_PAGE = 50;
@@ -2221,6 +2268,51 @@ var init_workable = __esm({
2221
2268
  }
2222
2269
  });
2223
2270
 
2271
+ // ../../packages/core/src/feeds/directory.ts
2272
+ function personCardToJob(row) {
2273
+ const tags = [...row.skill_tags];
2274
+ return {
2275
+ id: `dev:${row.login}`,
2276
+ source: "person",
2277
+ title: row.name ?? row.login,
2278
+ company: row.login,
2279
+ url: `/r/${row.login}`,
2280
+ remote: true,
2281
+ tags,
2282
+ coreTags: tags.slice(0, TOP_CORE_TAGS),
2283
+ roleType: "full_time",
2284
+ applyMode: "direct"
2285
+ };
2286
+ }
2287
+ function buildDirectoryIndex(people, opts) {
2288
+ return {
2289
+ builtAt: opts?.builtAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2290
+ cards: people.map(personCardToJob)
2291
+ };
2292
+ }
2293
+ function projectCardToJob(row) {
2294
+ const tags = [...row.needed_skills];
2295
+ return {
2296
+ id: `proj:${row.id}`,
2297
+ source: "project",
2298
+ title: row.title,
2299
+ company: row.owner_login,
2300
+ url: `/r/${row.owner_login}`,
2301
+ remote: true,
2302
+ tags,
2303
+ coreTags: tags.slice(0, TOP_CORE_TAGS),
2304
+ roleType: "full_time",
2305
+ applyMode: "direct"
2306
+ };
2307
+ }
2308
+ var TOP_CORE_TAGS;
2309
+ var init_directory = __esm({
2310
+ "../../packages/core/src/feeds/directory.ts"() {
2311
+ "use strict";
2312
+ TOP_CORE_TAGS = 4;
2313
+ }
2314
+ });
2315
+
2224
2316
  // ../../packages/core/src/feeds/index.ts
2225
2317
  async function aggregateBounties(opts) {
2226
2318
  const [gh, op] = await Promise.all([
@@ -2337,6 +2429,7 @@ var init_feeds = __esm({
2337
2429
  init_github_bounties();
2338
2430
  init_opire();
2339
2431
  init_workable();
2432
+ init_directory();
2340
2433
  init_bounty_gate();
2341
2434
  init_bounty_gate();
2342
2435
  FEEDS = [greenhouse, ashby, lever, workable, himalayas, wwr, hn];
@@ -2537,6 +2630,176 @@ var init_indexer = __esm({
2537
2630
  }
2538
2631
  });
2539
2632
 
2633
+ // ../../packages/core/src/intro.ts
2634
+ function buildIntroPayload(input) {
2635
+ const payload = {
2636
+ requesterLogin: input.requesterLogin,
2637
+ requesterDisplayName: input.requesterDisplayName,
2638
+ requesterContact: input.requesterContact,
2639
+ targetLogin: input.targetLogin
2640
+ };
2641
+ const note = input.note?.trim();
2642
+ if (note) payload.note = note;
2643
+ return payload;
2644
+ }
2645
+ function rejectExtraIntroFields(body) {
2646
+ for (const key of Object.keys(body)) {
2647
+ if (!INTRO_ALLOWED_SET.has(key)) {
2648
+ return `intro payload contains disallowed field: "${key}"`;
2649
+ }
2650
+ }
2651
+ return null;
2652
+ }
2653
+ function validateIntroPayload(body) {
2654
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
2655
+ return { ok: false, reason: "intro payload must be a JSON object" };
2656
+ }
2657
+ const b = body;
2658
+ const extra = rejectExtraIntroFields(b);
2659
+ if (extra) return { ok: false, reason: extra };
2660
+ const ok = (v, max) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
2661
+ if (!ok(b.requesterLogin, MAX_SHORT)) return { ok: false, reason: "requesterLogin is required" };
2662
+ if (!ok(b.requesterDisplayName, MAX_SHORT)) return { ok: false, reason: "requesterDisplayName is required" };
2663
+ if (!ok(b.requesterContact, MAX_SHORT)) return { ok: false, reason: "requesterContact is required" };
2664
+ if (!ok(b.targetLogin, MAX_SHORT)) return { ok: false, reason: "targetLogin is required" };
2665
+ if (b.note !== void 0 && (typeof b.note !== "string" || b.note.length > MAX_NOTE)) {
2666
+ return { ok: false, reason: "note must be a string of at most 500 chars" };
2667
+ }
2668
+ const value = {
2669
+ requesterLogin: b.requesterLogin.trim(),
2670
+ requesterDisplayName: b.requesterDisplayName.trim(),
2671
+ requesterContact: b.requesterContact.trim(),
2672
+ targetLogin: b.targetLogin.trim()
2673
+ };
2674
+ const note = typeof b.note === "string" ? b.note.trim() : "";
2675
+ if (note) value.note = note;
2676
+ return { ok: true, value };
2677
+ }
2678
+ function introRateLimitCheck(history, now, opts) {
2679
+ const cutoff = now - opts.windowMs;
2680
+ const recent = history.filter((t) => t > cutoff);
2681
+ if (recent.length >= opts.max) {
2682
+ const oldest = recent[0] ?? now;
2683
+ return { allowed: false, retained: recent, retryAfterMs: Math.max(0, oldest + opts.windowMs - now) };
2684
+ }
2685
+ return { allowed: true, retained: [...recent, now], retryAfterMs: 0 };
2686
+ }
2687
+ function isOverIntroLimit(recentCount, max) {
2688
+ return recentCount >= max;
2689
+ }
2690
+ function composeIntroEmail(args2) {
2691
+ const subject = `New intro request from @${args2.requesterLogin} \xB7 terminalhire`;
2692
+ const text = `@${args2.requesterLogin} wants an intro to you on terminalhire.
2693
+
2694
+ Sign in to view the request and choose whether to share your contact back:
2695
+ ${args2.dashboardUrl}
2696
+
2697
+ You control whether this connects \u2014 no contact details are shared unless you accept.
2698
+
2699
+ \u2014 Terminalhire`;
2700
+ return { subject, text };
2701
+ }
2702
+ function introActorRole(intro, actorLogin) {
2703
+ const a = actorLogin.trim().toLowerCase();
2704
+ if (a && a === intro.targetLogin.trim().toLowerCase()) return "target";
2705
+ if (a && a === intro.requesterLogin.trim().toLowerCase()) return "requester";
2706
+ return "other";
2707
+ }
2708
+ function authorizeIntroDecision(intro, actorLogin) {
2709
+ const role = introActorRole(intro, actorLogin);
2710
+ if (role === "target") return { ok: true };
2711
+ if (role === "requester") {
2712
+ return { ok: false, status: 403, reason: "the requester cannot accept or decline their own intro request" };
2713
+ }
2714
+ return { ok: false, status: 404, reason: "intro not found" };
2715
+ }
2716
+ function authorizeIntroDeletion(intro, actorLogin) {
2717
+ const role = introActorRole(intro, actorLogin);
2718
+ if (role === "other") return { ok: false, status: 404, reason: "intro not found" };
2719
+ return { ok: true };
2720
+ }
2721
+ function revealIntroContacts(intro) {
2722
+ if (intro.status !== "accepted") return { toRequester: null, toTarget: null };
2723
+ return { toRequester: intro.targetContact ?? null, toTarget: intro.requesterContact };
2724
+ }
2725
+ function validateTargetContact(v) {
2726
+ if (typeof v !== "string" || v.trim().length === 0) return { ok: false, reason: "targetContact is required" };
2727
+ if (v.length > MAX_SHORT) return { ok: false, reason: `targetContact must be at most ${MAX_SHORT} chars` };
2728
+ return { ok: true, value: v.trim() };
2729
+ }
2730
+ function buildIntroListItem(intro, viewerLogin) {
2731
+ const role = introActorRole(intro, viewerLogin);
2732
+ if (role === "other") return null;
2733
+ const reveal = revealIntroContacts(intro);
2734
+ if (role === "target") {
2735
+ return {
2736
+ id: intro.id,
2737
+ role: "incoming",
2738
+ counterpartyLogin: intro.requesterLogin,
2739
+ status: intro.status,
2740
+ note: intro.note ?? null,
2741
+ contact: reveal.toTarget
2742
+ };
2743
+ }
2744
+ return {
2745
+ id: intro.id,
2746
+ role: "outgoing",
2747
+ counterpartyLogin: intro.targetLogin,
2748
+ status: intro.status,
2749
+ note: intro.note ?? null,
2750
+ contact: reveal.toRequester
2751
+ };
2752
+ }
2753
+ function composeIntroAcceptedEmail(args2) {
2754
+ const subject = `Intro connected with @${args2.counterpartyLogin} \xB7 terminalhire`;
2755
+ const lead = args2.recipientRole === "requester" ? `@${args2.counterpartyLogin} accepted your intro request on terminalhire.` : `You accepted @${args2.counterpartyLogin}'s intro request on terminalhire.`;
2756
+ const text = `${lead}
2757
+
2758
+ You can now reach them directly:
2759
+ @${args2.counterpartyLogin} \u2014 ${args2.counterpartyContact}
2760
+
2761
+ Take it from here.
2762
+
2763
+ \u2014 Terminalhire`;
2764
+ return { subject, text };
2765
+ }
2766
+ function introRetentionAction(row, now) {
2767
+ if (row.status === "pending") {
2768
+ const created = Date.parse(row.createdAt);
2769
+ if (Number.isFinite(created) && now - created > INTRO_PENDING_TTL_MS) return "purge";
2770
+ return "keep";
2771
+ }
2772
+ if (row.status === "declined") {
2773
+ return row.hasContact ? "scrub-declined" : "keep";
2774
+ }
2775
+ if (row.status === "accepted") {
2776
+ const updated = Date.parse(row.updatedAt);
2777
+ if (row.hasContact && Number.isFinite(updated) && now - updated > INTRO_ACCEPTED_TTL_MS) {
2778
+ return "expire-accepted";
2779
+ }
2780
+ return "keep";
2781
+ }
2782
+ return "keep";
2783
+ }
2784
+ var INTRO_ALLOWED_FIELDS, INTRO_ALLOWED_SET, MAX_SHORT, MAX_NOTE, INTRO_PENDING_TTL_MS, INTRO_ACCEPTED_TTL_MS;
2785
+ var init_intro = __esm({
2786
+ "../../packages/core/src/intro.ts"() {
2787
+ "use strict";
2788
+ INTRO_ALLOWED_FIELDS = [
2789
+ "requesterLogin",
2790
+ "requesterDisplayName",
2791
+ "requesterContact",
2792
+ "note",
2793
+ "targetLogin"
2794
+ ];
2795
+ INTRO_ALLOWED_SET = new Set(INTRO_ALLOWED_FIELDS);
2796
+ MAX_SHORT = 200;
2797
+ MAX_NOTE = 500;
2798
+ INTRO_PENDING_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2799
+ INTRO_ACCEPTED_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
2800
+ }
2801
+ });
2802
+
2540
2803
  // ../../packages/core/src/index.ts
2541
2804
  var src_exports = {};
2542
2805
  __export(src_exports, {
@@ -2552,6 +2815,9 @@ __export(src_exports, {
2552
2815
  GRAPH: () => GRAPH,
2553
2816
  GREENHOUSE_SLUGS_BY_TIER: () => GREENHOUSE_SLUGS_BY_TIER,
2554
2817
  IDF_BACKGROUND: () => IDF_BACKGROUND,
2818
+ INTRO_ACCEPTED_TTL_MS: () => INTRO_ACCEPTED_TTL_MS,
2819
+ INTRO_ALLOWED_FIELDS: () => INTRO_ALLOWED_FIELDS,
2820
+ INTRO_PENDING_TTL_MS: () => INTRO_PENDING_TTL_MS,
2555
2821
  LEVER_SLUGS_BY_TIER: () => LEVER_SLUGS_BY_TIER,
2556
2822
  SYNONYMS: () => SYNONYMS,
2557
2823
  VOCABULARY: () => VOCABULARY,
@@ -2560,10 +2826,17 @@ __export(src_exports, {
2560
2826
  aggregate: () => aggregate,
2561
2827
  aggregateBounties: () => aggregateBounties,
2562
2828
  ashby: () => ashby,
2829
+ authorizeIntroDecision: () => authorizeIntroDecision,
2830
+ authorizeIntroDeletion: () => authorizeIntroDeletion,
2563
2831
  bestAcceptanceDomain: () => bestAcceptanceDomain,
2832
+ buildDirectoryIndex: () => buildDirectoryIndex,
2564
2833
  buildGraph: () => buildGraph,
2565
2834
  buildIndex: () => buildIndex,
2835
+ buildIntroListItem: () => buildIntroListItem,
2836
+ buildIntroPayload: () => buildIntroPayload,
2566
2837
  buildReason: () => buildReason,
2838
+ composeIntroAcceptedEmail: () => composeIntroAcceptedEmail,
2839
+ composeIntroEmail: () => composeIntroEmail,
2567
2840
  computeAcceptanceCredential: () => computeAcceptanceCredential,
2568
2841
  computeAcceptanceCredentialPublic: () => computeAcceptanceCredentialPublic,
2569
2842
  coreTagsFromTitle: () => coreTagsFromTitle,
@@ -2571,6 +2844,7 @@ __export(src_exports, {
2571
2844
  expandWeighted: () => expandWeighted,
2572
2845
  extractSkillTags: () => extractSkillTags,
2573
2846
  fetchGitHubProfile: () => fetchGitHubProfile,
2847
+ fetchOwnedRepoTraction: () => fetchOwnedRepoTraction,
2574
2848
  fetchRepoRecency: () => fetchRepoRecency,
2575
2849
  flattenTiers: () => flattenTiers,
2576
2850
  getBuyer: () => getBuyer,
@@ -2579,7 +2853,11 @@ __export(src_exports, {
2579
2853
  greenhouse: () => greenhouse,
2580
2854
  himalayas: () => himalayas,
2581
2855
  hn: () => hn,
2856
+ introActorRole: () => introActorRole,
2857
+ introRateLimitCheck: () => introRateLimitCheck,
2858
+ introRetentionAction: () => introRetentionAction,
2582
2859
  isBounty: () => isBounty,
2860
+ isOverIntroLimit: () => isOverIntroLimit,
2583
2861
  lever: () => lever,
2584
2862
  loadPartnerRoles: () => loadPartnerRoles,
2585
2863
  looksLikeEngRole: () => looksLikeEngRole,
@@ -2587,8 +2865,14 @@ __export(src_exports, {
2587
2865
  normalize: () => normalize,
2588
2866
  opire: () => opire,
2589
2867
  passesMaturityGate: () => passesMaturityGate,
2868
+ personCardToJob: () => personCardToJob,
2869
+ projectCardToJob: () => projectCardToJob,
2870
+ rejectExtraIntroFields: () => rejectExtraIntroFields,
2871
+ revealIntroContacts: () => revealIntroContacts,
2590
2872
  tokenize: () => tokenize,
2591
2873
  validateGraph: () => validateGraph,
2874
+ validateIntroPayload: () => validateIntroPayload,
2875
+ validateTargetContact: () => validateTargetContact,
2592
2876
  workable: () => workable,
2593
2877
  wwr: () => wwr
2594
2878
  });
@@ -2602,6 +2886,7 @@ var init_src = __esm({
2602
2886
  init_indexer();
2603
2887
  init_partners();
2604
2888
  init_github();
2889
+ init_intro();
2605
2890
  }
2606
2891
  });
2607
2892
 
@@ -106,10 +106,56 @@ var init_claims = __esm({
106
106
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
107
107
  import { join as join2 } from "path";
108
108
  import { homedir as homedir2 } from "os";
109
+ import { execFile } from "child_process";
110
+ import { promisify } from "util";
111
+ import { createInterface } from "readline";
109
112
  var TERMINALHIRE_DIR2 = join2(homedir2(), ".terminalhire");
110
113
  var INDEX_CACHE_FILE = join2(TERMINALHIRE_DIR2, "index-cache.json");
111
114
  var GH_API = "https://api.github.com";
112
115
  var GH_HEADERS = { "User-Agent": "terminalhire-claim", Accept: "application/vnd.github+json" };
116
+ var pExecFile = promisify(execFile);
117
+ async function sh(cmd, args, opts = {}) {
118
+ const { stdout } = await pExecFile(cmd, args, { ...opts, shell: false, maxBuffer: 16 * 1024 * 1024 });
119
+ return String(stdout).trim();
120
+ }
121
+ async function confirm(question) {
122
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
123
+ try {
124
+ const ans = await new Promise((resolve) => rl.question(question, resolve));
125
+ return /^y(es)?$/i.test(String(ans).trim());
126
+ } finally {
127
+ rl.close();
128
+ }
129
+ }
130
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["worktree", "branch"]);
131
+ function parseArgs(argv) {
132
+ const flags = {};
133
+ const positional = [];
134
+ for (let i = 0; i < argv.length; i++) {
135
+ const a = argv[i];
136
+ if (a.startsWith("--")) {
137
+ const key = a.slice(2);
138
+ if (VALUE_FLAGS.has(key)) {
139
+ const val = argv[i + 1];
140
+ if (val === void 0 || val.startsWith("--")) {
141
+ console.error(`terminalhire claim: --${key} requires a value.`);
142
+ process.exit(1);
143
+ }
144
+ flags[key] = val;
145
+ i++;
146
+ } else {
147
+ flags[key] = true;
148
+ }
149
+ } else {
150
+ positional.push(a);
151
+ }
152
+ }
153
+ return { flags, positional };
154
+ }
155
+ function parseRepoFromRemote(url) {
156
+ const m = String(url ?? "").trim().match(/github\.com[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
157
+ return m ? `${m[1]}/${m[2]}` : null;
158
+ }
113
159
  function findBountyInCache(bountyId) {
114
160
  try {
115
161
  if (!existsSync2(INDEX_CACHE_FILE)) return null;
@@ -264,7 +310,10 @@ async function cmdRecord(arg) {
264
310
  console.log(" \u2022 MUST NOT `git push` or `gh pr` \u2014 pushing happens only via `terminalhire submit`");
265
311
  console.log(" \u2022 clone + static analysis + patch only; NO test/build execution without explicit approval");
266
312
  console.log(" \u2022 no access to ~/.terminalhire (the executor never needs your profile)");
267
- console.log("\n Next: do the work, then `terminalhire claim update " + claim.id + " <state>` as you progress.");
313
+ console.log("\n Next:");
314
+ console.log(" 1. record the worktree: terminalhire claim attach " + claim.id + " --worktree <path> --branch <branch>");
315
+ console.log(" 2. do the work + review, then mark it cleared: terminalhire claim update " + claim.id + " ready");
316
+ console.log(" 3. publish (pushes to your fork + opens the PR): terminalhire claim submit " + claim.id);
268
317
  }
269
318
  async function cmdPreview(arg, { json } = {}) {
270
319
  if (!arg) {
@@ -390,33 +439,223 @@ async function cmdRelease(id) {
390
439
  console.log(removed ? `Released claim: ${id}` : `terminalhire claim: no claim with id '${id}'.`);
391
440
  if (!removed) process.exit(1);
392
441
  }
442
+ async function cmdAttach(id, worktree, branch) {
443
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
444
+ if (!id || !worktree || !branch) {
445
+ console.error("Usage: terminalhire claim attach <id> --worktree <path> --branch <branchName>");
446
+ console.error(" Records the worktree + branch so `terminalhire claim submit` can verify identity before pushing.");
447
+ process.exit(1);
448
+ }
449
+ if (!claims.findClaim(id)) {
450
+ console.error(`terminalhire claim: no claim with id '${id}'.`);
451
+ process.exit(1);
452
+ }
453
+ let toplevel;
454
+ try {
455
+ toplevel = await sh("git", ["-C", worktree, "rev-parse", "--show-toplevel"]);
456
+ } catch {
457
+ console.error(`terminalhire claim: '${worktree}' is not a git work tree.`);
458
+ process.exit(1);
459
+ }
460
+ claims.updateClaim(id, { worktreePath: toplevel, branch });
461
+ console.log(`Attached ${id}: worktree=${toplevel} branch=${branch}`);
462
+ }
463
+ async function cmdSubmit(id, worktreeOverride) {
464
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
465
+ if (!id) {
466
+ console.error("Usage: terminalhire claim submit <id> [--worktree <path>]");
467
+ process.exit(1);
468
+ }
469
+ const claim = claims.findClaim(id);
470
+ if (!claim) {
471
+ console.error(`terminalhire claim: no claim with id '${id}'.`);
472
+ process.exit(1);
473
+ }
474
+ if (claim.state !== "ready") {
475
+ console.error(
476
+ `terminalhire claim: ${id} is '${claim.state}', not 'ready'. Submit only runs after the review gate clears it:
477
+ terminalhire claim update ${id} ready`
478
+ );
479
+ process.exit(1);
480
+ }
481
+ if (claim.review && claim.review.verdict === "revise") {
482
+ console.error(`terminalhire claim: ${id} review verdict is 'revise' \u2014 the gate said do not submit. Resolve blockers and re-review first.`);
483
+ process.exit(1);
484
+ }
485
+ if (!claim.worktreePath || !claim.branch) {
486
+ console.error(
487
+ `terminalhire claim: ${id} has no recorded worktree/branch \u2014 cannot verify what to push. Run:
488
+ terminalhire claim attach ${id} --worktree <path> --branch <branch>`
489
+ );
490
+ process.exit(1);
491
+ }
492
+ const inspectDir = worktreeOverride || process.cwd();
493
+ let toplevel;
494
+ try {
495
+ toplevel = await sh("git", ["-C", inspectDir, "rev-parse", "--show-toplevel"]);
496
+ } catch {
497
+ console.error(
498
+ `terminalhire claim: '${inspectDir}' is not a git work tree.
499
+ Run submit from inside the claim's worktree (or pass --worktree <path>).`
500
+ );
501
+ process.exit(1);
502
+ }
503
+ if (toplevel !== claim.worktreePath) {
504
+ console.error(
505
+ `terminalhire claim: worktree mismatch \u2014 refusing to push.
506
+ expected: ${claim.worktreePath}
507
+ found: ${toplevel}
508
+ Run submit from inside the claim's worktree (or pass --worktree <path>).`
509
+ );
510
+ process.exit(1);
511
+ }
512
+ const wt = toplevel;
513
+ const curBranch = await sh("git", ["-C", wt, "rev-parse", "--abbrev-ref", "HEAD"]);
514
+ if (curBranch !== claim.branch) {
515
+ console.error(
516
+ `terminalhire claim: branch mismatch \u2014 refusing to push.
517
+ expected: ${claim.branch}
518
+ found: ${curBranch}
519
+ Check out the claim's branch first.`
520
+ );
521
+ process.exit(1);
522
+ }
523
+ let defaultBranch = null;
524
+ try {
525
+ defaultBranch = (await sh("git", ["-C", wt, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).replace(/^origin\//, "");
526
+ } catch {
527
+ }
528
+ if (defaultBranch && claim.branch === defaultBranch) {
529
+ console.error(`terminalhire claim: '${claim.branch}' is the default branch \u2014 open the PR from a feature branch.`);
530
+ process.exit(1);
531
+ }
532
+ if (defaultBranch) {
533
+ let ahead = "0";
534
+ try {
535
+ ahead = await sh("git", ["-C", wt, "rev-list", "--count", `origin/${defaultBranch}..HEAD`]);
536
+ } catch {
537
+ ahead = "1";
538
+ }
539
+ if (ahead === "0") {
540
+ console.error(`terminalhire claim: branch has no commits ahead of origin/${defaultBranch} \u2014 nothing to submit.`);
541
+ process.exit(1);
542
+ }
543
+ }
544
+ const dirty = await sh("git", ["-C", wt, "status", "--porcelain"]);
545
+ if (dirty) {
546
+ console.error("terminalhire claim: working tree is not clean \u2014 commit or stash before submitting (submit pushes what was reviewed).");
547
+ process.exit(1);
548
+ }
549
+ let originUrl;
550
+ try {
551
+ originUrl = await sh("git", ["-C", wt, "remote", "get-url", "origin"]);
552
+ } catch {
553
+ console.error("terminalhire claim: no 'origin' remote in the worktree.");
554
+ process.exit(1);
555
+ }
556
+ const originRepo = parseRepoFromRemote(originUrl);
557
+ if (!originRepo) {
558
+ console.error(`terminalhire claim: could not parse owner/repo from origin (${originUrl}).`);
559
+ process.exit(1);
560
+ }
561
+ if (originRepo.toLowerCase() === claim.repoFullName.toLowerCase()) {
562
+ console.error(
563
+ `terminalhire claim: origin points at the UPSTREAM bounty repo (${claim.repoFullName}), not a fork.
564
+ Pushing would create a branch directly on the target repo. Fork first:
565
+ gh repo fork ${claim.repoFullName} --clone=false
566
+ set your fork as 'origin' (or push it there), then retry.`
567
+ );
568
+ process.exit(1);
569
+ }
570
+ let ghUser;
571
+ try {
572
+ ghUser = await sh("gh", ["api", "user", "-q", ".login"]);
573
+ } catch {
574
+ console.error("terminalhire claim: 'gh' CLI not available or not authenticated. Run 'gh auth login'.");
575
+ process.exit(1);
576
+ }
577
+ const upstream = claim.repoFullName;
578
+ const head = `${ghUser}:${claim.branch}`;
579
+ console.log(`
580
+ SUBMIT \xB7 ${claim.title}`);
581
+ console.log(` upstream: ${upstream}`);
582
+ console.log(` fork: ${originRepo}`);
583
+ console.log(` branch: ${claim.branch}`);
584
+ console.log(` head: ${head}`);
585
+ console.log(` issue: ${claim.issueUrl}`);
586
+ const ok = await confirm(`
587
+ Push '${claim.branch}' to ${originRepo} and open a PR against ${upstream}? (y/N) `);
588
+ if (!ok) {
589
+ console.log("Aborted \u2014 nothing pushed.");
590
+ return;
591
+ }
592
+ try {
593
+ await sh("git", ["-C", wt, "push", "origin", claim.branch]);
594
+ } catch (err) {
595
+ console.error(`terminalhire claim: git push failed (NOT force-pushed). ${err.stderr || err.message || err}`);
596
+ console.error(` Resolve and retry, or open the PR manually then: terminalhire claim update ${id} submitted <prUrl>`);
597
+ process.exit(1);
598
+ }
599
+ let prUrl = null;
600
+ try {
601
+ const existing = await sh("gh", ["pr", "list", "--repo", upstream, "--head", head, "--state", "open", "--json", "url", "-q", ".[0].url // empty"]);
602
+ if (existing) prUrl = existing;
603
+ } catch {
604
+ }
605
+ if (!prUrl) {
606
+ const issueNum = (parseGitHubUrl(claim.issueUrl) || {}).number;
607
+ const body = issueNum ? `Closes #${issueNum}` : "";
608
+ try {
609
+ const out = await sh("gh", ["pr", "create", "--repo", upstream, "--head", head, "--title", claim.title, "--body", body]);
610
+ prUrl = out.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("https://github.com/")).pop() || null;
611
+ } catch (err) {
612
+ console.error(`terminalhire claim: branch pushed, but 'gh pr create' failed. ${err.stderr || err.message || err}`);
613
+ console.error(` Open the PR manually (gh pr create / web UI), then: terminalhire claim update ${id} submitted <prUrl>`);
614
+ process.exit(1);
615
+ }
616
+ }
617
+ if (!prUrl || !parseGitHubUrl(prUrl)) {
618
+ console.error(`terminalhire claim: could not determine the PR URL. Set it manually: terminalhire claim update ${id} submitted <prUrl>`);
619
+ process.exit(1);
620
+ }
621
+ claims.updateClaim(id, { state: "submitted", prUrl });
622
+ console.log(`
623
+ \u2713 Submitted ${id} \u2192 ${prUrl}`);
624
+ console.log(` Run 'terminalhire claim status ${id}' after the maintainer acts to fold the merge into your accepted-PR rate.`);
625
+ }
393
626
  async function run() {
394
627
  const verb = process.argv[2];
395
- const rest = process.argv.slice(3).filter((a) => !a.startsWith("--"));
396
- const active = process.argv.includes("--active");
397
- const json = process.argv.includes("--json");
628
+ const { flags, positional } = parseArgs(process.argv.slice(3));
629
+ const active = Boolean(flags.active);
630
+ const json = Boolean(flags.json);
398
631
  try {
399
632
  switch (verb) {
400
633
  case "preview":
401
- await cmdPreview(rest[0], { json });
634
+ await cmdPreview(positional[0], { json });
402
635
  break;
403
636
  case "record":
404
- await cmdRecord(rest[0]);
637
+ await cmdRecord(positional[0]);
405
638
  break;
406
639
  case "list":
407
640
  await cmdList(active);
408
641
  break;
409
642
  case "status":
410
- await cmdStatus(rest[0]);
643
+ await cmdStatus(positional[0]);
411
644
  break;
412
645
  case "update":
413
- await cmdUpdate(rest[0], rest[1], rest[2]);
646
+ await cmdUpdate(positional[0], positional[1], positional[2]);
647
+ break;
648
+ case "attach":
649
+ await cmdAttach(positional[0], flags.worktree, flags.branch);
650
+ break;
651
+ case "submit":
652
+ await cmdSubmit(positional[0], flags.worktree);
414
653
  break;
415
654
  case "release":
416
- await cmdRelease(rest[0]);
655
+ await cmdRelease(positional[0]);
417
656
  break;
418
657
  default:
419
- console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: preview | record | list | status | update | release`);
658
+ console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: preview | record | attach | list | status | update | submit | release`);
420
659
  process.exit(1);
421
660
  }
422
661
  } catch (err) {