@magnusekdahl/parallix 1.1.0 → 1.2.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 (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -18,9 +18,11 @@ const derivedRepoCache = new Map();
18
18
  function codexSandboxHint() {
19
19
  return 'Codex runtime cannot reach local Forgejo from Node subprocesses. Use the repo-local Codex config/profile that allows the workflow network path.';
20
20
  }
21
+ /** @param {string} rootDir @param {string} remoteName @returns {string} */
21
22
  function cacheKey(rootDir, remoteName) {
22
23
  return `${rootDir}::${remoteName}`;
23
24
  }
25
+ /** @param {string} rootDir @param {string} remoteName @returns {string|null} */
24
26
  function deriveRepoFromGitRemote(rootDir, remoteName) {
25
27
  if (derivedRepoCache.has(cacheKey(rootDir, remoteName))) {
26
28
  return derivedRepoCache.get(cacheKey(rootDir, remoteName));
@@ -46,12 +48,13 @@ function deriveRepoFromGitRemote(rootDir, remoteName) {
46
48
  }
47
49
  }
48
50
 
51
+ /** @param {string} [explicitUser] @returns {string} */
49
52
  function resolveForgejoUser(explicitUser) {
50
53
  return explicitUser || process.env.FORGEJO_USER || DEFAULT_FORGEJO_USER;
51
54
  }
52
55
 
53
56
  function resolveForgejoHome() {
54
- if (process.env.FORGEJO_HOME) return process.env.FORGEJO_HOME;
57
+ if (process.env.FORGEJO_HOME) {return process.env.FORGEJO_HOME;}
55
58
  const directLocal = path.join(process.cwd(), '.forgejo-local');
56
59
 
57
60
  // In test environments, we MUST NOT fall back to the real Forgejo home.
@@ -83,8 +86,9 @@ function resolveForgejoHome() {
83
86
  }
84
87
  }
85
88
 
89
+ /** @param {string} targetPath @returns {string|null} */
86
90
  function normalizePathForComparison(targetPath) {
87
- if (!targetPath) return null;
91
+ if (!targetPath) {return null;}
88
92
  try {
89
93
  return fs.realpathSync.native(targetPath);
90
94
  } catch (_) {
@@ -92,11 +96,16 @@ function normalizePathForComparison(targetPath) {
92
96
  }
93
97
  }
94
98
 
99
+ /**
100
+ * @param {string} targetPath
101
+ * @param {{forgejoHome?: string}} [options]
102
+ * @returns {boolean}
103
+ */
95
104
  function isForgejoPath(targetPath, options = {}) {
96
105
  const forgejoHome = options.forgejoHome || resolveForgejoHome();
97
106
  const normalizedTarget = normalizePathForComparison(targetPath);
98
107
  const normalizedForgejoHome = normalizePathForComparison(forgejoHome);
99
- if (!normalizedTarget || !normalizedForgejoHome) return false;
108
+ if (!normalizedTarget || !normalizedForgejoHome) {return false;}
100
109
  return normalizedTarget === normalizedForgejoHome || normalizedTarget.startsWith(normalizedForgejoHome + path.sep);
101
110
  }
102
111
 
@@ -109,12 +118,21 @@ function resolveForgejoSettings(rootDir = process.cwd()) {
109
118
  };
110
119
  }
111
120
 
121
+ /**
122
+ * @param {{forgejoUser?: string, token?: string}} options
123
+ * @returns {{forgejoUser: string, token: string|null}}
124
+ */
112
125
  function resolveForgejoAuth(options = {}) {
113
126
  const forgejoUser = resolveForgejoUser(options.forgejoUser);
114
127
  const token = options.token || readToken(forgejoUser);
115
128
  return { forgejoUser, token };
116
129
  }
117
130
 
131
+ /**
132
+ * @param {string} branch
133
+ * @param {{status?: number, statusCode?: number}} [apiErr]
134
+ * @returns {string}
135
+ */
118
136
  function formatPrLookupFailure(branch, apiErr = {}) {
119
137
  const sandboxNote = apiErr.status === 7 ? ` (${codexSandboxHint()})` : '';
120
138
  if (apiErr.statusCode === 401 || apiErr.statusCode === 403) {
@@ -126,7 +144,14 @@ function formatPrLookupFailure(branch, apiErr = {}) {
126
144
  return `failed to resolve PR for ${branch}${sandboxNote}`;
127
145
  }
128
146
 
147
+ /**
148
+ * @param {string} branch
149
+ * @param {string} [rootDir]
150
+ * @param {{forgejoUser?: string, token?: string, apiCall?: Function}} [options]
151
+ * @returns {{exists: boolean, error?: string, raw: string, number?: number, title?: string, state?: string, merged?: boolean, url?: string}}
152
+ */
129
153
  function getPrStatus(branch, rootDir = process.cwd(), options = {}) {
154
+ /** @type {{forgejoUser?: string, token?: string, apiCall?: Function}} */
130
155
  const {
131
156
  forgejoUser,
132
157
  token: providedToken,
@@ -198,7 +223,7 @@ function resolveTokenFile(user) {
198
223
  ];
199
224
 
200
225
  for (const candidate of candidates) {
201
- if (candidate && fs.existsSync(candidate)) return candidate;
226
+ if (candidate && fs.existsSync(candidate)) {return candidate;}
202
227
  }
203
228
 
204
229
  return null;
@@ -216,7 +241,7 @@ function readToken(user) {
216
241
  return process.env.FORGEJO_TOKEN;
217
242
  }
218
243
  const tokenFile = resolveTokenFile(resolvedUser);
219
- if (!tokenFile) return null;
244
+ if (!tokenFile) {return null;}
220
245
  return fs.readFileSync(tokenFile, 'utf8').trim();
221
246
  }
222
247
 
@@ -227,7 +252,15 @@ function readToken(user) {
227
252
  * @param {string} apiPath - Path relative to /api/v1/repos/<repo>
228
253
  * @param {string} token - Forgejo PAT
229
254
  * @param {object} [body] - Optional JSON body
230
- * @returns {{ ok: boolean, data: any, status: number|null }}
255
+ * @returns {{ ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null }}
256
+ */
257
+ /**
258
+ * @param {string} method
259
+ * @param {string} apiPath
260
+ * @param {string} token
261
+ * @param {object} [body]
262
+ * @param {{rootDir?: string}} [options]
263
+ * @returns {{ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null}}
231
264
  */
232
265
  function forgejoApi(method, apiPath, token, body, options = {}) {
233
266
  const { rootDir = process.cwd() } = options;
@@ -280,11 +313,22 @@ function forgejoApi(method, apiPath, token, body, options = {}) {
280
313
  ok: statusCode >= 200 && statusCode < 300,
281
314
  data,
282
315
  status: result.status,
283
- statusCode
316
+ statusCode,
317
+ stderr: result.stderr || null,
318
+ error: null
284
319
  };
285
320
  }
286
321
 
287
- function forgejoApiAsync(method, apiPath, token, body, options = {}) {
322
+ /**
323
+ * @param {string} method
324
+ * @param {string} apiPath
325
+ * @param {string} token
326
+ * @param {object} [body]
327
+ * @param {{rootDir?: string, timeout?: number}} [options]
328
+ * @returns {Promise<{ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null}>}
329
+ */
330
+ async function forgejoApiAsync(method, apiPath, token, body, options = {}) {
331
+ /** @type {{rootDir?: string, timeout?: number}} */
288
332
  const {
289
333
  rootDir = process.cwd(),
290
334
  timeout = HTTP_REQUEST_TIMEOUT
@@ -297,8 +341,8 @@ function forgejoApiAsync(method, apiPath, token, body, options = {}) {
297
341
 
298
342
  return new Promise((resolve) => {
299
343
  let settled = false;
300
- const finish = (result) => {
301
- if (settled) return;
344
+ const finish = /** @param {{ok: boolean, data: any, status: number|null, statusCode: number|null, stderr: string|null, error: string|null}} result */ (result) => {
345
+ if (settled) {return;}
302
346
  settled = true;
303
347
  resolve(result);
304
348
  };
@@ -311,10 +355,10 @@ function forgejoApiAsync(method, apiPath, token, body, options = {}) {
311
355
  'Content-Type': 'application/json',
312
356
  ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {})
313
357
  }
314
- }, (res) => {
358
+ }, /** @param {import('http').IncomingMessage} res */ (res) => {
315
359
  let responseBody = '';
316
360
  res.setEncoding('utf8');
317
- res.on('data', chunk => {
361
+ res.on('data', /** @param {string|Buffer} chunk */ (chunk) => {
318
362
  responseBody += chunk;
319
363
  });
320
364
  res.on('end', () => {
@@ -327,22 +371,24 @@ function forgejoApiAsync(method, apiPath, token, body, options = {}) {
327
371
  }
328
372
  }
329
373
  finish({
330
- ok: res.statusCode >= 200 && res.statusCode < 300,
374
+ ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300,
331
375
  data,
332
376
  status: 0,
333
- statusCode: res.statusCode
377
+ statusCode: res.statusCode ?? null,
378
+ stderr: null,
379
+ error: null
334
380
  });
335
381
  });
336
382
  });
337
383
 
338
- req.on('error', (error) => {
384
+ req.on('error', /** @param {{code?: string, message?: string}} error */ (error) => {
339
385
  finish({
340
386
  ok: false,
341
387
  data: null,
342
388
  status: null,
343
389
  statusCode: null,
344
- stderr: error.message,
345
- error: ['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH'].includes(error.code) ? codexSandboxHint() : null
390
+ stderr: error.message || null,
391
+ error: ['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH'].includes(error.code || '') ? codexSandboxHint() : null
346
392
  });
347
393
  });
348
394
 
@@ -375,6 +421,13 @@ function forgejoApiAsync(method, apiPath, token, body, options = {}) {
375
421
  * @param {object} [options]
376
422
  * @returns {{ ok: boolean, url: string|null, error: string|null }}
377
423
  */
424
+ /**
425
+ * @param {string} branch
426
+ * @param {string} user
427
+ * @param {string} token
428
+ * @param {{rootDir?: string, apiCall?: Function, log?: Function, force?: boolean, forceWithLease?: boolean, verificationArea?: string, captureVerifiedTreeProofFn?: Function, assertVerifiedTreeProofFn?: Function}} [options]
429
+ * @returns {{ok: boolean, url?: string|null, error?: string|null, prNumber?: number}}
430
+ */
378
431
  function createPr(branch, user, token, options = {}) {
379
432
  const {
380
433
  rootDir = process.cwd(),
@@ -382,7 +435,6 @@ function createPr(branch, user, token, options = {}) {
382
435
  log = fmt.log.info,
383
436
  force = false,
384
437
  forceWithLease = false,
385
- gitFetch = fetchReviewBranch,
386
438
  verificationArea = null,
387
439
  captureVerifiedTreeProofFn = verification.captureVerifiedTreeProof,
388
440
  assertVerifiedTreeProofFn = verification.assertVerifiedTreeProof
@@ -394,7 +446,7 @@ function createPr(branch, user, token, options = {}) {
394
446
  } catch (_) {
395
447
  primaryBranch = 'main';
396
448
  }
397
- if (branch === primaryBranch) return { ok: false, error: `cannot create a PR from ${primaryBranch}` };
449
+ if (branch === primaryBranch) {return { ok: false, error: `cannot create a PR from ${primaryBranch}` };}
398
450
 
399
451
  // Resolve the PR base: for feature-branch missions the PR targets the recorded
400
452
  // base branch; for legacy missions it falls back to the primary branch so
@@ -420,7 +472,7 @@ function createPr(branch, user, token, options = {}) {
420
472
  const apiUser = user;
421
473
  const apiToken = token;
422
474
  const resolvedVerificationArea = verificationArea || verification.resolveVerificationAdapter(rootDir).defaultArea;
423
- const authenticatedGitFetch = (branchName, dir, options = {}) => fetchReviewBranch(branchName, dir, {
475
+ const authenticatedGitFetch = /** @param {string} branchName @param {string} dir @param {{user?: string, token?: string}} [options] */ (branchName, dir, options = {}) => fetchReviewBranch(branchName, dir, {
424
476
  ...options,
425
477
  user: gitUser,
426
478
  token: gitToken,
@@ -463,13 +515,13 @@ function createPr(branch, user, token, options = {}) {
463
515
  if (!pushArgsResult.ok) {
464
516
  return { ok: false, error: pushArgsResult.error };
465
517
  }
466
- let pushArgs = pushArgsResult.pushArgs;
518
+ let pushArgs = pushArgsResult.pushArgs || [];
467
519
  let pushResult = git.git(pushArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: cLocaleEnv() });
468
- if (pushResult.stdout) process.stdout.write(pushResult.stdout);
469
- if (pushResult.stderr) process.stderr.write(pushResult.stderr);
520
+ if (pushResult.stdout) {process.stdout.write(pushResult.stdout);}
521
+ if (pushResult.stderr) {process.stderr.write(pushResult.stderr);}
470
522
 
471
523
  if (pushResult.status !== 0) {
472
- if (forceWithLease && isStaleInfoPushRejection(pushResult)) {
524
+ if (forceWithLease && isStaleInfoPushRejection(/** @type {{status?: number, stderr?: string, stdout?: string}} */ (pushResult))) {
473
525
  log(`Stale push rejection for ${branch}; fetching and retrying...`);
474
526
  pushArgsResult = buildCreatePrPushArgs(branch, remoteUrl, rootDir, {
475
527
  force,
@@ -480,10 +532,10 @@ function createPr(branch, user, token, options = {}) {
480
532
  if (!pushArgsResult.ok) {
481
533
  return { ok: false, error: pushArgsResult.error };
482
534
  }
483
- pushArgs = pushArgsResult.pushArgs;
535
+ pushArgs = pushArgsResult.pushArgs || [];
484
536
  pushResult = git.git(pushArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: cLocaleEnv() });
485
- if (pushResult.stdout) process.stdout.write(pushResult.stdout);
486
- if (pushResult.stderr) process.stderr.write(pushResult.stderr);
537
+ if (pushResult.stdout) {process.stdout.write(pushResult.stdout);}
538
+ if (pushResult.stderr) {process.stderr.write(pushResult.stderr);}
487
539
  }
488
540
  if (pushResult.status !== 0) {
489
541
  const pushError = (pushResult.stderr || pushResult.stdout || '').trim();
@@ -494,9 +546,9 @@ function createPr(branch, user, token, options = {}) {
494
546
  // 3. Check if an OPEN PR already exists for this branch
495
547
  const existingPrLookup = resolvePrAccess(branch, apiToken, { apiCall, slug, onlyOpen: true, forgejoUser: apiUser, rootDir });
496
548
 
497
- if (isApiErrorResult(existingPrLookup)) {
498
- const apiErr = existingPrLookup._apiError;
499
- return { ok: false, error: `failed to check existing PR: ${apiErr.error || 'API error'}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}` };
549
+ if (existingPrLookup && isApiErrorResult(existingPrLookup)) {
550
+ const apiErr = /** @type {{error?: string, status?: number}} */ (existingPrLookup._apiError || {});
551
+ return { ok: false, error: `failed to check existing PR: ${(apiErr.error || 'API error')}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}` };
500
552
  }
501
553
 
502
554
  // 4. Return existing PR if already present
@@ -526,12 +578,18 @@ function createPr(branch, user, token, options = {}) {
526
578
  return { ok: true, url: createResult.data.html_url, prNumber: createResult.data.number };
527
579
  }
528
580
 
581
+ /**
582
+ * @param {string} branch
583
+ * @param {string|null} token
584
+ * @param {{apiCall?: Function, slug?: string|null, onlyOpen?: boolean, forgejoUser?: string, rootDir?: string}} [options]
585
+ * @returns {number|null|{_apiError?: object, _notFound?: boolean}}
586
+ */
529
587
  function getPrNumber(branch, token, options = {}) {
530
588
  const resolved = resolvePrAccess(branch, token, options);
531
589
  if (!resolved || isApiErrorResult(resolved)) {
532
590
  return resolved;
533
591
  }
534
- return resolved.prNumber;
592
+ return resolved.prNumber ?? null;
535
593
  }
536
594
 
537
595
  /**
@@ -550,7 +608,14 @@ function getPrNumber(branch, token, options = {}) {
550
608
  * @param {object} [options]
551
609
  * @returns {string|null} The PR author's login, or null if undeterminable.
552
610
  */
611
+ /**
612
+ * @param {string} branch
613
+ * @param {string} token
614
+ * @param {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string, rootDir?: string}} [options]
615
+ * @returns {string|null}
616
+ */
553
617
  function getPrAuthor(branch, token, options = {}) {
618
+ /** @type {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string, rootDir?: string}} */
554
619
  const {
555
620
  apiCall = forgejoApi,
556
621
  resolvePrNumber = getPrNumber,
@@ -560,13 +625,19 @@ function getPrAuthor(branch, token, options = {}) {
560
625
  const slugMatch = branch.match(/^mission\/(task-\d+)/);
561
626
  const slug = slugMatch ? slugMatch[1] : null;
562
627
  const prNumber = resolvePrNumber(branch, token, { apiCall, slug, forgejoUser, rootDir });
563
- if (isApiErrorResult(prNumber) || !prNumber) return null;
628
+ if (isApiErrorResult(prNumber) || !prNumber) {return null;}
564
629
 
565
630
  const prRes = apiCall('GET', `/pulls/${prNumber}`, token, undefined, { rootDir });
566
- if (!prRes.ok || !prRes.data || !prRes.data.user) return null;
631
+ if (!prRes.ok || !prRes.data || !prRes.data.user) {return null;}
567
632
  return prRes.data.user.login || null;
568
633
  }
569
634
 
635
+ /**
636
+ * @param {string} branch
637
+ * @param {string|null} token
638
+ * @param {{apiCall?: Function, pageSize?: number, maxPages?: number, slug?: string|null, onlyOpen?: boolean, forgejoUser?: string, rootDir?: string, reportNotFound?: boolean}} [options]
639
+ * @returns {{prNumber?: number, token?: string, _apiError?: object, _notFound?: boolean}|null}
640
+ */
570
641
  function resolvePrAccess(branch, token, options = {}) {
571
642
  const {
572
643
  apiCall = forgejoApi,
@@ -581,6 +652,7 @@ function resolvePrAccess(branch, token, options = {}) {
581
652
  let lastApiError = null;
582
653
  let sawSuccessfulLookup = false;
583
654
 
655
+ /** @param {string} state @param {string} t */
584
656
  const searchInState = (state, t) => {
585
657
  let consecutiveErrors = 0;
586
658
  for (let page = 1; page <= maxPages; page += 1) {
@@ -618,16 +690,17 @@ function resolvePrAccess(branch, token, options = {}) {
618
690
  return null;
619
691
  };
620
692
 
693
+ /** @param {string} t */
621
694
  const doLookup = (t) => {
622
695
  const openResult = searchInState('open', t);
623
- if (openResult) return openResult;
624
- if (!onlyOpen) return searchInState('all', t);
696
+ if (openResult) {return openResult;}
697
+ if (!onlyOpen) {return searchInState('all', t);}
625
698
  return null;
626
699
  };
627
700
 
628
701
  // 1. Try with the provided token
629
- let prNumber = doLookup(token);
630
- if (prNumber) return { prNumber, token };
702
+ let prNumber = doLookup(token || '');
703
+ if (prNumber) {return { prNumber, token: token || undefined };}
631
704
 
632
705
  const currentUser = resolveForgejoUser(forgejoUser);
633
706
  const triedUsers = [currentUser];
@@ -645,7 +718,7 @@ function resolvePrAccess(branch, token, options = {}) {
645
718
  const fallbackToken = readToken(user);
646
719
  if (fallbackToken) {
647
720
  prNumber = doLookup(fallbackToken);
648
- if (prNumber) return { prNumber, token: fallbackToken };
721
+ if (prNumber) {return { prNumber, token: fallbackToken };}
649
722
  }
650
723
  }
651
724
  }
@@ -656,9 +729,11 @@ function resolvePrAccess(branch, token, options = {}) {
656
729
  const settings = resolveForgejoSettings(rootDir);
657
730
  fmt.log.info(`Current environment: FORGEJO_URL=${settings.url}, FORGEJO_REPO=${settings.repo}, FORGEJO_HOME=${resolveForgejoHome()}`);
658
731
  if (lastApiError) {
659
- fmt.log.warn(`API error encountered during lookup: status=${lastApiError.status}, error=${lastApiError.error || 'unknown'}`);
660
- if (lastApiError.stderr) {
661
- fmt.log.info(`API stderr: ${lastApiError.stderr}`);
732
+ /** @type {{status?: number, error?: string, stderr?: string|null}} */
733
+ const err = lastApiError;
734
+ fmt.log.warn(`API error encountered during lookup: status=${err.status || 0}, error=${err.error || 'unknown'}`);
735
+ if (err.stderr) {
736
+ fmt.log.info(`API stderr: ${err.stderr}`);
662
737
  }
663
738
  }
664
739
  fmt.log.info(`curl --version: ${curlCheck.status === 0 ? curlCheck.stdout.split('\n')[0] : 'failed to run curl'}`);
@@ -673,7 +748,14 @@ function resolvePrAccess(branch, token, options = {}) {
673
748
  return null;
674
749
  }
675
750
 
751
+ /**
752
+ * @param {string} baseSlug
753
+ * @param {string} token
754
+ * @param {{apiCall?: Function, pageSize?: number, maxPages?: number}} [options]
755
+ * @returns {Array<{number: number, title: string, html_url: string, head: string}>}
756
+ */
676
757
  function listOpenPrsForSlug(baseSlug, token, options = {}) {
758
+ /** @type {{apiCall?: Function, pageSize?: number, maxPages?: number}} */
677
759
  const {
678
760
  apiCall = forgejoApi,
679
761
  pageSize = 50,
@@ -687,11 +769,11 @@ function listOpenPrsForSlug(baseSlug, token, options = {}) {
687
769
  const result = apiCall('GET', `/pulls?state=open&page=${page}&limit=${pageSize}&sort=recentupdate&direction=desc`, token);
688
770
  if (!result.ok) {
689
771
  consecutiveErrors++;
690
- if (consecutiveErrors >= 3) break;
772
+ if (consecutiveErrors >= 3) {break;}
691
773
  continue;
692
774
  }
693
775
  consecutiveErrors = 0;
694
- if (!Array.isArray(result.data) || result.data.length === 0) break;
776
+ if (!Array.isArray(result.data) || result.data.length === 0) {break;}
695
777
 
696
778
  for (const pr of result.data) {
697
779
  const head = pr.head || {};
@@ -709,16 +791,23 @@ function listOpenPrsForSlug(baseSlug, token, options = {}) {
709
791
  }
710
792
  }
711
793
 
712
- if (result.data.length < pageSize) break;
794
+ if (result.data.length < pageSize) {break;}
713
795
  }
714
796
 
715
797
  return prs;
716
798
  }
717
799
 
800
+ /** @param {{_apiError?: object, _notFound?: boolean}} result @returns {boolean} */
718
801
  function isApiErrorResult(result) {
719
- return result && typeof result === 'object' && result._apiError && result._notFound === true;
802
+ return Boolean(result && typeof result === 'object' && result._apiError && result._notFound === true);
720
803
  }
721
804
 
805
+ /**
806
+ * @param {string} user
807
+ * @param {string} token
808
+ * @param {string} rootDir
809
+ * @returns {string}
810
+ */
722
811
  function authenticatedReviewUrl(user, token, rootDir = process.cwd()) {
723
812
  const { url: forgejoUrl, repo: forgejoRepo } = resolveForgejoSettings(rootDir);
724
813
  const url = new URL(forgejoUrl);
@@ -728,23 +817,31 @@ function authenticatedReviewUrl(user, token, rootDir = process.cwd()) {
728
817
  return `${protocol}//${user}:${token}@${host}/${forgejoRepo}.git`;
729
818
  }
730
819
 
820
+ /** @param {string} rootDir @returns {string|null} */
731
821
  function reviewRemoteUrl(rootDir = process.cwd()) {
732
822
  const { url: forgejoUrl, repo: forgejoRepo } = resolveForgejoSettings(rootDir);
733
- if (!forgejoUrl || !forgejoRepo) return null;
823
+ if (!forgejoUrl || !forgejoRepo) {return null;}
734
824
  const url = new URL(forgejoUrl);
735
825
  return `${url.protocol}//${url.host}/${forgejoRepo}.git`;
736
826
  }
737
827
 
828
+ /**
829
+ * @param {string} user
830
+ * @param {string} token
831
+ * @param {string} rootDir
832
+ * @param {{verificationProof?: object|null, assertVerifiedTreeProofFn?: Function, gitRunner?: Function}} [opts]
833
+ * @returns {{ok: boolean, skipped?: boolean, status?: number, stderr?: string, error?: string|null}}
834
+ */
738
835
  function syncPrimaryBaseline(user, token, rootDir = process.cwd(), {
739
836
  verificationProof = null,
740
837
  assertVerifiedTreeProofFn = verification.assertVerifiedTreeProof,
741
838
  gitRunner = git.git
742
839
  } = {}) {
743
- let primaryBranch = 'main';
840
+ let primaryBranchName = 'main';
744
841
  try {
745
- primaryBranch = getPrimaryBranch(rootDir);
842
+ primaryBranchName = getPrimaryBranch(rootDir);
746
843
  } catch (_) {
747
- primaryBranch = 'main';
844
+ primaryBranchName = 'main';
748
845
  }
749
846
 
750
847
  const proofCheck = assertVerifiedTreeProofFn(verificationProof, rootDir, { gitRunner });
@@ -752,8 +849,8 @@ function syncPrimaryBaseline(user, token, rootDir = process.cwd(), {
752
849
  return { ok: false, error: proofCheck.error || 'verification-proof-mismatch' };
753
850
  }
754
851
 
755
- const primaryExists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${primaryBranch}`]);
756
- if (primaryExists.status !== 0) return { ok: true, skipped: true };
852
+ const primaryExists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${primaryBranchName}`]);
853
+ if (primaryExists.status !== 0) {return { ok: true, skipped: true };}
757
854
 
758
855
  const remoteUrl = authenticatedReviewUrl(user, token, rootDir);
759
856
  // Force-push: the review remote's primary branch is a server-side mirror of our
@@ -761,7 +858,7 @@ function syncPrimaryBaseline(user, token, rootDir = process.cwd(), {
761
858
  // sync. Without --force a diverged remote primary (rebases, amended baseline
762
859
  // commits) rejects this as a non-fast-forward, aborting the sync and breaking
763
860
  // the review loop. See missions/task-1318 "Required Forgejo fix".
764
- const result = gitRunner(['-C', rootDir, 'push', '--force', remoteUrl, `${primaryBranch}:${primaryBranch}`], {
861
+ const result = gitRunner(['-C', rootDir, 'push', '--force', remoteUrl, `${primaryBranchName}:${primaryBranchName}`], {
765
862
  stdio: 'pipe'
766
863
  });
767
864
 
@@ -773,6 +870,14 @@ function syncPrimaryBaseline(user, token, rootDir = process.cwd(), {
773
870
  };
774
871
  }
775
872
 
873
+ /**
874
+ * @param {string} baseBranch
875
+ * @param {string} user
876
+ * @param {string} token
877
+ * @param {string} rootDir
878
+ * @param {{gitRunner?: Function}} [opts]
879
+ * @returns {{ok: boolean, status?: number, stderr?: string, error?: string|null}}
880
+ */
776
881
  function ensureRemoteBaseBranch(baseBranch, user, token, rootDir = process.cwd(), {
777
882
  gitRunner = git.git
778
883
  } = {}) {
@@ -780,6 +885,7 @@ function ensureRemoteBaseBranch(baseBranch, user, token, rootDir = process.cwd()
780
885
  // syncPrimaryBaseline only mirrors the primary branch, so the PR base would
781
886
  // not exist on the review remote and PR creation would fail server-side.
782
887
  // Mirror the local feature base branch here before the PR is opened.
888
+ /** @type {{status: number}} */
783
889
  const exists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${baseBranch}`]);
784
890
  if (exists.status !== 0) {
785
891
  return { ok: false, error: `PR base branch ${baseBranch} does not exist locally` };
@@ -802,6 +908,13 @@ function ensureRemoteBaseBranch(baseBranch, user, token, rootDir = process.cwd()
802
908
  };
803
909
  }
804
910
 
911
+ /**
912
+ * @param {string} sourceRef
913
+ * @param {string} destinationRef
914
+ * @param {string} rootDir
915
+ * @param {{force?: boolean, forceWithLease?: boolean, user?: string, token?: string}} [opts]
916
+ * @returns {*}
917
+ */
805
918
  function pushReviewRef(sourceRef, destinationRef, rootDir = process.cwd(), {
806
919
  force = false,
807
920
  forceWithLease = false,
@@ -811,7 +924,7 @@ function pushReviewRef(sourceRef, destinationRef, rootDir = process.cwd(), {
811
924
  const remote = token
812
925
  ? authenticatedReviewUrl(user || resolveForgejoUser(), token, rootDir)
813
926
  : 'review';
814
- const pushArgs = ['-C', rootDir, 'push', remote];
927
+ const pushArgs = /** @type {string[]} */ (['-C', rootDir, 'push', remote]);
815
928
  if (forceWithLease) {
816
929
  pushArgs.push('--force-with-lease');
817
930
  } else if (force) {
@@ -819,11 +932,17 @@ function pushReviewRef(sourceRef, destinationRef, rootDir = process.cwd(), {
819
932
  }
820
933
  pushArgs.push(`${sourceRef}:${destinationRef}`);
821
934
  const result = git.git(pushArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
822
- if (result.stdout) process.stdout.write(result.stdout);
823
- if (result.stderr) process.stderr.write(result.stderr);
935
+ if (result.stdout) {process.stdout.write(result.stdout);}
936
+ if (result.stderr) {process.stderr.write(result.stderr);}
824
937
  return result;
825
938
  }
826
939
 
940
+ /**
941
+ * @param {string} branch
942
+ * @param {string} rootDir
943
+ * @param {{user?: string, token?: string}} options
944
+ * @returns {*}
945
+ */
827
946
  function fetchReviewBranch(branch, rootDir = process.cwd(), options = {}) {
828
947
  const { user, token } = options;
829
948
  const source = token
@@ -839,6 +958,7 @@ function fetchReviewBranch(branch, rootDir = process.cwd(), options = {}) {
839
958
  });
840
959
  }
841
960
 
961
+ /** @param {string} branch @param {string} rootDir @returns {{ok: boolean, ref?: string, sha?: string, error?: string}} */
842
962
  function resolveTrackingBranchSha(branch, rootDir = process.cwd()) {
843
963
  const candidateRefs = [`refs/remotes/review/${branch}`, `refs/remotes/origin/${branch}`];
844
964
  for (const ref of candidateRefs) {
@@ -856,7 +976,15 @@ function resolveTrackingBranchSha(branch, rootDir = process.cwd()) {
856
976
  };
857
977
  }
858
978
 
979
+ /**
980
+ * @param {string} branch
981
+ * @param {string} remoteUrl
982
+ * @param {string} rootDir
983
+ * @param {{force?: boolean, forceWithLease?: boolean, gitFetch?: Function, refreshTrackingRef?: boolean}} [options]
984
+ * @returns {{ok: boolean, pushArgs?: string[], error?: string}}
985
+ */
859
986
  function buildCreatePrPushArgs(branch, remoteUrl, rootDir = process.cwd(), options = {}) {
987
+ /** @type {{force?: boolean, forceWithLease?: boolean, gitFetch?: Function, refreshTrackingRef?: boolean}} */
860
988
  const {
861
989
  force = false,
862
990
  forceWithLease = false,
@@ -911,6 +1039,12 @@ function buildCreatePrPushArgs(branch, remoteUrl, rootDir = process.cwd(), optio
911
1039
  return { ok: true, pushArgs };
912
1040
  }
913
1041
 
1042
+ /**
1043
+ * @param {string} branch
1044
+ * @param {string} rootDir
1045
+ * @param {{user?: string, token?: string}} [opts]
1046
+ * @returns {*}
1047
+ */
914
1048
  function deleteReviewRef(branch, rootDir = process.cwd(), { user, token } = {}) {
915
1049
  const remote = token
916
1050
  ? authenticatedReviewUrl(user || resolveForgejoUser(), token, rootDir)
@@ -918,17 +1052,19 @@ function deleteReviewRef(branch, rootDir = process.cwd(), { user, token } = {})
918
1052
  const result = git.git(['-C', rootDir, 'push', remote, '--delete', branch], {
919
1053
  stdio: ['ignore', 'pipe', 'pipe']
920
1054
  });
921
- if (result.stdout) process.stdout.write(result.stdout);
922
- if (result.stderr) process.stderr.write(result.stderr);
1055
+ if (result.stdout) {process.stdout.write(result.stdout);}
1056
+ if (result.stderr) {process.stderr.write(result.stderr);}
923
1057
  return result;
924
1058
  }
925
1059
 
1060
+ /** @param {string} commit @param {string} rootDir @returns {*} */
926
1061
  function verifyCommitExists(commit, rootDir = process.cwd()) {
927
1062
  return git.git(['-C', rootDir, 'rev-parse', '--verify', `${commit}^{commit}`], {
928
1063
  stdio: ['ignore', 'pipe', 'pipe']
929
1064
  });
930
1065
  }
931
1066
 
1067
+ /** @param {string} commit @param {string} [remoteRef] @param {string} rootDir @returns {*} */
932
1068
  function remoteRefContainsCommit(commit, remoteRef = `refs/remotes/review/${getPrimaryBranch(process.cwd())}`, rootDir = process.cwd()) {
933
1069
  return git.git(['-C', rootDir, 'merge-base', '--is-ancestor', commit, remoteRef], {
934
1070
  stdio: ['ignore', 'pipe', 'pipe']
@@ -939,10 +1075,12 @@ function remoteRefContainsCommit(commit, remoteRef = `refs/remotes/review/${getP
939
1075
  // are localized to the operator's locale. We parse these messages to decide
940
1076
  // control flow, so force a stable C locale on git calls whose stderr we
941
1077
  // inspect, keeping detection language-independent.
1078
+ /** @returns {Record<string, string>} */
942
1079
  function cLocaleEnv() {
943
1080
  return { ...process.env, LC_ALL: 'C', LANG: 'C' };
944
1081
  }
945
1082
 
1083
+ /** @param {{stderr?: string, stdout?: string}} result @returns {string} */
946
1084
  function pushOutput(result) {
947
1085
  return [result && result.stderr, result && result.stdout]
948
1086
  .filter(Boolean)
@@ -955,12 +1093,14 @@ function pushOutput(result) {
955
1093
  // "fatal: could not find remote ref refs/heads/<branch>". This must be
956
1094
  // distinguished from real fetch failures (auth/network), which also exit
957
1095
  // non-zero but do not carry this message and should still abort.
1096
+ /** @param {{stderr?: string, stdout?: string}} result @returns {boolean} */
958
1097
  function isMissingRemoteRef(result) {
959
1098
  const output = pushOutput(result).toLowerCase();
960
1099
  return output.includes('could not find remote ref')
961
1100
  || output.includes("couldn't find remote ref");
962
1101
  }
963
1102
 
1103
+ /** @param {{status?: number, stderr?: string, stdout?: string}} result @returns {boolean} */
964
1104
  function isStaleInfoPushRejection(result) {
965
1105
  return result && result.status !== 0 && /\bstale info\b|\bstale ref\b|fetch first/i.test(pushOutput(result));
966
1106
  }
@@ -976,6 +1116,14 @@ function isStaleInfoPushRejection(result) {
976
1116
  * @param {object} [options] - Optional overrides
977
1117
  * @returns {{ state: string, submittedAt: string }|null} Most recent eligible review, or null
978
1118
  */
1119
+ /**
1120
+ * @param {string} branch
1121
+ * @param {string} reviewerUser
1122
+ * @param {string} sinceIso
1123
+ * @param {string} token
1124
+ * @param {{apiCall?: Function, forgejoUser?: string, rootDir?: string}} [options]
1125
+ * @returns {{state: string, submittedAt: string}|null}
1126
+ */
979
1127
  function getLatestReview(branch, reviewerUser, sinceIso, token, options = {}) {
980
1128
  const {
981
1129
  apiCall = forgejoApi,
@@ -986,27 +1134,33 @@ function getLatestReview(branch, reviewerUser, sinceIso, token, options = {}) {
986
1134
  const slugMatch = branch.match(/^mission\/(task-\d+)/);
987
1135
  const slug = slugMatch ? slugMatch[1] : null;
988
1136
  const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
989
- if (!prAccess || isApiErrorResult(prAccess)) return null;
1137
+ if (!prAccess || isApiErrorResult(prAccess)) {return null;}
990
1138
 
991
1139
  const result = apiCall('GET', `/pulls/${prAccess.prNumber}/reviews`, prAccess.token);
992
- if (!result.ok || !Array.isArray(result.data)) return null;
1140
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
993
1141
 
994
1142
  const since = new Date(sinceIso).getTime();
995
1143
 
996
1144
  const eligible = result.data
997
- .filter(r => {
1145
+ .filter(/** @param {{user?: {login?: string}, submitted_at?: string, created_at?: string, state?: string}} r */ r => {
998
1146
  const user = (r.user || {}).login;
999
1147
  const submittedAt = r.submitted_at || r.created_at || '';
1000
1148
  const submitted = submittedAt ? new Date(submittedAt).getTime() : 0;
1001
1149
  return user === reviewerUser && submitted >= since;
1002
1150
  })
1003
- .map(r => ({ state: r.state, submittedAt: r.submitted_at || r.created_at || '' }))
1004
- .sort((a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1151
+ .map(/** @param {{state?: string, submitted_at?: string, created_at?: string}} r */ r => ({ state: r.state, submittedAt: r.submitted_at || r.created_at || '' }))
1152
+ .sort(/** @param {{submittedAt: string}} a @param {{submittedAt: string}} b */ (a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1005
1153
 
1006
1154
  return eligible.length > 0 ? eligible[eligible.length - 1] : null;
1007
1155
  }
1008
1156
 
1157
+ /**
1158
+ * @param {string} branch
1159
+ * @param {{forgejoUser?: string, token?: string, apiCall?: Function, rootDir?: string}} [options]
1160
+ * @returns {{ok: boolean, error?: string, reviewState?: string|null, prNumber?: number, defaultUserApproved?: boolean, raw?: string}}
1161
+ */
1009
1162
  function getLatestReviewDecision(branch, options = {}) {
1163
+ /** @type {{forgejoUser?: string, token?: string, apiCall?: Function, rootDir?: string}} */
1010
1164
  const {
1011
1165
  forgejoUser,
1012
1166
  token: providedToken,
@@ -1023,13 +1177,13 @@ function getLatestReviewDecision(branch, options = {}) {
1023
1177
  }
1024
1178
 
1025
1179
  const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1026
- if (isApiErrorResult(prAccess)) {
1027
- const apiErr = prAccess._apiError;
1180
+ if (prAccess && isApiErrorResult(prAccess)) {
1181
+ const apiErr = /** @type {{error?: string, status?: number}} */ (prAccess._apiError || {});
1028
1182
  return {
1029
1183
  ok: false,
1030
1184
  error: 'api-failed',
1031
1185
  reviewState: null,
1032
- raw: `failed to resolve PR for ${branch}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}`
1186
+ raw: /** @type {string|undefined} */ (`failed to resolve PR for ${branch}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}`)
1033
1187
  };
1034
1188
  }
1035
1189
  if (!prAccess) {
@@ -1046,27 +1200,27 @@ function getLatestReviewDecision(branch, options = {}) {
1046
1200
  // has approved, not the current CLI session user.
1047
1201
  const defaultUserLogin = DEFAULT_FORGEJO_USER;
1048
1202
  const reviews = result.data
1049
- .map(review => ({
1203
+ .map(/** @param {{user?: {login?: string}, state?: string, submitted_at?: string, created_at?: string, dismissed?: boolean}} review */ (review) => ({
1050
1204
  user: (review.user || {}).login || '?',
1051
1205
  state: review.state || '',
1052
1206
  submittedAt: review.submitted_at || review.created_at || '',
1053
1207
  dismissed: !!review.dismissed
1054
1208
  }))
1055
- .filter(review => review.state && review.submittedAt && !review.dismissed)
1056
- .sort((a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1209
+ .filter(/** @param {{state: string, submittedAt: string, dismissed: boolean}} review */ (review) => review.state && review.submittedAt && !review.dismissed)
1210
+ .sort(/** @param {{submittedAt: string}} a @param {{submittedAt: string}} b */ (a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1057
1211
 
1058
1212
  if (reviews.length === 0) {
1059
1213
  return { ok: true, prNumber, reviewState: null, defaultUserApproved: false };
1060
1214
  }
1061
1215
 
1062
1216
  // Find the latest formal decision overall
1063
- const formalReviews = reviews.filter(r => r.state === 'APPROVED' || r.state === 'REQUEST_CHANGES');
1217
+ const formalReviews = reviews.filter(/** @param {{state: string}} r */ (r) => r.state === 'APPROVED' || r.state === 'REQUEST_CHANGES');
1064
1218
 
1065
1219
  const finalState = formalReviews.length > 0
1066
1220
  ? formalReviews[formalReviews.length - 1].state
1067
1221
  : reviews[reviews.length - 1].state;
1068
1222
 
1069
- const defaultUserApproved = reviews.some(r => r.user === defaultUserLogin && r.state === 'APPROVED');
1223
+ const defaultUserApproved = reviews.some(/** @param {{user: string, state: string}} r */ (r) => r.user === defaultUserLogin && r.state === 'APPROVED');
1070
1224
 
1071
1225
  return {
1072
1226
  ok: true,
@@ -1088,6 +1242,7 @@ function getLatestReviewDecision(branch, options = {}) {
1088
1242
  * @returns {string|null} Disposition value (CHANGES_MADE|PUSHBACK_ALL|PARKED|BLOCKED) or null
1089
1243
  */
1090
1244
  function getLatestDisposition(branch, implementerUser, sinceIso, token, options = {}) {
1245
+ /** @type {{apiCall?: Function, forgejoUser?: string, rootDir?: string}} */
1091
1246
  const {
1092
1247
  apiCall = forgejoApi,
1093
1248
  forgejoUser,
@@ -1097,80 +1252,82 @@ function getLatestDisposition(branch, implementerUser, sinceIso, token, options
1097
1252
  const slugMatch = branch.match(/^mission\/(task-\d+)/);
1098
1253
  const slug = slugMatch ? slugMatch[1] : null;
1099
1254
  const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1100
- if (isApiErrorResult(prAccess)) return null;
1101
- if (!prAccess) return null;
1255
+ if (prAccess && isApiErrorResult(prAccess)) {return null;}
1256
+ if (!prAccess) {return null;}
1102
1257
  const prNumber = prAccess.prNumber;
1103
1258
 
1104
1259
  const result = apiCall('GET', `/issues/${prNumber}/comments`, prAccess.token);
1105
- if (!result.ok || !Array.isArray(result.data)) return null;
1260
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1106
1261
 
1107
1262
  const since = new Date(sinceIso).getTime();
1108
1263
 
1109
1264
  const eligible = result.data
1110
- .filter(c => {
1265
+ .filter(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c) => {
1111
1266
  const user = (c.user || {}).login;
1112
1267
  const createdStr = c.created_at || '';
1113
1268
  const created = createdStr ? new Date(createdStr).getTime() : 0;
1114
1269
  const body = c.body || '';
1115
1270
  return user === implementerUser && created >= since && DISPOSITION_PATTERN.test(body);
1116
1271
  })
1117
- .map(c => {
1118
- const match = DISPOSITION_PATTERN.exec(c.body);
1272
+ .map(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c) => {
1273
+ const match = DISPOSITION_PATTERN.exec(c.body || '');
1119
1274
  return { disposition: match ? match[1] : null, createdAt: c.created_at || '' };
1120
1275
  })
1121
- .filter(e => e.disposition)
1122
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1276
+ .filter(/** @param {{disposition: string|null}} e */ (e) => e.disposition)
1277
+ .sort(/** @param {{createdAt: string}} a @param {{createdAt: string}} b */ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1123
1278
 
1124
1279
  return eligible.length > 0 ? eligible[eligible.length - 1].disposition : null;
1125
1280
  }
1126
1281
 
1127
- async function getLatestReviewForPr(prNumber, reviewerUser, sinceIso, token, options = {}) {
1282
+ async function getLatestReviewForPr(/** @type {number} */ prNumber, /** @type {string} */ reviewerUser, /** @type {string} */ sinceIso, /** @type {string} */ token, options = {}) {
1283
+ /** @type {{apiCall?: Function}} */
1128
1284
  const {
1129
1285
  apiCall = forgejoApiAsync
1130
1286
  } = options;
1131
1287
 
1132
1288
  const result = await apiCall('GET', `/pulls/${prNumber}/reviews`, token);
1133
- if (!result.ok || !Array.isArray(result.data)) return null;
1289
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1134
1290
 
1135
1291
  const since = new Date(sinceIso).getTime();
1136
1292
 
1137
1293
  const eligible = result.data
1138
- .filter(r => {
1294
+ .filter(/** @param {{user?: {login?: string}, submitted_at?: string, created_at?: string, state?: string}} r */ r => {
1139
1295
  const user = (r.user || {}).login;
1140
1296
  const submittedAt = r.submitted_at || r.created_at || '';
1141
1297
  const submitted = submittedAt ? new Date(submittedAt).getTime() : 0;
1142
1298
  return user === reviewerUser && submitted >= since;
1143
1299
  })
1144
- .map(r => ({ state: r.state, submittedAt: r.submitted_at || r.created_at || '' }))
1145
- .sort((a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1300
+ .map(/** @param {{state?: string, submitted_at?: string, created_at?: string}} r */ r => ({ state: r.state, submittedAt: r.submitted_at || r.created_at || '' }))
1301
+ .sort(/** @param {{submittedAt: string}} a @param {{submittedAt: string}} b */ (a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());
1146
1302
 
1147
1303
  return eligible.length > 0 ? eligible[eligible.length - 1] : null;
1148
1304
  }
1149
1305
 
1150
- async function getLatestDispositionForPr(prNumber, implementerUser, sinceIso, token, options = {}) {
1306
+ async function getLatestDispositionForPr(/** @type {number} */ prNumber, /** @type {string} */ implementerUser, /** @type {string} */ sinceIso, /** @type {string} */ token, options = {}) {
1307
+ /** @type {{apiCall?: Function}} */
1151
1308
  const {
1152
1309
  apiCall = forgejoApiAsync
1153
1310
  } = options;
1154
1311
 
1155
1312
  const result = await apiCall('GET', `/issues/${prNumber}/comments`, token);
1156
- if (!result.ok || !Array.isArray(result.data)) return null;
1313
+ if (!result.ok || !Array.isArray(result.data)) {return null;}
1157
1314
 
1158
1315
  const since = new Date(sinceIso).getTime();
1159
1316
 
1160
1317
  const eligible = result.data
1161
- .filter(c => {
1318
+ .filter(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c) => {
1162
1319
  const user = (c.user || {}).login;
1163
1320
  const createdAt = c.created_at || '';
1164
1321
  const created = createdAt ? new Date(createdAt).getTime() : 0;
1165
1322
  const body = c.body || '';
1166
1323
  return user === implementerUser && created >= since && DISPOSITION_PATTERN.test(body);
1167
1324
  })
1168
- .map(c => {
1169
- const match = DISPOSITION_PATTERN.exec(c.body);
1325
+ .map(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c) => {
1326
+ const match = DISPOSITION_PATTERN.exec(c.body || '');
1170
1327
  return { disposition: match ? match[1] : null, createdAt: c.created_at || '' };
1171
1328
  })
1172
- .filter(e => e.disposition)
1173
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1329
+ .filter(/** @param {{disposition: string|null}} e */ (e) => e.disposition)
1330
+ .sort(/** @param {{createdAt: string}} a @param {{createdAt: string}} b */ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1174
1331
 
1175
1332
  return eligible.length > 0 ? eligible[eligible.length - 1].disposition : null;
1176
1333
  }
@@ -1181,9 +1338,10 @@ async function getLatestDispositionForPr(prNumber, implementerUser, sinceIso, to
1181
1338
  * @param {string} branch - Mission branch (e.g. 'mission/task-089')
1182
1339
  * @param {string} token - Forgejo PAT
1183
1340
  * @param {string} body - Comment body (markdown)
1184
- * @returns {{ ok: boolean, data: any, status: number|null }}
1341
+ * @returns {{ ok: boolean, data: any, status: number|null, error?: string, raw?: string }}
1185
1342
  */
1186
1343
  function postComment(branch, token, body, options = {}) {
1344
+ /** @type {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string}} */
1187
1345
  const {
1188
1346
  apiCall = forgejoApi,
1189
1347
  resolvePrNumber = getPrNumber,
@@ -1193,10 +1351,10 @@ function postComment(branch, token, body, options = {}) {
1193
1351
  const slug = slugMatch ? slugMatch[1] : null;
1194
1352
  const prNumber = resolvePrNumber(branch, token, { apiCall, slug, forgejoUser });
1195
1353
  if (isApiErrorResult(prNumber)) {
1196
- const apiErr = prNumber._apiError;
1197
- return { ok: false, data: null, status: null, error: 'api-failed', raw: `failed to resolve PR for ${branch}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}` };
1354
+ const apiErr = prNumber._apiError || {};
1355
+ return { ok: false, data: null, status: null, error: 'api-failed', raw: `failed to resolve PR for ${branch}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}` };
1198
1356
  }
1199
- if (!prNumber) return { ok: false, data: null, status: null, error: 'pr-not-found' };
1357
+ if (!prNumber) {return { ok: false, data: null, status: null, error: 'pr-not-found' };}
1200
1358
  return apiCall('POST', `/issues/${prNumber}/comments`, token, { body });
1201
1359
  }
1202
1360
 
@@ -1213,9 +1371,10 @@ const REVIEW_OUTCOME_MAP = {
1213
1371
  * @param {string} token - Forgejo PAT
1214
1372
  * @param {string} outcome - 'approve' | 'request-changes' | 'comment'
1215
1373
  * @param {string} summary - Review summary text
1216
- * @returns {{ ok: boolean, data: any, status: number|null }}
1374
+ * @returns {{ ok: boolean, data: any, status: number|null, error?: string, raw?: string }}
1217
1375
  */
1218
1376
  function postReview(branch, token, outcome, summary, options = {}) {
1377
+ /** @type {{apiCall?: Function, resolvePrNumber?: Function, forgejoUser?: string}} */
1219
1378
  const {
1220
1379
  apiCall = forgejoApi,
1221
1380
  resolvePrNumber = getPrNumber,
@@ -1225,18 +1384,19 @@ function postReview(branch, token, outcome, summary, options = {}) {
1225
1384
  const slug = slugMatch ? slugMatch[1] : null;
1226
1385
  const prNumber = resolvePrNumber(branch, token, { apiCall, slug, forgejoUser });
1227
1386
  if (isApiErrorResult(prNumber)) {
1228
- const apiErr = prNumber._apiError;
1229
- return { ok: false, data: null, status: null, error: 'api-failed', raw: `failed to resolve PR for ${branch}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}` };
1387
+ const apiErr = prNumber._apiError || {};
1388
+ return { ok: false, data: null, status: null, error: 'api-failed', raw: `failed to resolve PR for ${branch}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}` };
1230
1389
  }
1231
- if (!prNumber) return { ok: false, data: null, status: null, error: 'pr-not-found' };
1390
+ if (!prNumber) {return { ok: false, data: null, status: null, error: 'pr-not-found' };}
1232
1391
 
1233
1392
  // Resolve current head SHA for the PR to avoid submission failures if PR updated
1234
1393
  const prRes = apiCall('GET', `/pulls/${prNumber}`, token);
1235
1394
  const commit_id = (prRes.ok && prRes.data && prRes.data.head) ? prRes.data.head.sha : null;
1236
1395
 
1237
- const event = REVIEW_OUTCOME_MAP[outcome];
1238
- if (!event) return { ok: false, data: null, status: null, error: `unsupported-outcome: ${outcome}` };
1396
+ const event = /** @type {'APPROVED'|'REQUEST_CHANGES'|'COMMENT'|undefined} */ (REVIEW_OUTCOME_MAP[/** @type {keyof typeof REVIEW_OUTCOME_MAP} */ (outcome)]);
1397
+ if (!event) {return { ok: false, data: null, status: null, error: `unsupported-outcome: ${outcome}` };}
1239
1398
 
1399
+ /** @type {{body: string, event: string, commit_id?: string|null}} */
1240
1400
  const payload = { body: summary, event };
1241
1401
  if (commit_id) {
1242
1402
  payload.commit_id = commit_id;
@@ -1260,6 +1420,7 @@ function postReview(branch, token, outcome, summary, options = {}) {
1260
1420
  * @returns {Promise<boolean>} True if Forgejo is reachable, false otherwise
1261
1421
  */
1262
1422
  function forgejoAvailable(url = process.env.FORGEJO_URL || 'http://localhost:3300', options = {}) {
1423
+ /** @type {{request?: Function, timeout?: number}} */
1263
1424
  const {
1264
1425
  request = http.request,
1265
1426
  timeout = HTTP_REQUEST_TIMEOUT
@@ -1267,9 +1428,9 @@ function forgejoAvailable(url = process.env.FORGEJO_URL || 'http://localhost:330
1267
1428
  const targetUrl = new URL(url);
1268
1429
 
1269
1430
  return new Promise((resolve) => {
1270
- const req = request(targetUrl, { method: 'GET', timeout }, (res) => {
1431
+ const req = request(targetUrl, { method: 'GET', timeout }, (/** @type {import('http').IncomingMessage} */ res) => {
1271
1432
  req.destroy();
1272
- resolve(res.statusCode !== null && res.statusCode >= 200 && res.statusCode < 300);
1433
+ resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300);
1273
1434
  });
1274
1435
  req.on('error', () => resolve(false));
1275
1436
  req.on('timeout', () => {
@@ -1280,7 +1441,8 @@ function forgejoAvailable(url = process.env.FORGEJO_URL || 'http://localhost:330
1280
1441
  });
1281
1442
  }
1282
1443
 
1283
- function syncMerged(branch, mergedCommit, options = {}) {
1444
+ function syncMerged(/** @type {string} */ branch, /** @type {string} */ mergedCommit, options = {}) {
1445
+ /** @type {{forgejoUser?: string, rootDir?: string, token?: string|null, baseBranch?: string|null, apiCall?: Function, resolvePrNumber?: Function, gitPush?: Function, gitFetch?: Function, gitContainsCommit?: Function, gitDelete?: Function, verifyCommit?: Function, log?: Function}} */
1284
1446
  const {
1285
1447
  forgejoUser,
1286
1448
  rootDir = process.cwd(),
@@ -1304,7 +1466,7 @@ function syncMerged(branch, mergedCommit, options = {}) {
1304
1466
  return { ok: false, error: 'missing-merged-commit' };
1305
1467
  }
1306
1468
 
1307
- const { token } = resolveForgejoAuth({ forgejoUser, token: providedToken });
1469
+ const { token } = resolveForgejoAuth({ forgejoUser, token: providedToken || undefined });
1308
1470
  if (!token) {
1309
1471
  return { ok: false, error: 'missing-token' };
1310
1472
  }
@@ -1314,11 +1476,11 @@ function syncMerged(branch, mergedCommit, options = {}) {
1314
1476
 
1315
1477
  const prNumber = resolvePrNumber(branch, token, { slug, rootDir });
1316
1478
  if (isApiErrorResult(prNumber)) {
1317
- const apiErr = prNumber._apiError;
1479
+ const apiErr = prNumber._apiError || {};
1318
1480
  return {
1319
1481
  ok: false,
1320
1482
  error: 'api-failed',
1321
- raw: `failed to resolve PR for ${branch}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}`
1483
+ raw: `failed to resolve PR for ${branch}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}`
1322
1484
  };
1323
1485
  }
1324
1486
  if (!prNumber) {
@@ -1330,7 +1492,7 @@ function syncMerged(branch, mergedCommit, options = {}) {
1330
1492
  return { ok: false, error: 'missing-commit' };
1331
1493
  }
1332
1494
 
1333
- const verifyMergeState = (statusCode) => {
1495
+ const verifyMergeState = (/** @type {number} */ statusCode) => {
1334
1496
  const prDetails = apiCall('GET', `/pulls/${prNumber}`, token, undefined, { rootDir });
1335
1497
  if (!prDetails.ok) {
1336
1498
  return { ok: false, error: 'merge-verify-failed', prNumber, statusCode: prDetails.statusCode };
@@ -1339,7 +1501,7 @@ function syncMerged(branch, mergedCommit, options = {}) {
1339
1501
  const pr = prDetails.data || {};
1340
1502
  const baseSha = pr.base ? pr.base.sha : null;
1341
1503
  const headSha = pr.head ? pr.head.sha : null;
1342
- const shaMatch = (a, b) => a && b && (a === b || a.startsWith(b) || b.startsWith(a));
1504
+ const shaMatch = (/** @type {string|null} */ a, /** @type {string|null} */ b) => a && b && (a === b || a.startsWith(b) || b.startsWith(a));
1343
1505
  if (shaMatch(baseSha, mergedCommit) && shaMatch(headSha, mergedCommit)) {
1344
1506
  log(`PR #${prNumber} (${branch}): confirmed head/base match ${mergedCommit} (already merged)`);
1345
1507
  return { ok: true };
@@ -1449,9 +1611,10 @@ function syncMerged(branch, mergedCommit, options = {}) {
1449
1611
  * @param {object} [options]
1450
1612
  * @param {Function} [options.apiCall] - Forgejo API function, injectable for tests
1451
1613
  * @param {Function} [options.log] - Logger, injectable for tests
1452
- * @returns {Promise<Array|null>} - Array of comment objects sorted by creation time, or null if comments could not be fetched
1614
+ * @returns {any[]|null} - Array of comment objects sorted by creation time, or null if comments could not be fetched
1453
1615
  */
1454
1616
  function getCommentsSync(branch, token, options = {}) {
1617
+ /** @type {{apiCall?: Function, forgejoUser?: string, rootDir?: string, log?: Function}} */
1455
1618
  const {
1456
1619
  apiCall = forgejoApi,
1457
1620
  forgejoUser,
@@ -1461,11 +1624,11 @@ function getCommentsSync(branch, token, options = {}) {
1461
1624
  const slugMatch = branch.match(/^mission\/(task-\d+)/);
1462
1625
  const slug = slugMatch ? slugMatch[1] : null;
1463
1626
  const prAccess = resolvePrAccess(branch, token, { apiCall, slug, forgejoUser, rootDir });
1464
- if (isApiErrorResult(prAccess)) {
1465
- logger(`getComments API error resolving PR for ${branch}: status=${prAccess._apiError.status}`);
1627
+ if (prAccess && isApiErrorResult(prAccess)) {
1628
+ logger(`getComments API error resolving PR for ${branch}: status=${(/** @type {{status?: number}} */ (prAccess._apiError || {})).status || 0}`);
1466
1629
  return null;
1467
1630
  }
1468
- if (!prAccess) return null;
1631
+ if (!prAccess) {return null;}
1469
1632
  const prNumber = prAccess.prNumber;
1470
1633
  const accessToken = prAccess.token;
1471
1634
 
@@ -1479,10 +1642,10 @@ function getCommentsSync(branch, token, options = {}) {
1479
1642
 
1480
1643
  const issueComments = issueCommentsRes.data || [];
1481
1644
  const reviews = reviewsRes.data || [];
1482
- const allComments = [];
1645
+ const allComments = /** @type {Array<{kind: string, user: string, created: string, body: string, state?: string, location?: string}>} */ ([]);
1483
1646
 
1484
1647
  // 1. Process issue comments
1485
- issueComments.forEach(c => {
1648
+ issueComments.forEach(/** @param {{user?: {login?: string}, created_at?: string, body?: string}} c */ (c) => {
1486
1649
  allComments.push({
1487
1650
  kind: 'issue-comment',
1488
1651
  user: (c.user || {}).login || '?',
@@ -1494,8 +1657,8 @@ function getCommentsSync(branch, token, options = {}) {
1494
1657
  // 2. Process reviews and their inline comments
1495
1658
  for (const r of reviews) {
1496
1659
  const flags = [];
1497
- if (r.stale) flags.push('stale');
1498
- if (r.dismissed) flags.push('dismissed');
1660
+ if (r.stale) {flags.push('stale');}
1661
+ if (r.dismissed) {flags.push('dismissed');}
1499
1662
  const kind = flags.length > 0 ? `review [${flags.join(', ')}]` : 'review';
1500
1663
 
1501
1664
  allComments.push({
@@ -1509,12 +1672,12 @@ function getCommentsSync(branch, token, options = {}) {
1509
1672
  // Fetch inline comments for this review
1510
1673
  const inlineRes = apiCall('GET', `/pulls/${prNumber}/reviews/${r.id}/comments`, accessToken);
1511
1674
  if (inlineRes.ok && Array.isArray(inlineRes.data)) {
1512
- inlineRes.data.forEach(c => {
1675
+ inlineRes.data.forEach(/** @param {{user?: {login?: string}, created_at?: string, body?: string, path?: string, line?: number, original_line?: number}} c */ (c) => {
1513
1676
  const iFlags = [];
1514
- if (r.stale) iFlags.push('stale');
1515
- if (r.dismissed) iFlags.push('dismissed');
1677
+ if (r.stale) {iFlags.push('stale');}
1678
+ if (r.dismissed) {iFlags.push('dismissed');}
1516
1679
  let iKind = 'inline-comment';
1517
- if (iFlags.length > 0) iKind += ` [${iFlags.join(', ')}]`;
1680
+ if (iFlags.length > 0) {iKind += ` [${iFlags.join(', ')}]`;}
1518
1681
 
1519
1682
  const path = c.path || '';
1520
1683
  const line = c.line || c.original_line || '';
@@ -1531,10 +1694,10 @@ function getCommentsSync(branch, token, options = {}) {
1531
1694
  }
1532
1695
  }
1533
1696
 
1534
- return allComments.sort((a, b) => a.created.localeCompare(b.created));
1697
+ return allComments.sort(/** @param {{created: string}} a @param {{created: string}} b */ (a, b) => a.created.localeCompare(b.created));
1535
1698
  }
1536
1699
 
1537
- async function getComments(branch, token, options = {}) {
1700
+ async function getComments(/** @type {string} */ branch, /** @type {string} */ token, options = {}) {
1538
1701
  return getCommentsSync(branch, token, options);
1539
1702
  }
1540
1703
 
@@ -1544,22 +1707,21 @@ async function getComments(branch, token, options = {}) {
1544
1707
  *
1545
1708
  * @param {string} branch - Mission branch
1546
1709
  * @param {string} token - Forgejo PAT
1547
- * @param {string} user - Forgejo login
1548
1710
  * @returns {Promise<Object>} - { ok: boolean, error: string }
1549
1711
  */
1550
- async function closePr(branch, token, user) {
1712
+ async function closePr(branch, token) {
1551
1713
  const slugMatch = branch.match(/^mission\/(task-\d+)/);
1552
1714
  const slug = slugMatch ? slugMatch[1] : null;
1553
1715
  const rootDir = process.cwd();
1554
1716
  const prNumber = getPrNumber(branch, token, { slug, rootDir });
1555
1717
 
1556
- if (isApiErrorResult(prNumber)) {
1557
- const apiErr = prNumber._apiError;
1558
- fmt.log.warn(`API error resolving PR for ${fmt.branch(branch)}: status=${apiErr.status}${apiErr.status === 7 ? ` (${codexSandboxHint()})` : ''}`);
1718
+ if (prNumber && typeof prNumber === 'object' && isApiErrorResult(prNumber)) {
1719
+ const apiErr = /** @type {{status?: number}} */ (prNumber._apiError || {});
1720
+ fmt.log.warn(`API error resolving PR for ${fmt.branch(branch)}: status=${apiErr.status || 0}${(apiErr.status || 0) === 7 ? ` (${codexSandboxHint()})` : ''}`);
1559
1721
  return { ok: true };
1560
1722
  }
1561
1723
 
1562
- if (prNumber) {
1724
+ if (prNumber && typeof prNumber === 'number') {
1563
1725
  // Check current state
1564
1726
  const prDetails = forgejoApi('GET', `/pulls/${prNumber}`, token, undefined, { rootDir });
1565
1727
  if (prDetails.ok) {
@@ -1619,7 +1781,6 @@ module.exports = {
1619
1781
  verifyCommitExists,
1620
1782
  remoteRefContainsCommit,
1621
1783
  resolveTrackingBranchSha,
1622
- resolveForgejoHome,
1623
1784
  deriveRepoFromGitRemote,
1624
1785
  resolveForgejoSettings,
1625
1786
  reviewRemoteUrl,