terminalhire 0.37.0 → 0.39.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.
Files changed (49) hide show
  1. package/dist/bin/claim-push-bg.js +170 -29
  2. package/dist/bin/job-status-store.js +29 -0
  3. package/dist/bin/jpi-bounties.js +729 -60
  4. package/dist/bin/jpi-chat-read.js +279 -54
  5. package/dist/bin/jpi-chat.js +298 -73
  6. package/dist/bin/jpi-claim.js +15205 -406
  7. package/dist/bin/jpi-contribute.js +240 -32
  8. package/dist/bin/jpi-devs.js +705 -60
  9. package/dist/bin/jpi-dispatch.js +40089 -19693
  10. package/dist/bin/jpi-hub.js +946 -125
  11. package/dist/bin/jpi-inbox.js +298 -73
  12. package/dist/bin/jpi-init.js +677 -57
  13. package/dist/bin/jpi-intro.js +77 -4
  14. package/dist/bin/jpi-jobs.js +710 -61
  15. package/dist/bin/jpi-learn.js +77 -4
  16. package/dist/bin/jpi-link.js +27 -2
  17. package/dist/bin/jpi-login.js +845 -94
  18. package/dist/bin/jpi-mcp-chat.js +6203 -283
  19. package/dist/bin/jpi-mcp.js +5826 -131
  20. package/dist/bin/jpi-profile.js +77 -4
  21. package/dist/bin/jpi-project.js +677 -57
  22. package/dist/bin/jpi-refresh.js +1001 -191
  23. package/dist/bin/jpi-repo.js +101 -4
  24. package/dist/bin/jpi-save.js +77 -4
  25. package/dist/bin/jpi-spinner.js +29 -0
  26. package/dist/bin/jpi-sync.js +77 -4
  27. package/dist/bin/jpi-trajectory.js +77 -4
  28. package/dist/bin/jpi.js +34 -6
  29. package/dist/bin/spinner-render.js +30 -1
  30. package/dist/bin/spinner-seen.js +5 -1
  31. package/dist/bin/spinner.js +30 -1
  32. package/dist/src/chat-client.js +234 -36
  33. package/dist/src/chat-keystore.js +229 -27
  34. package/dist/src/claims.js +24 -0
  35. package/dist/src/crypto-store.js +27 -2
  36. package/dist/src/github-auth.js +132 -13
  37. package/dist/src/intro.js +77 -4
  38. package/dist/src/link.js +26 -1
  39. package/dist/src/profile.js +56 -2
  40. package/dist/src/repo-experience.js +56 -2
  41. package/dist/src/repo-policy-semantic.js +13499 -0
  42. package/dist/src/repo-policy.js +81 -14
  43. package/dist/src/reputation/fetch.js +59 -9
  44. package/dist/src/signal.js +29 -0
  45. package/dist/src/state-dir.js +32 -1
  46. package/dist/src/test-race-barrier.js +45 -0
  47. package/dist/src/trajectory.js +77 -4
  48. package/dist/src/web-session.js +26 -1
  49. package/package.json +2 -1
@@ -50,7 +50,31 @@ function ensureStateDir(dir) {
50
50
  }
51
51
  }
52
52
  }
