terminalhire 0.4.0 → 0.4.4

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.
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/claims.ts
13
+ var claims_exports = {};
14
+ __export(claims_exports, {
15
+ acceptedPRRate: () => acceptedPRRate,
16
+ findClaim: () => findClaim,
17
+ listClaims: () => listClaims,
18
+ readClaims: () => readClaims,
19
+ recordClaim: () => recordClaim,
20
+ removeClaim: () => removeClaim,
21
+ updateClaim: () => updateClaim
22
+ });
23
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "fs";
24
+ import { join } from "path";
25
+ import { homedir } from "os";
26
+ function nowISO() {
27
+ return (/* @__PURE__ */ new Date()).toISOString();
28
+ }
29
+ function readClaims() {
30
+ try {
31
+ if (!existsSync(CLAIMS_FILE)) return [];
32
+ const data = JSON.parse(readFileSync(CLAIMS_FILE, "utf8"));
33
+ return Array.isArray(data?.claims) ? data.claims : [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+ function writeClaims(claims) {
39
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
40
+ const tmp = `${CLAIMS_FILE}.tmp`;
41
+ const payload = { claims };
42
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf8");
43
+ renameSync(tmp, CLAIMS_FILE);
44
+ }
45
+ function findClaim(id) {
46
+ return readClaims().find((c) => c.id === id) ?? null;
47
+ }
48
+ function listClaims(opts = {}) {
49
+ const claims = readClaims();
50
+ if (!opts.active) return claims;
51
+ return claims.filter((c) => !TERMINAL_STATES.has(c.state));
52
+ }
53
+ function recordClaim(rec) {
54
+ const claims = readClaims();
55
+ if (claims.some((c) => c.id === rec.id)) {
56
+ throw new Error(
57
+ `claim already exists for '${rec.id}' \u2014 run 'terminalhire claim status ${rec.id}' or 'terminalhire claim release ${rec.id}'`
58
+ );
59
+ }
60
+ const ts = nowISO();
61
+ const claim = {
62
+ ...rec,
63
+ state: "claimed",
64
+ worktreePath: null,
65
+ branch: null,
66
+ prUrl: null,
67
+ review: null,
68
+ claimedAt: ts,
69
+ updatedAt: ts
70
+ };
71
+ claims.push(claim);
72
+ writeClaims(claims);
73
+ return claim;
74
+ }
75
+ function updateClaim(id, patch) {
76
+ const claims = readClaims();
77
+ const idx = claims.findIndex((c) => c.id === id);
78
+ if (idx === -1) return null;
79
+ claims[idx] = { ...claims[idx], ...patch, updatedAt: nowISO() };
80
+ writeClaims(claims);
81
+ return claims[idx];
82
+ }
83
+ function removeClaim(id) {
84
+ const claims = readClaims();
85
+ const next = claims.filter((c) => c.id !== id);
86
+ if (next.length === claims.length) return false;
87
+ writeClaims(next);
88
+ return true;
89
+ }
90
+ function acceptedPRRate(claims = readClaims()) {
91
+ const total = claims.length;
92
+ const merged = claims.filter((c) => c.state === "merged").length;
93
+ return { merged, total, rate: total === 0 ? 0 : merged / total };
94
+ }
95
+ var TERMINALHIRE_DIR, CLAIMS_FILE, TERMINAL_STATES;
96
+ var init_claims = __esm({
97
+ "src/claims.ts"() {
98
+ "use strict";
99
+ TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
100
+ CLAIMS_FILE = join(TERMINALHIRE_DIR, "claims.json");
101
+ TERMINAL_STATES = /* @__PURE__ */ new Set(["merged", "abandoned"]);
102
+ }
103
+ });
104
+
105
+ // bin/jpi-claim.js
106
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
107
+ import { join as join2 } from "path";
108
+ import { homedir as homedir2 } from "os";
109
+ var TERMINALHIRE_DIR2 = join2(homedir2(), ".terminalhire");
110
+ var INDEX_CACHE_FILE = join2(TERMINALHIRE_DIR2, "index-cache.json");
111
+ var GH_API = "https://api.github.com";
112
+ var GH_HEADERS = { "User-Agent": "terminalhire-claim", Accept: "application/vnd.github+json" };
113
+ function findBountyInCache(bountyId) {
114
+ try {
115
+ if (!existsSync2(INDEX_CACHE_FILE)) return null;
116
+ const entry = JSON.parse(readFileSync2(INDEX_CACHE_FILE, "utf8"));
117
+ const jobs = entry?.index?.jobs ?? [];
118
+ const job = jobs.find((j) => j.id === bountyId && j.source === "bounty");
119
+ return job ?? null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ function parseGitHubUrl(url) {
125
+ const m = String(url ?? "").match(/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/);
126
+ if (!m) return null;
127
+ return { owner: m[1], repo: m[2], number: parseInt(m[3], 10), repoFullName: `${m[1]}/${m[2]}` };
128
+ }
129
+ async function countOpenPRsReferencingIssue(repoFullName, issueNumber) {
130
+ try {
131
+ const res = await fetch(`${GH_API}/repos/${repoFullName}/pulls?state=open&per_page=100`, {
132
+ headers: GH_HEADERS,
133
+ signal: AbortSignal.timeout(1e4)
134
+ });
135
+ if (!res.ok) return null;
136
+ const prs = await res.json();
137
+ if (!Array.isArray(prs)) return null;
138
+ if (prs.length === 100) return null;
139
+ const needle = new RegExp(`#${issueNumber}\\b`);
140
+ return prs.filter((p) => needle.test(`${p.title ?? ""}
141
+ ${p.body ?? ""}`)).length;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+ async function fetchIssueState(repoFullName, issueNumber) {
147
+ try {
148
+ const res = await fetch(`${GH_API}/repos/${repoFullName}/issues/${issueNumber}`, {
149
+ headers: GH_HEADERS,
150
+ signal: AbortSignal.timeout(1e4)
151
+ });
152
+ if (!res.ok) return null;
153
+ const issue = await res.json();
154
+ return issue.state === "open" ? "open" : issue.state === "closed" ? "closed" : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+ async function pollPR(prUrl) {
160
+ const p = parseGitHubUrl(prUrl);
161
+ if (!p) return null;
162
+ try {
163
+ const res = await fetch(`${GH_API}/repos/${p.repoFullName}/pulls/${p.number}`, {
164
+ headers: GH_HEADERS,
165
+ signal: AbortSignal.timeout(1e4)
166
+ });
167
+ if (!res.ok) return null;
168
+ const pr = await res.json();
169
+ return { merged: pr.merged === true, state: pr.state };
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+ function fmtAmount(a) {
175
+ return a != null ? "$" + a.toLocaleString() : "$\u2014";
176
+ }
177
+ function printMetric(rate) {
178
+ const pct = Math.round(rate.rate * 100);
179
+ console.log(`
180
+ \u{1F4CA} Accepted-PR rate: ${rate.merged}/${rate.total} claims merged (${pct}%)`);
181
+ }
182
+ async function cmdRecord(arg) {
183
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
184
+ if (!arg) {
185
+ console.error("Usage: terminalhire claim record <bountyId|issueUrl>");
186
+ console.error(" Run `terminalhire bounties` first to populate the local index cache,");
187
+ console.error(" then pass the id shown in its output \u2014 or pass a GitHub issue URL directly.");
188
+ process.exit(1);
189
+ }
190
+ let bountyId, title, repoFullName, issueUrl, amountUSD;
191
+ const job = findBountyInCache(arg);
192
+ if (job) {
193
+ const b = job.bounty ?? {};
194
+ bountyId = job.id;
195
+ title = job.title;
196
+ repoFullName = b.repoFullName ?? job.company ?? "";
197
+ issueUrl = b.claimUrl ?? job.url ?? "";
198
+ amountUSD = b.amountUSD ?? null;
199
+ } else {
200
+ const parsed = parseGitHubUrl(arg);
201
+ if (!parsed) {
202
+ console.error(`terminalhire claim: '${arg}' is not in the index cache and is not a GitHub issue URL.`);
203
+ console.error(" Run `terminalhire bounties` to populate the cache, or pass a full issue URL.");
204
+ process.exit(1);
205
+ }
206
+ bountyId = `gh:${parsed.repoFullName}#${parsed.number}`;
207
+ title = `${parsed.repoFullName}#${parsed.number}`;
208
+ repoFullName = parsed.repoFullName;
209
+ issueUrl = arg;
210
+ amountUSD = null;
211
+ }
212
+ const ghIssue = parseGitHubUrl(issueUrl);
213
+ const [issueState, openPRs] = ghIssue ? await Promise.all([
214
+ fetchIssueState(repoFullName, ghIssue.number),
215
+ countOpenPRsReferencingIssue(repoFullName, ghIssue.number)
216
+ // Guardrail #5
217
+ ]) : [null, null];
218
+ if (issueState === "closed") {
219
+ console.error(
220
+ `terminalhire claim: ${repoFullName}#${ghIssue.number} is CLOSED \u2014 not claimable.
221
+ The bounty index drops closed issues; this one is likely a stale cache entry.
222
+ Run \`terminalhire bounties\` for the current open pool.`
223
+ );
224
+ process.exit(1);
225
+ }
226
+ let claim;
227
+ try {
228
+ claim = claims.recordClaim({ id: bountyId, bountyId, title, repoFullName, issueUrl, amountUSD, openPRsAtClaim: openPRs });
229
+ } catch (err) {
230
+ console.error(`terminalhire claim: ${err.message ?? err}`);
231
+ process.exit(1);
232
+ }
233
+ console.log(`
234
+ \u2713 Claimed: ${claim.title}`);
235
+ console.log(` id: ${claim.id}`);
236
+ console.log(` repo: ${claim.repoFullName}`);
237
+ console.log(` amount: ${fmtAmount(claim.amountUSD)}`);
238
+ console.log(` issue: ${claim.issueUrl}`);
239
+ if (openPRs == null) {
240
+ console.log(" open PRs: unknown (GitHub read unavailable \u2014 check the issue manually before working)");
241
+ } else if (openPRs > 0) {
242
+ console.log(` \u26A0 open PRs referencing this issue: ${openPRs} \u2014 someone may already be on it. Check before working.`);
243
+ } else {
244
+ console.log(" open PRs referencing this issue: 0");
245
+ }
246
+ console.log("\n Executor constraints (enforce when spawning the background agent):");
247
+ console.log(" \u2022 work in an ISOLATED git worktree; scrub the subprocess env (no token/profile inheritance)");
248
+ console.log(" \u2022 MUST NOT `git push` or `gh pr` \u2014 pushing happens only via `terminalhire submit`");
249
+ console.log(" \u2022 clone + static analysis + patch only; NO test/build execution without explicit approval");
250
+ console.log(" \u2022 no access to ~/.terminalhire (the executor never needs your profile)");
251
+ console.log("\n Next: do the work, then `terminalhire claim update " + claim.id + " <state>` as you progress.");
252
+ }
253
+ async function cmdList(active) {
254
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
255
+ const list = claims.listClaims({ active });
256
+ if (list.length === 0) {
257
+ console.log(active ? "No active claims." : "No claims yet. Use `terminalhire claim record <bountyId>`.");
258
+ return;
259
+ }
260
+ console.log(`
261
+ ${list.length} ${active ? "active " : ""}claim${list.length === 1 ? "" : "s"}:
262
+ `);
263
+ for (const c of list) {
264
+ const pr = c.prUrl ? ` \xB7 ${c.prUrl}` : "";
265
+ console.log(` [${c.state}] ${fmtAmount(c.amountUSD)} \xB7 ${c.title}`);
266
+ console.log(` id: ${c.id}${pr}`);
267
+ }
268
+ printMetric(claims.acceptedPRRate());
269
+ }
270
+ async function cmdStatus(id) {
271
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
272
+ const targets = id ? [claims.findClaim(id)].filter(Boolean) : claims.listClaims();
273
+ if (targets.length === 0) {
274
+ console.log(id ? `No claim with id '${id}'.` : "No claims to poll.");
275
+ return;
276
+ }
277
+ let polled = 0;
278
+ for (const c of targets) {
279
+ if (!c.prUrl) continue;
280
+ const res = await pollPR(c.prUrl);
281
+ if (!res) {
282
+ console.log(` ? ${c.title} \u2014 could not read PR state (${c.prUrl})`);
283
+ continue;
284
+ }
285
+ polled++;
286
+ let next = c.state;
287
+ if (res.merged) next = "merged";
288
+ else if (res.state === "closed") next = "abandoned";
289
+ else next = "submitted";
290
+ const ORDER = ["claimed", "working", "in-review", "ready", "submitted", "merged", "abandoned"];
291
+ if (next !== c.state && ORDER.indexOf(next) > ORDER.indexOf(c.state)) {
292
+ claims.updateClaim(c.id, { state: next });
293
+ }
294
+ const mark = res.merged ? "\u2713 merged" : res.state === "closed" ? "\u2717 closed (unmerged)" : "\u2026 open";
295
+ console.log(` ${mark} \u2014 ${c.title} (${c.prUrl})`);
296
+ }
297
+ if (polled === 0) console.log(" No submitted claims with a PR URL yet. Set one via `claim update <id> submitted` after `submit`.");
298
+ printMetric(claims.acceptedPRRate());
299
+ }
300
+ async function cmdUpdate(id, state, prUrl) {
301
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
302
+ const VALID = ["claimed", "working", "in-review", "ready", "submitted", "merged", "abandoned"];
303
+ if (!id || !state || !VALID.includes(state)) {
304
+ console.error("Usage: terminalhire claim update <id> <state> [prUrl]");
305
+ console.error(" state: " + VALID.join(" | "));
306
+ console.error(" prUrl: attach the source PR URL (so `claim status` can poll its merge state)");
307
+ process.exit(1);
308
+ }
309
+ const patch = { state };
310
+ if (prUrl) {
311
+ if (!parseGitHubUrl(prUrl)) {
312
+ console.error(`terminalhire claim: '${prUrl}' is not a GitHub PR URL.`);
313
+ process.exit(1);
314
+ }
315
+ patch.prUrl = prUrl;
316
+ }
317
+ const updated = claims.updateClaim(id, patch);
318
+ if (!updated) {
319
+ console.error(`terminalhire claim: no claim with id '${id}'.`);
320
+ process.exit(1);
321
+ }
322
+ console.log(`Updated ${id} \u2192 ${state}${prUrl ? ` (PR: ${prUrl})` : ""}`);
323
+ }
324
+ async function cmdRelease(id) {
325
+ const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
326
+ if (!id) {
327
+ console.error("Usage: terminalhire claim release <id>");
328
+ process.exit(1);
329
+ }
330
+ const removed = claims.removeClaim(id);
331
+ console.log(removed ? `Released claim: ${id}` : `terminalhire claim: no claim with id '${id}'.`);
332
+ if (!removed) process.exit(1);
333
+ }
334
+ async function run() {
335
+ const verb = process.argv[2];
336
+ const rest = process.argv.slice(3).filter((a) => !a.startsWith("--"));
337
+ const active = process.argv.includes("--active");
338
+ try {
339
+ switch (verb) {
340
+ case "record":
341
+ await cmdRecord(rest[0]);
342
+ break;
343
+ case "list":
344
+ await cmdList(active);
345
+ break;
346
+ case "status":
347
+ await cmdStatus(rest[0]);
348
+ break;
349
+ case "update":
350
+ await cmdUpdate(rest[0], rest[1], rest[2]);
351
+ break;
352
+ case "release":
353
+ await cmdRelease(rest[0]);
354
+ break;
355
+ default:
356
+ console.error(`terminalhire claim: unknown verb '${verb ?? ""}'. Expected: record | list | status | update | release`);
357
+ process.exit(1);
358
+ }
359
+ } catch (err) {
360
+ console.error("terminalhire claim error:", err.message ?? err);
361
+ process.exit(1);
362
+ }
363
+ }
364
+ export {
365
+ run
366
+ };