terminalhire 0.40.12 → 0.41.1

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.
@@ -26056,6 +26056,13 @@ function openInBrowser(url) {
26056
26056
  }
26057
26057
  }
26058
26058
 
26059
+ // bin/sanitize.js
26060
+ var CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g;
26061
+ function sanitizeText(s) {
26062
+ if (s == null) return "";
26063
+ return String(s).replace(CONTROL_CHARS, "");
26064
+ }
26065
+
26059
26066
  // src/policy-acks.ts
26060
26067
  init_state_dir();
26061
26068
  import { lstatSync, readFileSync as readFileSync2, writeFileSync } from "fs";
@@ -26129,7 +26136,6 @@ var TERMINALHIRE_DIR5 = process.env.TERMINALHIRE_DIR || join7(homedir5(), ".term
26129
26136
  var CLAIM_PUSH_AUTO_MARKER = join7(TERMINALHIRE_DIR5, "claim-push-auto.json");
26130
26137
  var CLAIM_PUSH_TOKEN_FILE = join7(TERMINALHIRE_DIR5, "claim-push-token.enc");
26131
26138
  var CLAIM_PUSH_MANUAL_MARKER = join7(TERMINALHIRE_DIR5, "claim-push.json");
26132
- var CLAIM_SYNC_BASE = "https://terminalhire.com";
26133
26139
  var AUTO_CONSENT_VERSION = 2;
26134
26140
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
26135
26141
  async function writePushTokenEnc(rawToken) {
@@ -26174,28 +26180,6 @@ function clearAutoMarker() {
26174
26180
  function computeSnapshotHash(pushed) {
26175
26181
  return createHash3("sha256").update(JSON.stringify(pushed)).digest("hex");
26176
26182
  }
26177
- function backgroundPushGate(params) {
26178
- const {
26179
- autoMarkerExists,
26180
- tokenFileExists,
26181
- lastPushedAt,
26182
- now,
26183
- throttleMs,
26184
- currentHash,
26185
- lastSnapshotHash
26186
- } = params;
26187
- if (!autoMarkerExists || !tokenFileExists) {
26188
- return { push: false, reason: "not-opted-in" };
26189
- }
26190
- const last = lastPushedAt ? Date.parse(lastPushedAt) : NaN;
26191
- if (!Number.isNaN(last) && now - last < throttleMs) {
26192
- return { push: false, reason: "throttled" };
26193
- }
26194
- if (lastSnapshotHash && lastSnapshotHash === currentHash) {
26195
- return { push: false, reason: "unchanged" };
26196
- }
26197
- return { push: true, reason: "ok" };
26198
- }
26199
26183
  function unpushedNudgeGate(params) {
26200
26184
  const {
26201
26185
  autoMarkerExists,
@@ -26233,50 +26217,107 @@ async function shouldNudgeUnpushed() {
26233
26217
  return false;
26234
26218
  }
26235
26219
  }
26236
- async function runBackgroundClaimPush({ now = Date.now() } = {}) {
26220
+
26221
+ // bin/founder-verdict-sync.js
26222
+ var CLAIM_SYNC_BASE = "https://terminalhire.com";
26223
+ var TERMINAL = /* @__PURE__ */ new Set(["merged", "abandoned"]);
26224
+ function verdictState(verdict) {
26225
+ return verdict === "rejected" ? "abandoned" : "merged";
26226
+ }
26227
+ async function fetchFounderVerdicts(pushToken, fetchImpl = fetch) {
26228
+ if (typeof pushToken !== "string" || pushToken.length === 0) return null;
26237
26229
  try {
26238
- if (!existsSync5(CLAIM_PUSH_AUTO_MARKER) || !existsSync5(CLAIM_PUSH_TOKEN_FILE)) {
26239
- return { pushed: false, reason: "not-opted-in" };
26240
- }
26241
- const marker = readAutoMarker();
26242
- if (!marker || !marker.autoConsentedAt) return { pushed: false, reason: "not-opted-in" };
26243
- const { listClaims: listClaims2, toPushedClaim: toPushedClaim2, PUSHED_CLAIM_FIELDS: PUSHED_CLAIM_FIELDS2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
26244
- const pushed = listClaims2().map((c) => toPushedClaim2(c));
26245
- const currentHash = computeSnapshotHash(pushed);
26246
- const gate = backgroundPushGate({
26247
- autoMarkerExists: true,
26248
- tokenFileExists: true,
26249
- lastPushedAt: marker.lastPushedAt ?? null,
26250
- now,
26251
- throttleMs: AUTO_PUSH_THROTTLE_MS,
26252
- currentHash,
26253
- lastSnapshotHash: marker.lastSnapshotHash ?? null
26254
- });
26255
- if (!gate.push) return { pushed: false, reason: gate.reason };
26256
- const token = await readPushTokenEnc();
26257
- if (!token) return { pushed: false, reason: "unreadable-token" };
26258
- const consentReceipt = {
26259
- consentedAt: marker.autoConsentedAt,
26260
- version: AUTO_CONSENT_VERSION,
26261
- fields: PUSHED_CLAIM_FIELDS2
26262
- };
26263
- const res = await fetch(`${CLAIM_SYNC_BASE}/api/claim-sync`, {
26230
+ const res = await fetchImpl(`${CLAIM_SYNC_BASE}/api/claim/verdicts`, {
26264
26231
  method: "POST",
26265
26232
  headers: { "Content-Type": "application/json" },
26266
- body: JSON.stringify({ consentToken: consentReceipt, claims: pushed, pushToken: token }),
26267
- signal: AbortSignal.timeout(1e4)
26233
+ body: JSON.stringify({ pushToken }),
26234
+ signal: AbortSignal.timeout(15e3)
26268
26235
  });
26269
- if (!res.ok) {
26270
- return { pushed: false, reason: `server-${res.status}` };
26271
- }
26272
- writeAutoMarker({
26273
- ...marker,
26274
- lastPushedAt: new Date(now).toISOString(),
26275
- lastSnapshotHash: currentHash
26236
+ if (!res?.ok) return null;
26237
+ const body = await res.json();
26238
+ if (!body || !Array.isArray(body.verdicts)) return null;
26239
+ const verdicts = body.verdicts.filter(
26240
+ (v) => v && typeof v.claimId === "string" && v.claimId !== "" && (v.verdict === "accepted" || v.verdict === "rejected")
26241
+ );
26242
+ return { verdicts, latestAt: typeof body.latestAt === "string" ? body.latestAt : null };
26243
+ } catch {
26244
+ return null;
26245
+ }
26246
+ }
26247
+ function planVerdictTransitions(claims, verdicts, nextPolledState2) {
26248
+ if (!Array.isArray(claims) || !Array.isArray(verdicts)) return [];
26249
+ const byClaimId = /* @__PURE__ */ new Map();
26250
+ for (const v of verdicts) {
26251
+ if (v && typeof v.claimId === "string" && v.claimId !== "") byClaimId.set(v.claimId, v);
26252
+ }
26253
+ const plan = [];
26254
+ for (const c of claims) {
26255
+ const claimId = c?.approval?.claimId;
26256
+ if (!claimId) continue;
26257
+ const v = byClaimId.get(claimId);
26258
+ if (!v) continue;
26259
+ if (TERMINAL.has(c.state)) continue;
26260
+ const to = verdictState(v.verdict);
26261
+ const next = nextPolledState2(c.state, to);
26262
+ if (next === c.state) continue;
26263
+ plan.push({
26264
+ id: c.id,
26265
+ claimId,
26266
+ from: c.state,
26267
+ to: next,
26268
+ verdict: v.verdict,
26269
+ settled: v.settled === true,
26270
+ amountUSD: typeof c.amountUSD === "number" ? c.amountUSD : null,
26271
+ title: typeof c.title === "string" ? c.title : ""
26276
26272
  });
26277
- return { pushed: true, reason: "ok" };
26273
+ }
26274
+ return plan;
26275
+ }
26276
+ function buildVerdictNotice(t) {
26277
+ if (!t || typeof t.to !== "string") return null;
26278
+ const amount = typeof t.amountUSD === "number" && t.amountUSD > 0 ? `$${t.amountUSD}` : null;
26279
+ if (t.verdict === "rejected") {
26280
+ return ` \u2717 founder rejected${amount ? ` \u2014 ${amount}` : ""} \xB7 claim moved to ${t.to}`;
26281
+ }
26282
+ const paid = t.settled ? " \xB7 paid" : "";
26283
+ return ` \u2713 founder accepted${amount ? ` \u2014 ${amount}` : ""}${paid} \xB7 claim moved to ${t.to}`;
26284
+ }
26285
+ async function syncFounderVerdicts({
26286
+ claimsModule,
26287
+ targets,
26288
+ readPushTokenEnc: readPushTokenEnc2,
26289
+ fetchImpl = fetch,
26290
+ log = console.log
26291
+ } = {}) {
26292
+ const quiet = { checked: false, unavailable: false, applied: [] };
26293
+ try {
26294
+ const founderTargets = (targets ?? []).filter(
26295
+ (c) => Boolean(c?.approval) && !TERMINAL.has(c.state)
26296
+ );
26297
+ if (founderTargets.length === 0) return quiet;
26298
+ let pushToken = null;
26299
+ try {
26300
+ pushToken = await readPushTokenEnc2();
26301
+ } catch {
26302
+ pushToken = null;
26303
+ }
26304
+ if (!pushToken) return quiet;
26305
+ const res = await fetchFounderVerdicts(pushToken, fetchImpl);
26306
+ if (!res) return { checked: false, unavailable: true, applied: [] };
26307
+ const plan = planVerdictTransitions(founderTargets, res.verdicts, claimsModule.nextPolledState);
26308
+ const applied = [];
26309
+ for (const t of plan) {
26310
+ try {
26311
+ claimsModule.updateClaim(t.id, { state: t.to });
26312
+ applied.push(t);
26313
+ const line = buildVerdictNotice(t);
26314
+ if (line) log(line);
26315
+ } catch {
26316
+ }
26317
+ }
26318
+ return { checked: true, unavailable: false, applied };
26278
26319
  } catch {
26279
- return { pushed: false, reason: "failed" };
26320
+ return quiet;
26280
26321
  }
26281
26322
  }
26282
26323
 
@@ -27218,15 +27259,35 @@ async function localLoginForPaidBrowserClaim() {
27218
27259
  if (timer) clearTimeout(timer);
27219
27260
  }
27220
27261
  }
27262
+ var PUSH_TOKEN_REFUSAL = Object.freeze({
27263
+ /** The credential is unknown or revoked — dead. Clear it. */
27264
+ INVALID: "invalid-push-token",
27265
+ /** The credential is LIVE, just not a registration credential. NEVER clear it. */
27266
+ INSUFFICIENT: "insufficient-push-token",
27267
+ /**
27268
+ * TERM-325. Minted before tokens were bound to a GitHub account id, so it can no
27269
+ * longer prove who holds it. Unrevoked and real, and it fails closed at every scope
27270
+ * — the slice route answers `invalid-push-token` for the same row — so the ACTION is
27271
+ * the revoked one: clear it and verify in the browser.
27272
+ *
27273
+ * The WORDING is not. The server is explicit that nothing was revoked and the
27274
+ * requirement changed on our side, so telling this developer they revoked something
27275
+ * is false. Sharing a branch must not mean sharing a sentence.
27276
+ */
27277
+ LEGACY: "legacy-push-token"
27278
+ });
27279
+ var SYNC_BACKGROUND_PUSH_ACTIVE_FIELD = "backgroundPushActive";
27221
27280
  async function registerFounderClaim(b) {
27222
27281
  const postingId = b.bountyId.replace(/^bounty:founder:/, "");
27282
+ let clearedLocalCredential = false;
27283
+ let refusedForPurpose = false;
27223
27284
  const refuse = (reason) => {
27224
27285
  console.error(
27225
27286
  `
27226
27287
  terminalhire claim: refusing to record \u2014 ${reason}
27227
- Nothing was recorded: a founder-posting claim registers with terminalhire BEFORE
27288
+ No CLAIM was recorded: a founder-posting claim registers with terminalhire BEFORE
27228
27289
  it is recorded locally (fail-closed), so a posting that is gone, taken, or
27229
- unverifiable is never claimed on stale cache data.`
27290
+ unverifiable is never claimed on stale cache data.` + (clearedLocalCredential ? "\n One local change was kept: the stored push token was deleted from this\n machine (the server no longer accepts it, so keeping it would fail every\n later claim)." : "")
27230
27291
  );
27231
27292
  process.exit(1);
27232
27293
  };
@@ -27241,7 +27302,7 @@ terminalhire claim: refusing to record \u2014 ${reason}
27241
27302
  } catch {
27242
27303
  }
27243
27304
  let expectLogin;
27244
- if (!auth) {
27305
+ const acquireProofAuth = async () => {
27245
27306
  try {
27246
27307
  expectLogin = await localLoginForPaidBrowserClaim();
27247
27308
  } catch (err) {
@@ -27275,7 +27336,8 @@ terminalhire claim: refusing to record \u2014 ${reason}
27275
27336
  );
27276
27337
  }
27277
27338
  auth = { proofToken };
27278
- }
27339
+ };
27340
+ if (!auth) await acquireProofAuth();
27279
27341
  console.log("\n Registering this claim with terminalhire (founder posting)...");
27280
27342
  const sendRegistration = async (includeExpectation) => fetch(`${CLAIM_SYNC_BASE2}/api/claim/register`, {
27281
27343
  method: "POST",
@@ -27287,6 +27349,14 @@ terminalhire claim: refusing to record \u2014 ${reason}
27287
27349
  }),
27288
27350
  signal: AbortSignal.timeout(1e4)
27289
27351
  });
27352
+ const readRefusal = async (r) => {
27353
+ if (r.ok) return null;
27354
+ try {
27355
+ return await r.json();
27356
+ } catch {
27357
+ return null;
27358
+ }
27359
+ };
27290
27360
  let res;
27291
27361
  try {
27292
27362
  res = await sendRegistration(true);
@@ -27295,23 +27365,54 @@ terminalhire claim: refusing to record \u2014 ${reason}
27295
27365
  `terminalhire is unreachable (${err instanceof Error ? err.message : String(err)}), so the posting could not be revalidated.`
27296
27366
  );
27297
27367
  }
27298
- let refusalBody = null;
27299
- if (!res.ok) {
27368
+ let refusalBody = await readRefusal(res);
27369
+ const pushTokenRefusal = storedPushToken && res.status === 403 && (refusalBody?.error === PUSH_TOKEN_REFUSAL.INVALID || refusalBody?.error === PUSH_TOKEN_REFUSAL.LEGACY || refusalBody?.error === PUSH_TOKEN_REFUSAL.INSUFFICIENT) ? refusalBody.error : null;
27370
+ if (pushTokenRefusal) {
27371
+ if (pushTokenRefusal !== PUSH_TOKEN_REFUSAL.INSUFFICIENT) {
27372
+ if (pushTokenRefusal === PUSH_TOKEN_REFUSAL.LEGACY) {
27373
+ console.log("\n The push token on this machine was issued before terminalhire tied");
27374
+ console.log(" tokens to a GitHub account, so it can no longer prove who holds it.");
27375
+ console.log(" Nothing was revoked \u2014 the requirement changed on our side.");
27376
+ console.log(" Registering this claim needs only a ONE-TIME identity check, so");
27377
+ console.log(" falling back to browser verification.");
27378
+ } else {
27379
+ console.log("\n The push token stored on this machine is no longer valid.");
27380
+ console.log(" Registering this claim needs only a ONE-TIME identity check, so");
27381
+ console.log(" falling back to browser verification. (Re-enrolling background");
27382
+ console.log(" push is a separate thing you need only for repeatable reads \u2014");
27383
+ console.log(" fetching your granted slice and polling CI.)");
27384
+ }
27385
+ storedPushToken = null;
27386
+ const hadAutoMarker = Boolean(readAutoMarker());
27387
+ clearPushTokenEnc();
27388
+ clearedLocalCredential = true;
27389
+ if (hadAutoMarker) {
27390
+ clearAutoMarker();
27391
+ console.log("\n Background dashboard updates are now OFF \u2014 that token was the");
27392
+ console.log(" credential they ran on. Turn them back on any time:");
27393
+ console.log(" terminalhire claim --push --keep-updated");
27394
+ }
27395
+ } else {
27396
+ refusedForPurpose = true;
27397
+ console.log("\n Registering a claim takes a one-time browser check, which the");
27398
+ console.log(" credential stored on this machine is not for. Falling back to it now.");
27399
+ console.log(" It was not revoked or changed \u2014 this refusal was about which");
27400
+ console.log(" action the credential is for, not whether it is still good.");
27401
+ }
27402
+ await acquireProofAuth();
27300
27403
  try {
27301
- refusalBody = await res.json();
27302
- } catch {
27404
+ res = await sendRegistration(true);
27405
+ } catch (err) {
27406
+ refuse(
27407
+ `terminalhire is unreachable (${err instanceof Error ? err.message : String(err)}), so the posting could not be revalidated.`
27408
+ );
27303
27409
  }
27410
+ refusalBody = await readRefusal(res);
27304
27411
  }
27305
27412
  if (expectLogin && res.status === 422 && refusalBody?.error === "unknown field(s): expectLogin") {
27306
27413
  try {
27307
27414
  res = await sendRegistration(false);
27308
- refusalBody = null;
27309
- if (!res.ok) {
27310
- try {
27311
- refusalBody = await res.json();
27312
- } catch {
27313
- }
27314
- }
27415
+ refusalBody = await readRefusal(res);
27315
27416
  } catch (err) {
27316
27417
  refuse(
27317
27418
  `terminalhire is unreachable (${err instanceof Error ? err.message : String(err)}), so the posting could not be revalidated.`
@@ -27333,43 +27434,61 @@ terminalhire claim: refusing to record \u2014 ${reason}
27333
27434
  if (!body || body.ok !== true) {
27334
27435
  refuse("malformed registration response from the server.");
27335
27436
  }
27437
+ const mintedToken = typeof body.pushToken === "string" && body.pushToken.length > 0 ? body.pushToken : null;
27438
+ if (refusedForPurpose && mintedToken) {
27439
+ try {
27440
+ await writePushTokenEnc(mintedToken);
27441
+ console.log("\n Your stored credential was refreshed \u2014 registering this claim");
27442
+ console.log(" rotated it, so the previous one is no longer valid.");
27443
+ } catch (err) {
27444
+ console.log("\n \u26A0 Registered, but the refreshed credential could not be stored here.");
27445
+ const reason = err instanceof Error ? err.message : String(err);
27446
+ for (const line of reason.split("\n")) console.log(` ${line}`);
27447
+ console.log(" Re-enrol before fetching a slice: terminalhire claim --push");
27448
+ }
27449
+ }
27336
27450
  return {
27337
27451
  claimId: typeof body.claimId === "string" ? body.claimId : null,
27338
27452
  claimantLogin: typeof body.claimantLogin === "string" ? body.claimantLogin : null,
27339
- // Existing-token auth deliberately gets no replacement from the server. Reuse
27340
- // the encrypted value we just authenticated with; proof auth receives a newly
27341
- // minted token exactly once in this private/no-store response.
27342
- pushToken: storedPushToken ?? (typeof body.pushToken === "string" && body.pushToken.length > 0 ? body.pushToken : null)
27453
+ // Existing-token auth deliberately gets no replacement from the server: reuse the
27454
+ // encrypted value we just authenticated with. The one exception is the rotation
27455
+ // above there the stored value is the superseded one, so it must not win.
27456
+ pushToken: refusedForPurpose && mintedToken ? mintedToken : storedPushToken ?? mintedToken
27343
27457
  };
27344
27458
  }
27459
+ function readCredentialDisposition({
27460
+ markerExists,
27461
+ enrolledTokenExists,
27462
+ enrollmentRevoked = false
27463
+ }) {
27464
+ if (!markerExists) return "store";
27465
+ if (enrollmentRevoked) return "store-and-clear-stale-marker";
27466
+ if (enrolledTokenExists) return "keep-enrolled";
27467
+ return "store-and-clear-stale-marker";
27468
+ }
27345
27469
  async function bootstrapFounderClaimEnrollment(claim, registration) {
27346
- if (!(claim.amountUSD > 0)) return { enrolled: false, reason: "free" };
27470
+ if (!(claim.amountUSD > 0)) return { stored: false, reason: "free" };
27347
27471
  if (!registration.pushToken || !registration.claimantLogin) {
27348
- return { enrolled: false, reason: "token-unavailable" };
27472
+ return { stored: false, reason: "token-unavailable" };
27473
+ }
27474
+ const disposition = readCredentialDisposition({
27475
+ markerExists: Boolean(readAutoMarker()),
27476
+ enrolledTokenExists: Boolean(await readPushTokenEnc().catch(() => null))
27477
+ });
27478
+ if (disposition === "keep-enrolled") {
27479
+ return { stored: false, reason: "kept-enrolled" };
27349
27480
  }
27350
27481
  try {
27351
27482
  await writePushTokenEnc(registration.pushToken);
27352
- const prior = readAutoMarker();
27353
- const now = (/* @__PURE__ */ new Date()).toISOString();
27354
- writeAutoMarker({
27355
- ...prior ?? {},
27356
- autoConsentedAt: prior?.autoConsentedAt ?? now,
27357
- version: AUTO_CONSENT_VERSION,
27358
- login: registration.claimantLogin
27359
- // Do not invent lastPushedAt/lastSnapshotHash here. If this is a fresh
27360
- // marker the immediate existing background path must push the newly
27361
- // recorded local claim; if it is an existing marker those fields remain
27362
- // truthful and the changed snapshot makes the normal gate fire.
27363
- });
27483
+ if (disposition === "store-and-clear-stale-marker") clearAutoMarker();
27364
27484
  } catch (err) {
27365
27485
  return {
27366
- enrolled: false,
27486
+ stored: false,
27367
27487
  reason: "local-write-failed",
27368
27488
  detail: err instanceof Error ? err.message : String(err)
27369
27489
  };
27370
27490
  }
27371
- const pushed = await runBackgroundClaimPush();
27372
- return pushed?.pushed ? { enrolled: true, reason: "ok" } : { enrolled: true, reason: pushed?.reason ?? "sync-failed" };
27491
+ return { stored: true, reason: "ok" };
27373
27492
  }
27374
27493
  async function cmdRecord(arg, flags = {}) {
27375
27494
  const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
@@ -27580,21 +27699,19 @@ terminalhire claim: refusing to record \u2014 read ${b.repoFullName}'s contribut
27580
27699
  ` registered with terminalhire${claim.approval.claimId ? ` (server claim ${claim.approval.claimId})` : ""}`
27581
27700
  );
27582
27701
  if (claim.amountUSD > 0) {
27583
- if (enrollment?.enrolled && enrollment.reason === "ok") {
27584
- console.log(" \u2713 claim updates enabled; push-only token stored encrypted on this machine");
27585
- console.log(" \u2713 score-free claim ledger synced for your founder-facing work record");
27586
- console.log(" Revoke any time: terminalhire claim --push --revoke");
27587
- } else if (enrollment?.enrolled) {
27588
- console.log(" \u2713 claim updates enabled; push-only token stored encrypted on this machine");
27589
- console.log(` \u26A0 initial claim-ledger sync did not finish (${enrollment.reason}).`);
27590
- console.log(" Retry: terminalhire claim --push --keep-updated");
27591
- console.log(" Revoke any time: terminalhire claim --push --revoke");
27702
+ if (enrollment?.stored) {
27703
+ console.log(" \u2713 credential for your granted slice + CI results stored encrypted here");
27704
+ console.log(" Background dashboard updates are NOT on (claiming does not enable them).");
27705
+ console.log(" Want them? terminalhire claim --push --keep-updated");
27706
+ console.log(" Remove the stored credential any time: terminalhire claim --push --revoke");
27707
+ } else if (enrollment?.reason === "kept-enrolled") {
27708
+ console.log(" \u2713 your existing enrolled credential is kept and covers this claim");
27592
27709
  } else {
27593
- console.log(" \u26A0 Claim recorded, but automatic claim updates were not fully enrolled.");
27710
+ console.log(" \u26A0 Claim recorded, but the credential could not be stored on this machine.");
27594
27711
  if (enrollment?.detail) {
27595
27712
  for (const line of String(enrollment.detail).split("\n")) console.log(` ${line}`);
27596
27713
  }
27597
- console.log(" Finish setup: terminalhire claim --push --keep-updated");
27714
+ console.log(" Store it before fetching your slice: terminalhire claim --push");
27598
27715
  }
27599
27716
  }
27600
27717
  if (claim.approval.state === "pending") {
@@ -27726,6 +27843,17 @@ async function cmdList(active) {
27726
27843
  }
27727
27844
  await syncFounderApprovals(claims, list);
27728
27845
  list = claims.listClaims({ active });
27846
+ await syncFounderVerdicts({
27847
+ claimsModule: claims,
27848
+ targets: list,
27849
+ readPushTokenEnc
27850
+ });
27851
+ list = claims.listClaims({ active });
27852
+ if (list.length === 0) {
27853
+ console.log(active ? "\nNo active claims." : "\nNo claims yet.");
27854
+ printMetric(claims.acceptedPRRate());
27855
+ return;
27856
+ }
27729
27857
  console.log(`
27730
27858
  ${list.length} ${active ? "active " : ""}claim${list.length === 1 ? "" : "s"}:
27731
27859
  `);
@@ -27874,6 +28002,12 @@ async function cmdStatus(id) {
27874
28002
  approvalsChecked = syncRes.approvalsChecked;
27875
28003
  approvalsUnavailable = syncRes.approvalsUnavailable;
27876
28004
  targets = id ? [claims.findClaim(id)].filter(Boolean) : claims.listClaims();
28005
+ await syncFounderVerdicts({
28006
+ claimsModule: claims,
28007
+ targets,
28008
+ readPushTokenEnc
28009
+ });
28010
+ targets = id ? [claims.findClaim(id)].filter(Boolean) : claims.listClaims();
27877
28011
  console.log("\n Founder claims:");
27878
28012
  for (const c of targets.filter((claim) => Boolean(claim.approval))) {
27879
28013
  console.log(` ${founderClaimStanding(c, approvalsChecked)} \u2014 ${c.title}`);
@@ -28180,35 +28314,61 @@ async function ensureForkExists(repoFullName, ghUser) {
28180
28314
  if (!isFork) throw new Error(`fork ${forkFullName} created but could not be verified as a fork`);
28181
28315
  return forkFullName;
28182
28316
  }
28317
+ function shouldStatePending(claim, approvalsChecked) {
28318
+ return Boolean(claim?.approval) && claim.approval.state === "pending" && Boolean(approvalsChecked);
28319
+ }
28320
+ function startableRow(c) {
28321
+ const bits = [fmtClaimAmount(c), sanitizeText(c.title)];
28322
+ if (c.repoFullName) bits.push(sanitizeText(c.repoFullName));
28323
+ return bits.join(" \xB7 ");
28324
+ }
28325
+ async function pickStartableClaim(claims, { prompt = ask, isTTY = process.stdin.isTTY } = {}) {
28326
+ const active = claims.listClaims({ active: true });
28327
+ await syncFounderApprovals(claims, active);
28328
+ const startable = claims.listClaims({ active: true }).filter((c) => c.state === "claimed");
28329
+ if (startable.length === 0) {
28330
+ console.log("No claims ready to start. Claim one first: terminalhire claim <ref>");
28331
+ return null;
28332
+ }
28333
+ const interactive = Boolean(isTTY);
28334
+ console.log(`
28335
+ ${startable.length} claim${startable.length === 1 ? "" : "s"} ready to start:
28336
+ `);
28337
+ const width = String(startable.length).length;
28338
+ startable.forEach((c, i) => {
28339
+ console.log(` ${String(i + 1).padStart(width)}) ${startableRow(c)}`);
28340
+ if (!interactive) console.log(` terminalhire claim start ${c.id}`);
28341
+ });
28342
+ if (!interactive) {
28343
+ console.log("\nRun the command under the one you want to start.");
28344
+ return null;
28345
+ }
28346
+ const answer = await prompt(`
28347
+ Which one? (1-${startable.length}, or q to quit) `);
28348
+ if (answer === "" || /^q(uit)?$/i.test(answer)) return null;
28349
+ const n = Number.parseInt(answer, 10);
28350
+ if (!/^\d+$/.test(answer) || !Number.isInteger(n) || n < 1 || n > startable.length) {
28351
+ console.log(`
28352
+ Nothing started \u2014 '${answer}' is not one of 1-${startable.length}.`);
28353
+ return null;
28354
+ }
28355
+ return startable[n - 1].id;
28356
+ }
28183
28357
  async function cmdStart(id, flags = {}) {
28184
28358
  const claims = await Promise.resolve().then(() => (init_claims(), claims_exports));
28185
28359
  if (!id) {
28186
- let list = claims.listClaims({ active: true });
28187
- await syncFounderApprovals(claims, list);
28188
- const startable = claims.listClaims({ active: true }).filter((c) => c.state === "claimed");
28189
- if (startable.length === 0) {
28190
- console.log("No claims ready to start. Claim one first: terminalhire claim <ref>");
28191
- return;
28192
- }
28193
- console.log(
28194
- `
28195
- ${startable.length} claim${startable.length === 1 ? "" : "s"} ready to start:
28196
- `
28197
- );
28198
- for (const c of startable) {
28199
- console.log(` ${c.title}`);
28200
- console.log(` terminalhire claim start ${c.id}`);
28201
- }
28202
- console.log("\nRun the command under the one you want to start.");
28203
- return;
28360
+ const picked = await pickStartableClaim(claims);
28361
+ if (!picked) return;
28362
+ id = picked;
28204
28363
  }
28205
28364
  let claim = claims.findClaim(id);
28206
28365
  if (!claim) {
28207
28366
  console.error(`terminalhire claim: no claim with id '${id}'.`);
28208
28367
  process.exit(1);
28209
28368
  }
28369
+ let approvalsChecked = false;
28210
28370
  if (claim.approval?.state === "pending") {
28211
- await syncFounderApprovals(claims, [claim]);
28371
+ ({ approvalsChecked } = await syncFounderApprovals(claims, [claim]));
28212
28372
  claim = claims.findClaim(id);
28213
28373
  }
28214
28374
  if (claim.worktreePath) {
@@ -28229,19 +28389,14 @@ When it's done: terminalhire claim submit ${id}`);
28229
28389
  }
28230
28390
  if (claim.approval) {
28231
28391
  console.log(`
28232
- ${claim.title}`);
28233
- if (claim.approval.state === "pending") {
28234
- console.log(
28235
- "\n Access is pending \u2014 this approval-only posting needs the founder to approve"
28236
- );
28237
- console.log(" your claim before any work can be delivered. No fork was attempted: founder");
28238
- console.log(" postings are never forked or cloned; once approved, your work slice is");
28239
- console.log(" delivered through terminalhire.");
28240
- } else {
28241
- console.log("\n No fork was attempted \u2014 founder postings are never forked or cloned. Your");
28242
- console.log(" work slice is delivered through terminalhire, and your patch goes back the");
28243
- console.log(" same way.");
28244
- }
28392
+ ${sanitizeText(claim.title)}`);
28393
+ console.log("\n No fork was attempted \u2014 founder postings are never forked or cloned. Your");
28394
+ console.log(" work slice is delivered through terminalhire, and your patch goes back the");
28395
+ console.log(" same way.");
28396
+ if (shouldStatePending(claim, approvalsChecked)) {
28397
+ console.log("\n Access is pending \u2014 the founder has not approved your claim yet.");
28398
+ }
28399
+ printNextSteps([claim]);
28245
28400
  return;
28246
28401
  }
28247
28402
  if (flags.here) {
@@ -28491,10 +28646,10 @@ var CLAIM_EVENT_LABEL = {
28491
28646
  rejected: "the founder rejected"
28492
28647
  };
28493
28648
  var LINE_BREAKS = /\r\n|[\r\n\v\f\u0085\u2028\u2029]/;
28494
- var CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/g;
28649
+ var CONTROL_CHARS2 = /[\u0000-\u001F\u007F-\u009F]/g;
28495
28650
  function terminalSafeLines(raw) {
28496
28651
  if (typeof raw !== "string" || raw === "") return [];
28497
- return raw.split(LINE_BREAKS).map((l) => l.replace(CONTROL_CHARS, ""));
28652
+ return raw.split(LINE_BREAKS).map((l) => l.replace(CONTROL_CHARS2, ""));
28498
28653
  }
28499
28654
  function terminalSafeInline(raw) {
28500
28655
  return terminalSafeLines(raw).join(" ");
@@ -28577,6 +28732,26 @@ function requireFounderLoopClaim(claims, id, verb) {
28577
28732
  }
28578
28733
  return claim;
28579
28734
  }
28735
+ function retireRevokedReadCredential(status, body, what) {
28736
+ if (status !== 403 || body?.error !== PUSH_TOKEN_REFUSAL.INVALID) return false;
28737
+ const hadMarker = Boolean(readAutoMarker());
28738
+ clearPushTokenEnc();
28739
+ if (hadMarker) clearAutoMarker();
28740
+ console.error(
28741
+ `
28742
+ terminalhire claim: ${what} needs the stored credential, and the server says it has been revoked.`
28743
+ );
28744
+ console.error(" It has been removed from this machine, so nothing keeps retrying a");
28745
+ console.error(" credential that cannot work.");
28746
+ if (hadMarker) {
28747
+ console.error(" Background dashboard updates are now OFF \u2014 they ran on that credential.");
28748
+ }
28749
+ console.error(" Get a working one (browser verification): terminalhire claim --push");
28750
+ if (hadMarker) {
28751
+ console.error(" Add --keep-updated to that command to turn background updates back on.");
28752
+ }
28753
+ return true;
28754
+ }
28580
28755
  async function requireReadPushToken(what) {
28581
28756
  let stored = null;
28582
28757
  try {
@@ -28620,7 +28795,9 @@ async function cmdSlice(id, flags = {}) {
28620
28795
  } catch {
28621
28796
  }
28622
28797
  if (!res.ok) {
28623
- console.error(`terminalhire claim: ${renderServerRefusal(res.status, body)}`);
28798
+ if (!retireRevokedReadCredential(res.status, body, "fetching your granted slice")) {
28799
+ console.error(`terminalhire claim: ${renderServerRefusal(res.status, body)}`);
28800
+ }
28624
28801
  process.exit(1);
28625
28802
  }
28626
28803
  if (!body || body.ok !== true || !Array.isArray(body.files)) {
@@ -28748,7 +28925,9 @@ async function cmdRuns(id, flags = {}) {
28748
28925
  process.exit(1);
28749
28926
  }
28750
28927
  if (r.kind === "refusal") {
28751
- console.error(`terminalhire claim: ${renderServerRefusal(r.status, r.body)}`);
28928
+ if (!retireRevokedReadCredential(r.status, r.body, "reading CI results")) {
28929
+ console.error(`terminalhire claim: ${renderServerRefusal(r.status, r.body)}`);
28930
+ }
28752
28931
  process.exit(1);
28753
28932
  }
28754
28933
  console.log(`
@@ -29527,10 +29706,11 @@ async function cmdPush({ keepUpdated = false } = {}) {
29527
29706
  }
29528
29707
  let deleteToken = null;
29529
29708
  let pushToken = null;
29709
+ let syncBody = null;
29530
29710
  try {
29531
- const body = await res.json();
29532
- deleteToken = body?.deleteToken || null;
29533
- pushToken = body?.pushToken || null;
29711
+ syncBody = await res.json();
29712
+ deleteToken = syncBody?.deleteToken || null;
29713
+ pushToken = syncBody?.pushToken || null;
29534
29714
  } catch {
29535
29715
  }
29536
29716
  writeClaimPushMarker({
@@ -29540,23 +29720,55 @@ async function cmdPush({ keepUpdated = false } = {}) {
29540
29720
  lastPushedAt: consentedAt,
29541
29721
  lastSnapshotHash: computeSnapshotHash(pushed)
29542
29722
  });
29543
- if (autoConsent && pushToken) {
29723
+ if (pushToken) {
29544
29724
  try {
29545
- await writePushTokenEnc(pushToken);
29546
- writeAutoMarker({
29547
- autoConsentedAt: consentedAt,
29548
- version: AUTO_CONSENT_VERSION,
29549
- login,
29550
- lastPushedAt: consentedAt,
29551
- lastSnapshotHash: computeSnapshotHash(pushed)
29552
- });
29553
- console.log("\n \u2713 Background updates enabled \u2014 your dashboard will stay current");
29554
- console.log(" (at most once/day). Stop any time: terminalhire claim --push --revoke");
29725
+ if (autoConsent) {
29726
+ await writePushTokenEnc(pushToken);
29727
+ writeAutoMarker({
29728
+ autoConsentedAt: consentedAt,
29729
+ version: AUTO_CONSENT_VERSION,
29730
+ login,
29731
+ lastPushedAt: consentedAt,
29732
+ lastSnapshotHash: computeSnapshotHash(pushed)
29733
+ });
29734
+ console.log("\n \u2713 Background updates enabled \u2014 your dashboard will stay current");
29735
+ console.log(" (at most once/day). Stop any time: terminalhire claim --push --revoke");
29736
+ } else {
29737
+ const backgroundPushRevoked = syncBody?.[SYNC_BACKGROUND_PUSH_ACTIVE_FIELD] === false;
29738
+ const disposition = readCredentialDisposition({
29739
+ markerExists: Boolean(readAutoMarker()),
29740
+ enrolledTokenExists: Boolean(await readPushTokenEnc()),
29741
+ enrollmentRevoked: backgroundPushRevoked
29742
+ });
29743
+ if (disposition === "store") {
29744
+ await writePushTokenEnc(pushToken);
29745
+ console.log(
29746
+ "\n \u2713 Stored the credential for fetching your granted slice and CI results."
29747
+ );
29748
+ } else if (disposition === "keep-enrolled") {
29749
+ console.log("\n \u2713 Background updates are already active; keeping the credential");
29750
+ console.log(" they run on. Nothing about them changed.");
29751
+ } else {
29752
+ await writePushTokenEnc(pushToken);
29753
+ clearAutoMarker();
29754
+ console.log(
29755
+ "\n \u2713 Stored the credential for fetching your granted slice and CI results."
29756
+ );
29757
+ console.log(
29758
+ backgroundPushRevoked ? " Background dashboard updates are OFF \u2014 that enrolment was stopped or revoked." : " Background dashboard updates are OFF (their credential was gone)."
29759
+ );
29760
+ console.log(" Turn them back on any time: terminalhire claim --push --keep-updated");
29761
+ }
29762
+ }
29555
29763
  } catch (err) {
29556
- console.log("\n \u2713 Pushed, but could not enable background updates on this machine.");
29764
+ console.log(
29765
+ autoConsent ? "\n \u2713 Pushed, but could not enable background updates on this machine." : "\n \u2713 Pushed, but could not store the credential for slice fetch / CI polling."
29766
+ );
29557
29767
  const reason = err instanceof Error ? err.message : String(err);
29558
29768
  for (const line of reason.split("\n")) console.log(` ${line}`);
29559
- console.log(" Re-run `terminalhire claim --push --keep-updated` to retry.");
29769
+ console.log(
29770
+ autoConsent ? " Re-run `terminalhire claim --push --keep-updated` to retry." : " Re-run `terminalhire claim --push` to retry."
29771
+ );
29560
29772
  }
29561
29773
  } else if (backgroundEnableFailed(autoConsent, pushToken)) {
29562
29774
  console.log(
@@ -29844,8 +30056,10 @@ async function run() {
29844
30056
  export {
29845
30057
  AI_DISCLOSURE_NOTE,
29846
30058
  CLAIM_CONSENT_VERSION,
30059
+ PUSH_TOKEN_REFUSAL,
29847
30060
  REVISE_RECOVERY_STATES,
29848
30061
  SUBMIT_ACCEPTS,
30062
+ SYNC_BACKGROUND_PUSH_ACTIVE_FIELD,
29849
30063
  backgroundEnableFailed,
29850
30064
  buildAssignmentComment,
29851
30065
  buildPatchSubmission,
@@ -29853,6 +30067,7 @@ export {
29853
30067
  buildStandDownComment,
29854
30068
  buildSubmitBody,
29855
30069
  claimUpdatePatch,
30070
+ cmdPush,
29856
30071
  cmdRecord,
29857
30072
  cmdRuns,
29858
30073
  cmdSlice,
@@ -29879,7 +30094,9 @@ export {
29879
30094
  normalizeIntent,
29880
30095
  pickBodySource,
29881
30096
  pickExistingPr,
30097
+ pickStartableClaim,
29882
30098
  printNextSteps,
30099
+ readCredentialDisposition,
29883
30100
  renderClaimHistory,
29884
30101
  renderRunView,
29885
30102
  renderServerRefusal,
@@ -29893,6 +30110,7 @@ export {
29893
30110
  selectCompetingPrs,
29894
30111
  selectPushRemote,
29895
30112
  shouldRequestAssignment,
30113
+ shouldStatePending,
29896
30114
  sliceWorkDirFor,
29897
30115
  stakeDecision,
29898
30116
  startBranchFor,