53
- var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs;
53
+ function applyStateDirSecretPolicy(dir, status) {
54
+ if (status === STATE_DIR_SYMLINK) {
55
+ throw new Error(
56
+ `terminalhire: refusing to write key material into ${dir} \u2014 it is a symlink, not a directory.
57
+ A write through it would FOLLOW THE LINK and place key/token material wherever the symlink points, outside our control and outside the "owner-only" (0700) guarantee this directory is supposed to carry.
58
+ Fix: remove the symlink so terminalhire can recreate it as a real directory \u2014
59
+ rm ${dir}
60
+ then re-run the command. If the symlink is intentional, point TERMINALHIRE_DIR at a real directory instead of routing it through this one.`
61
+ );
62
+ }
63
+ if (status === STATE_DIR_UNVERIFIED && !warnedUnverifiedSecretWriteThisProcess) {
64
+ warnedUnverifiedSecretWriteThisProcess = true;
65
+ try {
66
+ process.stderr.write(
67
+ `terminalhire: could not verify ${dir}'s permissions (expected on Windows \u2014 POSIX mode bits do not apply there) \u2014 proceeding, but the "owner-only" guarantee on key/token storage is NOT enforced on this platform.
68
+ `
69
+ );
70
+ } catch {
71
+ }
72
+ }
73
+ }
74
+ function ensureStateDirForSecret(dir) {
75
+ applyStateDirSecretPolicy(dir, ensureStateDir(dir));
76
+ }
77
+ var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs, warnedUnverifiedSecretWriteThisProcess;
54
78
  var init_state_dir = __esm({
55
79
  "src/state-dir.ts"() {
56
80
  "use strict";
@@ -59,6 +83,7 @@ var init_state_dir = __esm({
59
83
  STATE_DIR_SYMLINK = "symlink";
60
84
  STATE_DIR_UNVERIFIED = "unverified";
61
85
  warnedDirs = /* @__PURE__ */ new Set();
86
+ warnedUnverifiedSecretWriteThisProcess = false;
62
87
  }
63
88
  });
64
89
 
@@ -73,6 +98,8 @@ __export(claims_exports, {
73
98
  readClaims: () => readClaims,
74
99
  recordClaim: () => recordClaim,
75
100
  removeClaim: () => removeClaim,
101
+ removeClaimIfStakeMatches: () => removeClaimIfStakeMatches,
102
+ reserveStake: () => reserveStake,
76
103
  toPushedClaim: () => toPushedClaim,
77
104
  updateClaim: () => updateClaim
78
105
  });
@@ -81,12 +108,12 @@ import {
81
108
  writeFileSync as writeFileSync2,
82
109
  mkdirSync as mkdirSync2,
83
110
  renameSync as renameSync2,
84
- existsSync as existsSync2,
111
+ existsSync as existsSync3,
85
112
  rmSync as rmSync2,
86
113
  statSync
87
114
  } from "fs";
88
115
  import { randomBytes as randomBytes2 } from "crypto";
89
- import { join as join2 } from "path";
116
+ import { join as join3 } from "path";
90
117
  import { homedir as homedir2 } from "os";
91
118
  function sleepSync(ms) {
92
119
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -142,7 +169,7 @@ function normalizeClaim(c) {
142
169
  }
143
170
  function readClaims() {
144
171
  try {
145
- if (!existsSync2(CLAIMS_FILE)) return [];
172
+ if (!existsSync3(CLAIMS_FILE)) return [];
146
173
  const data = JSON.parse(readFileSync2(CLAIMS_FILE, "utf8"));
147
174
  const claims = Array.isArray(data?.claims) ? data.claims : [];
148
175
  return claims.map(normalizeClaim);
@@ -215,6 +242,17 @@ function updateClaim(id, patch) {
215
242
  return claims[idx];
216
243
  });
217
244
  }
245
+ function reserveStake(id, stake) {
246
+ return withClaimsLock(() => {
247
+ const claims = readClaims();
248
+ const idx = claims.findIndex((c) => c.id === id);
249
+ if (idx === -1) return null;
250
+ if (claims[idx].stake) return null;
251
+ claims[idx] = { ...claims[idx], stake, updatedAt: nowISO() };
252
+ writeClaims(claims);
253
+ return claims[idx];
254
+ });
255
+ }
218
256
  function removeClaim(id) {
219
257
  return withClaimsLock(() => {
220
258
  const claims = readClaims();
@@ -224,6 +262,17 @@ function removeClaim(id) {
224
262
  return true;
225
263
  });
226
264
  }
265
+ function removeClaimIfStakeMatches(id, expectedStakePostedAt) {
266
+ return withClaimsLock(() => {
267
+ const claims = readClaims();
268
+ const target = claims.find((c) => c.id === id);
269
+ if (!target) return false;
270
+ const actual = target.stake ? target.stake.postedAt : null;
271
+ if (actual !== expectedStakePostedAt) return false;
272
+ writeClaims(claims.filter((c) => c.id !== id));
273
+ return true;
274
+ });
275
+ }
227
276
  function acceptedPRRate(claims = readClaims()) {
228
277
  const total = claims.length;
229
278
  const merged = claims.filter((c) => c.state === "merged").length;
@@ -234,8 +283,8 @@ var init_claims = __esm({
234
283
  "src/claims.ts"() {
235
284
  "use strict";
236
285
  init_state_dir();
237
- TERMINALHIRE_DIR2 = process.env.TERMINALHIRE_DIR || join2(homedir2(), ".terminalhire");
238
- CLAIMS_FILE = join2(TERMINALHIRE_DIR2, "claims.json");
286
+ TERMINALHIRE_DIR2 = process.env.TERMINALHIRE_DIR || join3(homedir2(), ".terminalhire");
287
+ CLAIMS_FILE = join3(TERMINALHIRE_DIR2, "claims.json");
239
288
  LOCK_DIR = `${CLAIMS_FILE}.lock`;
240
289
  LOCK_STALE_MS = Number(process.env.TERMINALHIRE_LOCK_STALE_MS) || 1e4;
241
290
  LOCK_RETRY_MS = Number(process.env.TERMINALHIRE_LOCK_RETRY_MS) || 25;
@@ -274,30 +323,122 @@ var init_claims = __esm({
274
323
 
275
324
  // bin/claim-push-bg.js
276
325
  import { createHash } from "crypto";
277
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync as rmSync3 } from "fs";
278
- import { join as join3 } from "path";
326
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync4, rmSync as rmSync3 } from "fs";
327
+ import { join as join4 } from "path";
279
328
  import { homedir as homedir3 } from "os";
280
329
 
281
330
  // src/github-auth.ts
282
331
  init_state_dir();
283
332
  import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
284
- import { readFileSync, writeFileSync, existsSync, rmSync, renameSync } from "fs";
285
- import { join } from "path";
333
+ import {
334
+ readFileSync,
335
+ writeFileSync,
336
+ existsSync as existsSync2,
337
+ rmSync,
338
+ renameSync,
339
+ linkSync,
340
+ unlinkSync
341
+ } from "fs";
342
+ import { join as join2 } from "path";
286
343
  import { homedir } from "os";
287
- var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join(homedir(), ".terminalhire");
288
- var TOKEN_FILE = join(TERMINALHIRE_DIR, "github-token.enc");
289
- var KEY_FILE = join(TERMINALHIRE_DIR, "key");
344
+
345
+ // src/test-race-barrier.ts
346
+ import { closeSync as closeSync2, constants as constants2, existsSync, lstatSync, openSync as openSync2 } from "fs";
347
+ import { join } from "path";
348
+ var ENV_VAR = "TERMINALHIRE_TEST_RACE_BARRIER_DIR";
349
+ function syncSleepMs(ms) {
350
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
351
+ }
352
+ function waitForTestRaceBarrier(phase) {
353
+ const root = process.env[ENV_VAR];
354
+ if (!root) return;
355
+ const phaseDir = join(root, phase);
356
+ if (!existsSync(phaseDir)) return;
357
+ const readyFile = join(phaseDir, `ready-${process.pid}`);
358
+ const goFile = join(phaseDir, "go");
359
+ const noFollow = constants2.O_NOFOLLOW ?? 0;
360
+ if (lstatSync(readyFile, { throwIfNoEntry: false })) {
361
+ throw new Error(
362
+ `terminalhire: test race barrier "${phase}" found something already at its ready marker path ${readyFile} (regular file or symlink) \u2014 refusing rather than following or overwriting whatever is already there (this only fires under ${ENV_VAR}, never in production).`
363
+ );
364
+ }
365
+ let readyFd;
366
+ try {
367
+ readyFd = openSync2(
368
+ readyFile,
369
+ constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | noFollow
370
+ );
371
+ } catch (err) {
372
+ throw new Error(
373
+ `terminalhire: test race barrier "${phase}" could not create its ready marker at ${readyFile} (${err instanceof Error ? err.message : String(err)}) \u2014 refusing rather than blocking on or writing through whatever is already there (this only fires under ${ENV_VAR}, never in production).`
374
+ );
375
+ }
376
+ closeSync2(readyFd);
377
+ const deadline = Date.now() + 3e4;
378
+ while (!existsSync(goFile)) {
379
+ if (Date.now() > deadline) {
380
+ throw new Error(
381
+ `terminalhire: test race barrier "${phase}" timed out waiting for ${goFile} (the test process never released it \u2014 this only fires under ${ENV_VAR}, never in production).`
382
+ );
383
+ }
384
+ syncSleepMs(2);
385
+ }
386
+ }
387
+
388
+ // src/github-auth.ts
389
+ var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join2(homedir(), ".terminalhire");
390
+ var TOKEN_FILE = join2(TERMINALHIRE_DIR, "github-token.enc");
391
+ var KEY_FILE = join2(TERMINALHIRE_DIR, "key");
290
392
  var ALGO = "aes-256-gcm";
291
393
  var KEY_BYTES = 32;
292
394
  var IV_BYTES = 12;
395
+ var KEY_HEX_RE = new RegExp(`^[0-9a-f]{${KEY_BYTES * 2}}$`);
396
+ function isValidKeyHex(value) {
397
+ return KEY_HEX_RE.test(value);
398
+ }
399
+ function readKeyFileOrThrow() {
400
+ const raw = readFileSync(KEY_FILE, "utf8").trim();
401
+ if (!isValidKeyHex(raw)) {
402
+ throw new Error(
403
+ `terminalhire: the shared encryption key at ${KEY_FILE} is not in the expected format (expected exactly ${KEY_BYTES * 2} lowercase-hex characters \u2014 a ${KEY_BYTES}-byte key).
404
+ This key decrypts the GitHub token, local profile, and chat identity stores under ~/.terminalhire \u2014 it should never be hand-edited.
405
+ Recovery: if you intend to reset it, delete the file yourself (this INVALIDATES every encrypted store under ~/.terminalhire, which will need to be re-created/re-authenticated):
406
+ rm ${KEY_FILE}`
407
+ );
408
+ }
409
+ return Buffer.from(raw, "hex");
410
+ }
411
+ function publishKeyBlob(key) {
412
+ const tmpFile = `${KEY_FILE}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
413
+ try {
414
+ writeFileSync(tmpFile, key.toString("hex"), { encoding: "utf8", mode: 384, flag: "wx" });
415
+ try {
416
+ linkSync(tmpFile, KEY_FILE);
417
+ return true;
418
+ } catch (err) {
419
+ if (err?.code === "EEXIST") {
420
+ return false;
421
+ }
422
+ throw err;
423
+ }
424
+ } finally {
425
+ try {
426
+ unlinkSync(tmpFile);
427
+ } catch {
428
+ }
429
+ }
430
+ }
293
431
  async function loadKey() {
294
- ensureStateDir(TERMINALHIRE_DIR);
295
- if (existsSync(KEY_FILE)) {
296
- return Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "hex");
432
+ ensureStateDirForSecret(TERMINALHIRE_DIR);
433
+ if (existsSync2(KEY_FILE)) {
434
+ return readKeyFileOrThrow();
297
435
  }
436
+ waitForTestRaceBarrier("key");
298
437
  const key = randomBytes(KEY_BYTES);
299
- writeFileSync(KEY_FILE, key.toString("hex"), { mode: 384, encoding: "utf8" });
300
- return key;
438
+ if (publishKeyBlob(key)) {
439
+ return key;
440
+ }
441
+ return readKeyFileOrThrow();
301
442
  }
302
443
  function encrypt(plaintext, key) {
303
444
  const iv = randomBytes(IV_BYTES);
@@ -318,21 +459,21 @@ function decrypt(blob, key) {
318
459
 
319
460
  // bin/claim-push-bg.js
320
461
  init_state_dir();
321
- var TERMINALHIRE_DIR3 = process.env.TERMINALHIRE_DIR || join3(homedir3(), ".terminalhire");
322
- var CLAIM_PUSH_AUTO_MARKER = join3(TERMINALHIRE_DIR3, "claim-push-auto.json");
323
- var CLAIM_PUSH_TOKEN_FILE = join3(TERMINALHIRE_DIR3, "claim-push-token.enc");
324
- var CLAIM_PUSH_MANUAL_MARKER = join3(TERMINALHIRE_DIR3, "claim-push.json");
462
+ var TERMINALHIRE_DIR3 = process.env.TERMINALHIRE_DIR || join4(homedir3(), ".terminalhire");
463
+ var CLAIM_PUSH_AUTO_MARKER = join4(TERMINALHIRE_DIR3, "claim-push-auto.json");
464
+ var CLAIM_PUSH_TOKEN_FILE = join4(TERMINALHIRE_DIR3, "claim-push-token.enc");
465
+ var CLAIM_PUSH_MANUAL_MARKER = join4(TERMINALHIRE_DIR3, "claim-push.json");
325
466
  var CLAIM_SYNC_BASE = "https://terminalhire.com";
326
467
  var AUTO_CONSENT_VERSION = 2;
327
468
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
328
469
  async function writePushTokenEnc(rawToken) {
329
- ensureStateDir(TERMINALHIRE_DIR3);
470
+ ensureStateDirForSecret(TERMINALHIRE_DIR3);
330
471
  const key = await loadKey();
331
472
  const blob = encrypt(rawToken, key);
332
473
  writeFileSync3(CLAIM_PUSH_TOKEN_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
333
474
  }
334
475
  async function readPushTokenEnc() {
335
- if (!existsSync3(CLAIM_PUSH_TOKEN_FILE)) return void 0;
476
+ if (!existsSync4(CLAIM_PUSH_TOKEN_FILE)) return void 0;
336
477
  try {
337
478
  const key = await loadKey();
338
479
  const blob = JSON.parse(readFileSync3(CLAIM_PUSH_TOKEN_FILE, "utf8"));
@@ -349,7 +490,7 @@ function clearPushTokenEnc() {
349
490
  }
350
491
  function readAutoMarker() {
351
492
  try {
352
- return existsSync3(CLAIM_PUSH_AUTO_MARKER) ? JSON.parse(readFileSync3(CLAIM_PUSH_AUTO_MARKER, "utf8")) : null;
493
+ return existsSync4(CLAIM_PUSH_AUTO_MARKER) ? JSON.parse(readFileSync3(CLAIM_PUSH_AUTO_MARKER, "utf8")) : null;
353
494
  } catch {
354
495
  return null;
355
496
  }
@@ -410,13 +551,13 @@ async function shouldNudgeUnpushed() {
410
551
  const currentHash = computeSnapshotHash(pushed);
411
552
  let manual = null;
412
553
  try {
413
- manual = existsSync3(CLAIM_PUSH_MANUAL_MARKER) ? JSON.parse(readFileSync3(CLAIM_PUSH_MANUAL_MARKER, "utf8")) : null;
554
+ manual = existsSync4(CLAIM_PUSH_MANUAL_MARKER) ? JSON.parse(readFileSync3(CLAIM_PUSH_MANUAL_MARKER, "utf8")) : null;
414
555
  } catch {
415
556
  manual = null;
416
557
  }
417
558
  return unpushedNudgeGate({
418
- autoMarkerExists: existsSync3(CLAIM_PUSH_AUTO_MARKER),
419
- tokenFileExists: existsSync3(CLAIM_PUSH_TOKEN_FILE),
559
+ autoMarkerExists: existsSync4(CLAIM_PUSH_AUTO_MARKER),
560
+ tokenFileExists: existsSync4(CLAIM_PUSH_TOKEN_FILE),
420
561
  manualMarkerExists: !!manual,
421
562
  lastSnapshotHash: manual?.lastSnapshotHash ?? null,
422
563
  currentHash,
@@ -428,7 +569,7 @@ async function shouldNudgeUnpushed() {
428
569
  }
429
570
  async function runBackgroundClaimPush({ now = Date.now() } = {}) {
430
571
  try {
431
- if (!existsSync3(CLAIM_PUSH_AUTO_MARKER) || !existsSync3(CLAIM_PUSH_TOKEN_FILE)) return;
572
+ if (!existsSync4(CLAIM_PUSH_AUTO_MARKER) || !existsSync4(CLAIM_PUSH_TOKEN_FILE)) return;
432
573
  const marker = readAutoMarker();
433
574
  if (!marker || !marker.autoConsentedAt) return;
434
575
  const { listClaims: listClaims2, toPushedClaim: toPushedClaim2, PUSHED_CLAIM_FIELDS: PUSHED_CLAIM_FIELDS2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -699,6 +699,35 @@ var VERIFY_CONTRACT = [
699
699
  '{"supported":["c1","c3"]} \u2014 the ids of the claims that survive (omit all others; use',
700
700
  '{"supported":[]} if none do). Any surviving id MUST be one you were given.'
701
701
  ].join("\n");
702
+ var STAGE4_CONTRACT = [
703
+ "You are the CLAIM VALIDITY STAGE \u2014 a second, independent, ADVERSARIAL verifier.",
704
+ "You have NOT seen how any claim below was generated, by whom, or why \u2014 only the",
705
+ "claims themselves and the source. Your only job is to try to INVALIDATE each claim",
706
+ "using the FULL, ORIGINAL, UNABRIDGED source object given below \u2014 not a narrow",
707
+ "excerpt of it, the whole thing.",
708
+ "",
709
+ "For each claim: does the FULL source UNEQUIVOCALLY support every assertion in its",
710
+ "text, with no benefit of the doubt, no inference, no outside knowledge? A claim can",
711
+ "cite a real, resolvable path and still be FALSE in context \u2014 for example if it",
712
+ "quotes an early or superseded value while the full record shows the opposite held",
713
+ "later, or generalizes one narrow fact into a broader claim the record does not",
714
+ "support. Treat either as UNSUPPORTED. When in doubt, mark it UNSUPPORTED",
715
+ "(default-to-drop).",
716
+ "",
717
+ "ANTICIPATION RULE: a claim asserting the contributor anticipated, or acted ahead",
718
+ "of, reviewer concerns is supported ONLY if the full record shows the relevant work",
719
+ "was delivered BEFORE any reviewer raised a related concern. If the full record",
720
+ "shows a reviewer raised it first, or the ordering cannot be established, that",
721
+ 'specific "anticipated" framing is NOT supported \u2014 mark it UNSUPPORTED rather than',
722
+ "accept the claim's framing at face value.",
723
+ "",
724
+ "OUTPUT FORMAT \u2014 obey exactly: respond with ONLY the JSON object and NOTHING ELSE.",
725
+ "No preamble, no per-claim commentary, no reasoning prose, no markdown fence, no",
726
+ "text before or after:",
727
+ '{"results":[{"id":"c1","supported":true},{"id":"c2","supported":false}]}',
728
+ "Exactly one entry per claim you were given, each with EXACTLY the keys `id` and",
729
+ "`supported` and no others."
730
+ ].join("\n");
702
731
 
703
732
  // ../../packages/core/src/short-token.ts
704
733
  import { createHash as createHash2 } from "crypto";