@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
@@ -17,25 +17,30 @@ let tokenNameCounter = 0;
17
17
  const REVIEW_TOKEN_SCOPES = ['write:user', 'write:repository', 'write:issue', 'write:organization'];
18
18
  const PASSWORD_PROMPT_OPTIONS = { hidden: false };
19
19
 
20
+ /** @param {string} repo @returns {{owner: string, repo: string}|null} */
20
21
  function parseRepoSlug(repo) {
21
- if (typeof repo !== 'string') return null;
22
+ if (typeof repo !== 'string') {return null;}
22
23
  const match = repo.trim().match(/^([^/\s]+)\/([^/\s]+)$/);
23
- if (!match) return null;
24
+ if (!match) {return null;}
24
25
  return { owner: match[1], repo: match[2] };
25
26
  }
26
27
 
28
+ /** @param {string[]} values @returns {string[]} */
27
29
  function unique(values) {
28
30
  return [...new Set(values.filter(Boolean))];
29
31
  }
30
32
 
33
+ /** @param {string|undefined} baseUrl @returns {string|undefined} */
31
34
  function normalizeBaseUrl(baseUrl) {
32
35
  return typeof baseUrl === 'string' ? baseUrl.replace(/\/+$/, '') : baseUrl;
33
36
  }
34
37
 
38
+ /** @param {string} repoName @param {string} user @param {string} [suffix] @returns {string} */
35
39
  function buildTokenName(repoName, user, suffix = `${Date.now()}-${++tokenNameCounter}`) {
36
40
  return `workflow-${repoName}-${user}-${suffix}`;
37
41
  }
38
42
 
43
+ /** @param {string} user @param {{allowBlank?: boolean}} [options] @returns {string} */
39
44
  function passwordPrompt(user, options = {}) {
40
45
  const { allowBlank = false } = options;
41
46
  return allowBlank
@@ -43,22 +48,30 @@ function passwordPrompt(user, options = {}) {
43
48
  : `Password for ${user} (input visible): `;
44
49
  }
45
50
 
51
+ /** @param {string|undefined} value @param {boolean} [defaultValue] @returns {boolean} */
46
52
  function parseYesNo(value, defaultValue = true) {
47
- if (typeof value !== 'string' || value.trim() === '') return defaultValue;
53
+ if (typeof value !== 'string' || value.trim() === '') {return defaultValue;}
48
54
  const normalized = value.trim().toLowerCase();
49
- if (['y', 'yes'].includes(normalized)) return true;
50
- if (['n', 'no'].includes(normalized)) return false;
55
+ if (['y', 'yes'].includes(normalized)) {return true;}
56
+ if (['n', 'no'].includes(normalized)) {return false;}
51
57
  return defaultValue;
52
58
  }
53
59
 
60
+ /** @param {string} [rootDir] @returns {string} */
54
61
  function defaultProductName(rootDir = process.cwd()) {
55
62
  return path.basename(rootDir) || 'Workflow Project';
56
63
  }
57
64
 
65
+ /** @param {string} [rootDir] @param {string} [ownerLogin] @returns {string} */
58
66
  function defaultRepoSlug(rootDir = process.cwd(), ownerLogin = 'human') {
59
67
  return `${ownerLogin}/${path.basename(rootDir) || 'project'}`;
60
68
  }
61
69
 
70
+ /**
71
+ * @param {string} rootDir
72
+ * @param {{spawnSyncFn?: Function, configuredPrimaryBranch?: string|null}} [options]
73
+ * @returns {string}
74
+ */
62
75
  function detectPrimaryBranchDefault(rootDir = process.cwd(), options = {}) {
63
76
  const { spawnSyncFn = spawnSync, configuredPrimaryBranch = null } = options;
64
77
  const listResult = spawnSyncFn(
@@ -67,7 +80,7 @@ function detectPrimaryBranchDefault(rootDir = process.cwd(), options = {}) {
67
80
  { encoding: 'utf8' }
68
81
  );
69
82
  const branches = listResult.status === 0
70
- ? (listResult.stdout || '').split('\n').map(value => value.trim()).filter(Boolean)
83
+ ? (listResult.stdout || '').split('\n').map(/** @param {string} value */ value => value.trim()).filter(Boolean)
71
84
  : [];
72
85
 
73
86
  if (configuredPrimaryBranch && branches.includes(configuredPrimaryBranch)) {
@@ -82,9 +95,9 @@ function detectPrimaryBranchDefault(rootDir = process.cwd(), options = {}) {
82
95
  return currentBranch;
83
96
  }
84
97
 
85
- if (branches.includes('main')) return 'main';
86
- if (branches.includes('master')) return 'master';
87
- if (configuredPrimaryBranch) return configuredPrimaryBranch;
98
+ if (branches.includes('main')) {return 'main';}
99
+ if (branches.includes('master')) {return 'master';}
100
+ if (configuredPrimaryBranch) {return configuredPrimaryBranch;}
88
101
  return 'main';
89
102
  }
90
103
 
@@ -98,6 +111,7 @@ function standardLayoutDescription() {
98
111
  ].join('; ');
99
112
  }
100
113
 
114
+ /** @param {{reviewProvider?: string, baseUrl?: string, reviewRemote?: string, reviewRepo?: string}} answers @returns {{provider: string, baseUrl?: string, remote?: string, repo?: string}} */
101
115
  function buildReviewAdapterConfig(answers) {
102
116
  const provider = answers.reviewProvider || 'forgejo';
103
117
  if (provider !== 'forgejo') {
@@ -111,11 +125,13 @@ function buildReviewAdapterConfig(answers) {
111
125
  };
112
126
  }
113
127
 
128
+ /** @param {{missionsBaseDir?: string, branchPrefix?: string, worktreePattern?: string, primaryBranch?: string, productName?: string, tasksProvider?: string, tasksStorage?: string, verificationCommand?: string, verificationDefaultArea?: string, reviewProvider?: string, baseUrl?: string, reviewRemote?: string, reviewRepo?: string}} answers @returns {{product: {name: string, targetUser: string}, adapters: {tasks: {provider: string, storage: string}, missions: {baseDir: string, branchPrefix: string, worktreePattern: string, primaryBranch?: string}, verification: {command: string, defaultArea: string}, review: {provider: string, baseUrl?: string, remote?: string, repo?: string}, agents: {}}}} */
114
129
  function buildWorkflowConfig(answers) {
130
+ /** @type {{baseDir: string, branchPrefix: string, worktreePattern: string, primaryBranch?: string}} */
115
131
  const missions = {
116
- baseDir: answers.missionsBaseDir,
117
- branchPrefix: answers.branchPrefix,
118
- worktreePattern: answers.worktreePattern,
132
+ baseDir: /** @type {string} */ (answers.missionsBaseDir),
133
+ branchPrefix: /** @type {string} */ (answers.branchPrefix),
134
+ worktreePattern: /** @type {string} */ (answers.worktreePattern),
119
135
  };
120
136
  if (
121
137
  typeof answers.primaryBranch === 'string' &&
@@ -127,18 +143,18 @@ function buildWorkflowConfig(answers) {
127
143
 
128
144
  return {
129
145
  product: {
130
- name: answers.productName,
146
+ name: /** @type {string} */ (answers.productName),
131
147
  targetUser: 'Engineering teams using git, task tracking, and code review',
132
148
  },
133
149
  adapters: {
134
150
  tasks: {
135
- provider: answers.tasksProvider,
136
- storage: answers.tasksStorage,
151
+ provider: /** @type {string} */ (answers.tasksProvider),
152
+ storage: /** @type {string} */ (answers.tasksStorage),
137
153
  },
138
154
  missions,
139
155
  verification: {
140
- command: answers.verificationCommand,
141
- defaultArea: answers.verificationDefaultArea,
156
+ command: /** @type {string} */ (answers.verificationCommand),
157
+ defaultArea: /** @type {string} */ (answers.verificationDefaultArea),
142
158
  },
143
159
  review: buildReviewAdapterConfig(answers),
144
160
  agents: {},
@@ -146,12 +162,14 @@ function buildWorkflowConfig(answers) {
146
162
  };
147
163
  }
148
164
 
165
+ /** @param {string} rootDir @param {{[key: string]: any}} config @returns {string} */
149
166
  function writeWorkflowConfig(rootDir, config) {
150
167
  const configPath = path.join(rootDir, 'workflow.config.json');
151
168
  fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
152
169
  return configPath;
153
170
  }
154
171
 
172
+ /** @returns {string[]} */
155
173
  function suggestedForgejoUsers() {
156
174
  return unique([
157
175
  ...eligibleAgentsForStep('active'),
@@ -159,18 +177,21 @@ function suggestedForgejoUsers() {
159
177
  ]);
160
178
  }
161
179
 
180
+ /** @param {string} user @param {string} [forgejoHome] @returns {string} */
162
181
  function tokenFilePath(user, forgejoHome = resolveForgejoHome()) {
163
182
  return path.join(forgejoHome, 'tokens', user);
164
183
  }
165
184
 
185
+ /** @param {string} [forgejoHome] @returns {string} */
166
186
  function ensureTokenDir(forgejoHome = resolveForgejoHome()) {
167
187
  const dir = path.join(forgejoHome, 'tokens');
168
188
  fs.mkdirSync(dir, { recursive: true });
169
189
  return dir;
170
190
  }
171
191
 
192
+ /** @param {string} [rootDir] @param {string} [explicitForgejoHome] @returns {string} */
172
193
  function resolveBootstrapForgejoHome(rootDir = process.cwd(), explicitForgejoHome) {
173
- if (explicitForgejoHome) return explicitForgejoHome;
194
+ if (explicitForgejoHome) {return explicitForgejoHome;}
174
195
  return path.join(rootDir, '.forgejo-local');
175
196
  }
176
197
 
@@ -178,10 +199,15 @@ function readConfiguredReviewRemote(rootDir = process.cwd(), remoteName = 'revie
178
199
  const result = spawnSync('git', ['-C', rootDir, 'remote', 'get-url', remoteName], {
179
200
  encoding: 'utf8',
180
201
  });
181
- if (result.status !== 0) return null;
202
+ if (result.status !== 0) {return null;}
182
203
  return (result.stdout || '').trim() || null;
183
204
  }
184
205
 
206
+ /**
207
+ * @param {string} rootDir
208
+ * @param {{users?: string[], remoteName?: string, remoteUrlFn?: Function, getRemoteUrlFn?: Function, tokenPathFn?: Function, requestFn?: Function}} [options]
209
+ * @returns {{required: boolean, ok: boolean, issues: string[], steps: string[]}}
210
+ */
185
211
  function evaluateReviewSetup(rootDir = process.cwd(), options = {}) {
186
212
  const {
187
213
  users = suggestedForgejoUsers(),
@@ -214,7 +240,7 @@ function evaluateReviewSetup(rootDir = process.cwd(), options = {}) {
214
240
  const configuredRemoteName = remoteName || reviewAdapter.remote || 'review';
215
241
  const issues = [];
216
242
  const steps = [];
217
- const missingUsers = users.filter(user => !fs.existsSync(tokenPathFn(user)));
243
+ const missingUsers = users.filter(/** @param {string} user */ user => !fs.existsSync(tokenPathFn(user)));
218
244
 
219
245
  if (missingUsers.length > 0) {
220
246
  issues.push(`missing Forgejo tokens for: ${missingUsers.join(', ')}`);
@@ -247,7 +273,7 @@ function evaluateReviewSetup(rootDir = process.cwd(), options = {}) {
247
273
  } else if (!probe.ok && probe.statusCode === 404) {
248
274
  issues.push(`Forgejo review repo ${review.repo} is missing or inaccessible for ${user}`);
249
275
  steps.push('Run `px setup` to recreate the review repo and refresh token/remote wiring.');
250
- } else if (!probe.ok && probe.statusCode == null) {
276
+ } else if (!probe.ok && probe.statusCode === null) {
251
277
  issues.push(`Forgejo at ${normalizedBaseUrl} is unreachable while validating ${user}`);
252
278
  steps.push('Start Forgejo and rerun `px verify-env` or `px setup-review`.');
253
279
  }
@@ -261,6 +287,12 @@ function evaluateReviewSetup(rootDir = process.cwd(), options = {}) {
261
287
  return { required: true, ok: false, issues, steps };
262
288
  }
263
289
 
290
+ /**
291
+ * @param {string} method
292
+ * @param {string} requestUrl
293
+ * @param {{basicAuth?: {user: string, password: string}, token?: string, body?: {[key: string]: any}}} [options]
294
+ * @returns {{ok: boolean, statusCode: number|null, data: any, error: string|null}}
295
+ */
264
296
  function apiRequest(method, requestUrl, options = {}) {
265
297
  const args = [
266
298
  '-s',
@@ -288,6 +320,7 @@ function apiRequest(method, requestUrl, options = {}) {
288
320
  return {
289
321
  ok: false,
290
322
  statusCode: null,
323
+ data: null,
291
324
  error: (result.stderr || result.stdout || 'curl failed').trim(),
292
325
  };
293
326
  }
@@ -314,6 +347,7 @@ function apiRequest(method, requestUrl, options = {}) {
314
347
  };
315
348
  }
316
349
 
350
+ /** @param {string} baseUrl @param {string} user @param {string} password @param {string} tokenName @param {Function} [requestFn] @param {string[]} [scopes] @returns {{ok: boolean, token?: string, error?: string, statusCode?: number|null, response?: any}} */
317
351
  function createToken(baseUrl, user, password, tokenName, requestFn = apiRequest, scopes = REVIEW_TOKEN_SCOPES) {
318
352
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
319
353
  const result = requestFn('POST', `${normalizedBaseUrl}/api/v1/users/${encodeURIComponent(user)}/tokens`, {
@@ -340,6 +374,7 @@ function createToken(baseUrl, user, password, tokenName, requestFn = apiRequest,
340
374
  return { ok: true, token };
341
375
  }
342
376
 
377
+ /** @param {string} baseUrl @param {string} repoSlug @param {string} ownerLogin @param {string} ownerToken @param {Function} [requestFn] @returns {{ok: boolean, created?: boolean, error?: string, response?: any}} */
343
378
  function ensureRepo(baseUrl, repoSlug, ownerLogin, ownerToken, requestFn = apiRequest) {
344
379
  const repoInfo = parseRepoSlug(repoSlug);
345
380
  if (!repoInfo) {
@@ -379,6 +414,7 @@ function ensureRepo(baseUrl, repoSlug, ownerLogin, ownerToken, requestFn = apiRe
379
414
  return { ok: true, created: true };
380
415
  }
381
416
 
417
+ /** @param {string} baseUrl @param {string} repoSlug @param {string} ownerToken @param {string} collaborator @param {string} [permission] @param {Function} [requestFn] @returns {{ok: boolean, created?: boolean, skipped?: boolean, error?: string, response?: any}} */
382
418
  function ensureRepoCollaborator(baseUrl, repoSlug, ownerToken, collaborator, permission = 'write', requestFn = apiRequest) {
383
419
  const repoInfo = parseRepoSlug(repoSlug);
384
420
  if (!repoInfo) {
@@ -403,13 +439,14 @@ function ensureRepoCollaborator(baseUrl, repoSlug, ownerToken, collaborator, per
403
439
  return { ok: true, created: true };
404
440
  }
405
441
 
442
+ /** @param {string} baseUrl @param {string} repoSlug @param {string} ownerToken @param {string[]} [collaborators] @param {string} [permission] @param {Function} [requestFn] @returns {{ok: boolean, created: string[]}} */
406
443
  function ensureRepoCollaborators(baseUrl, repoSlug, ownerToken, collaborators = [], permission = 'write', requestFn = apiRequest) {
407
- const uniqueCollaborators = unique(collaborators.map(user => (typeof user === 'string' ? user.trim() : '')).filter(Boolean));
444
+ const uniqueCollaborators = unique(collaborators.map(/** @param {string|boolean} user */ user => (typeof user === 'string' ? user.trim() : '')).filter(Boolean));
408
445
  const created = [];
409
446
  for (const collaborator of uniqueCollaborators) {
410
447
  const result = ensureRepoCollaborator(baseUrl, repoSlug, ownerToken, collaborator, permission, requestFn);
411
448
  if (!result.ok) {
412
- return result;
449
+ return { ok: false, created: [] };
413
450
  }
414
451
  if (result.created) {
415
452
  created.push(collaborator);
@@ -418,6 +455,7 @@ function ensureRepoCollaborators(baseUrl, repoSlug, ownerToken, collaborators =
418
455
  return { ok: true, created };
419
456
  }
420
457
 
458
+ /** @param {string} rootDir @param {string} remoteName @param {string} remoteUrl @returns {{ok: boolean, created?: boolean, updated?: boolean, error?: string}} */
421
459
  function ensureReviewRemote(rootDir, remoteName, remoteUrl) {
422
460
  const current = readConfiguredReviewRemote(rootDir, remoteName);
423
461
  const args = current
@@ -433,6 +471,11 @@ function ensureReviewRemote(rootDir, remoteName, remoteUrl) {
433
471
  return { ok: true, created: !current, updated: Boolean(current && current !== remoteUrl) };
434
472
  }
435
473
 
474
+ /**
475
+ * @param {string} prompt
476
+ * @param {{hidden?: boolean, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream}} [options]
477
+ * @returns {Promise<string>}
478
+ */
436
479
  async function promptLine(prompt, options = {}) {
437
480
  const { hidden = false, input = process.stdin, output = process.stdout } = options;
438
481
  return await new Promise((resolve) => {
@@ -446,7 +489,7 @@ async function promptLine(prompt, options = {}) {
446
489
  }
447
490
 
448
491
  const originalWrite = output.write.bind(output);
449
- const maskChunk = (chunk) => {
492
+ const maskChunk = /** @param {string|Buffer} chunk */ (chunk) => {
450
493
  const value = typeof chunk === 'string' ? chunk : String(chunk);
451
494
  let masked = '';
452
495
  for (const character of value) {
@@ -464,18 +507,22 @@ async function promptLine(prompt, options = {}) {
464
507
  }
465
508
  return masked;
466
509
  };
467
- output.write = (chunk, encoding, cb) => {
468
- if (rl.stdoutMuted) {
469
- const masked = maskChunk(chunk);
510
+ /** @type {import('readline').Interface & {stdoutMuted?: boolean}} */
511
+ const rlAny = rl;
512
+ /** @type {Function} */
513
+ // @ts-expect-error overriding Writable.write with custom signature
514
+ output.write = /** @type {any} */ ((chunk, encoding, cb) => {
515
+ if (rlAny.stdoutMuted) {
516
+ const masked = maskChunk(/** @type {string | Buffer} */ (chunk));
470
517
  if (masked) {
471
- return originalWrite(masked, encoding, cb);
518
+ return /** @type {Function} */ (originalWrite)(masked, encoding, cb);
472
519
  }
473
- if (typeof cb === 'function') cb();
520
+ if (typeof cb === 'function') {cb();}
474
521
  return true;
475
522
  }
476
- return originalWrite(chunk, encoding, cb);
477
- };
478
- rl.stdoutMuted = true;
523
+ return /** @type {Function} */ (originalWrite)(chunk, encoding, cb);
524
+ });
525
+ rlAny.stdoutMuted = true;
479
526
  originalWrite(prompt);
480
527
  rl.question('', answer => {
481
528
  output.write = originalWrite;
@@ -486,10 +533,18 @@ async function promptLine(prompt, options = {}) {
486
533
  });
487
534
  }
488
535
 
536
+ /** @param {{statusCode?: number}} result @returns {boolean} */
489
537
  function isAuthFailure(result) {
490
538
  return Boolean(result && (result.statusCode === 401 || result.statusCode === 403));
491
539
  }
492
540
 
541
+ /**
542
+ * @param {{baseUrl: string, repo: string, ownerLogin: string, ownerPassword: string}} setup
543
+ * @param {string} user
544
+ * @param {string} initialPassword
545
+ * @param {{allowBlank?: boolean, log?: Function, promptFn?: Function, maxAttempts?: number, requestFn?: Function, scopes?: string[]}} [options]
546
+ * @returns {Promise<{ok: boolean, token?: string, error?: string, skipped?: boolean, statusCode?: number|null, response?: any}>}
547
+ */
493
548
  async function createTokenWithRetries(setup, user, initialPassword, options = {}) {
494
549
  const {
495
550
  allowBlank = false,
@@ -538,8 +593,8 @@ async function createTokenWithRetries(setup, user, initialPassword, options = {}
538
593
  requestFn,
539
594
  scopes
540
595
  );
541
- if (tokenResult.ok) return tokenResult;
542
- if (!isAuthFailure(tokenResult) || attempt === maxAttempts || typeof promptFn !== 'function') {
596
+ if (tokenResult.ok) {return tokenResult;}
597
+ if (!isAuthFailure(/** @type {{statusCode?: number}} */ (tokenResult)) || attempt === maxAttempts || typeof promptFn !== 'function') {
543
598
  return tokenResult;
544
599
  }
545
600
 
@@ -550,6 +605,11 @@ async function createTokenWithRetries(setup, user, initialPassword, options = {}
550
605
  return { ok: false, error: `token creation failed for ${user}` };
551
606
  }
552
607
 
608
+ /**
609
+ * @param {string} [rootDir]
610
+ * @param {{promptFn?: Function, log?: Function, users?: string[]}} [options]
611
+ * @returns {Promise<{baseUrl: string, repo: string, ownerLogin: string, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[]}>}
612
+ */
553
613
  async function collectSetupAnswers(rootDir = process.cwd(), options = {}) {
554
614
  const {
555
615
  promptFn = promptLine,
@@ -568,7 +628,7 @@ async function collectSetupAnswers(rootDir = process.cwd(), options = {}) {
568
628
  const ownerLogin = (await promptFn(`Forgejo login with repo-create access [${defaultOwnerLogin}]: `)) || defaultOwnerLogin;
569
629
  const ownerPassword = await promptFn(passwordPrompt(ownerLogin), PASSWORD_PROMPT_OPTIONS);
570
630
  const userList = (await promptFn(`Agent Forgejo users [${defaultUsers}]: `)) || defaultUsers;
571
- const agentUsers = unique(userList.split(',').map(value => value.trim()).filter(Boolean));
631
+ const agentUsers = unique(userList.split(',').map(/** @param {string} value */ value => value.trim()).filter(Boolean));
572
632
  const agentPasswords = [];
573
633
 
574
634
  for (const user of agentUsers) {
@@ -588,6 +648,11 @@ async function collectSetupAnswers(rootDir = process.cwd(), options = {}) {
588
648
  };
589
649
  }
590
650
 
651
+ /**
652
+ * @param {string} [rootDir]
653
+ * @param {{promptFn?: Function, log?: Function, users?: string[], loadWorkflowConfigFn?: Function, detectPrimaryBranchDefaultFn?: Function}} [options]
654
+ * @returns {Promise<{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}>}
655
+ */
591
656
  async function collectWizardAnswers(rootDir = process.cwd(), options = {}) {
592
657
  const {
593
658
  promptFn = promptLine,
@@ -668,7 +733,7 @@ async function collectWizardAnswers(rootDir = process.cwd(), options = {}) {
668
733
  log(fmt.status('INFO', 'Forgejo bootstrap is enabled. Hidden password prompts are next: first for the repo-creating login, then once per listed agent user.'));
669
734
  ownerPassword = await promptFn(passwordPrompt(ownerLogin), PASSWORD_PROMPT_OPTIONS);
670
735
  const userList = (await promptFn(`Agent Forgejo users [${defaultUsers}]: `)) || defaultUsers;
671
- agentUsers = unique(userList.split(',').map(value => value.trim()).filter(Boolean));
736
+ agentUsers = unique(userList.split(',').map(/** @param {string} value */ value => value.trim()).filter(Boolean));
672
737
  for (const user of agentUsers) {
673
738
  const password = await promptFn(passwordPrompt(user, { allowBlank: true }), PASSWORD_PROMPT_OPTIONS);
674
739
  if (password) {
@@ -677,7 +742,7 @@ async function collectWizardAnswers(rootDir = process.cwd(), options = {}) {
677
742
  }
678
743
  }
679
744
 
680
- return {
745
+ return /** @type {{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}} */ ({
681
746
  productName,
682
747
  ownerLogin,
683
748
  baseUrl,
@@ -688,9 +753,10 @@ async function collectWizardAnswers(rootDir = process.cwd(), options = {}) {
688
753
  agentPasswords,
689
754
  agentUsers,
690
755
  ...defaults,
691
- };
756
+ });
692
757
  }
693
758
 
759
+ /** @param {string} user @param {string} token @param {string} [forgejoHome] @returns {string} */
694
760
  function writeToken(user, token, forgejoHome = resolveForgejoHome()) {
695
761
  ensureTokenDir(forgejoHome);
696
762
  const file = tokenFilePath(user, forgejoHome);
@@ -706,13 +772,22 @@ function writeToken(user, token, forgejoHome = resolveForgejoHome()) {
706
772
  * @param {string} baseUrl - Forgejo base URL
707
773
  * @param {string} repoSlug - Repository slug for token naming
708
774
  * @param {string} ownerToken - Owner's Forgejo PAT
709
- * @param {Array} agentPasswords - Array of {user, password} to create tokens for
710
- * @param {object} repoInfo - Parsed repo slug {owner, repo}
711
- * @param {object} options - Injected dependencies
712
- * @returns {{ ok: boolean, createdTokens: Array, warnings?: Array }}
775
+ * @param {Array<{user: string, password: string}>} agentPasswords - Array of {user, password} to create tokens for
776
+ * @param {{owner: string, repo: string}} repoInfo - Parsed repo slug {owner, repo}
777
+ * @param {{requestFn?: Function, writeTokenFn?: Function, buildTokenNameFn?: Function, forgejoHome?: string}} options - Injected dependencies
778
+ * @returns {{ ok: boolean, createdTokens: Array<{user: string, path: string}>, warnings?: Array<{user: string, error: string, response?: any}>, error?: string }}
779
+ */
780
+ /**
781
+ * @param {string} baseUrl
782
+ * @param {string} repoSlug
783
+ * @param {string} ownerToken
784
+ * @param {Array<{user: string, password: string}>} agentPasswords
785
+ * @param {{owner: string, repo: string}} repoInfo
786
+ * @param {{requestFn?: Function, writeTokenFn?: Function, buildTokenNameFn?: Function, forgejoHome?: string}} [options]
787
+ * @returns {{ok: boolean, createdTokens: Array<{user: string, path: string}>, warnings?: Array<{user: string, error: string, response?: any}>, error?: string}}
713
788
  */
714
789
  function tokenCreateViaOwnerToken(baseUrl, repoSlug, ownerToken, agentPasswords, repoInfo, options = {}) {
715
- const { requestFn = apiRequest, writeTokenFn, buildTokenNameFn = buildTokenName, log = fmt.log.info, forgejoHome: injectedForgejoHome } = options;
790
+ const { requestFn = apiRequest, writeTokenFn = writeToken, buildTokenNameFn = buildTokenName, forgejoHome: injectedForgejoHome } = options;
716
791
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
717
792
  const forgejoHome = typeof injectedForgejoHome === 'string' ? injectedForgejoHome : resolveForgejoHome();
718
793
 
@@ -757,6 +832,12 @@ function tokenCreateViaOwnerToken(baseUrl, repoSlug, ownerToken, agentPasswords,
757
832
  return { ok: true, createdTokens, warnings };
758
833
  }
759
834
 
835
+ /**
836
+ * @param {string} rootDir
837
+ * @param {{baseUrl: string, repo: string, ownerLogin: string, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers?: string[]}} setup
838
+ * @param {{log?: Function, promptFn?: Function, requestFn?: Function, maxPasswordAttempts?: number, interactive?: boolean, forgejoHome?: string}} [options]
839
+ * @returns {Promise<{ok: boolean, warnings?: Array<{user: string, error: string, response?: any}>, createdTokens?: Array<{user: string, path: string}>, error?: string, response?: any}>}
840
+ */
760
841
  async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options = {}) {
761
842
  const {
762
843
  log = fmt.log.info,
@@ -781,7 +862,7 @@ async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options =
781
862
  // Original interactive path: prompt for owner password, create tokens for all users
782
863
  const ownerTokenResult = await createTokenWithRetries({
783
864
  ...setup,
784
- baseUrl,
865
+ baseUrl: /** @type {string} */ (baseUrl),
785
866
  }, setup.ownerLogin, setup.ownerPassword, {
786
867
  allowBlank: false,
787
868
  log,
@@ -789,24 +870,27 @@ async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options =
789
870
  maxAttempts: maxPasswordAttempts,
790
871
  requestFn,
791
872
  });
792
- if (!ownerTokenResult.ok) return ownerTokenResult;
873
+ if (!ownerTokenResult.ok) {return ownerTokenResult;}
874
+ const ownerToken = /** @type {string} */ (ownerTokenResult.token);
793
875
 
794
- const ownerTokenPath = writeToken(setup.ownerLogin, ownerTokenResult.token, forgejoHome);
795
- const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerTokenResult.token, requestFn);
796
- if (!repoResult.ok) return repoResult;
876
+ const ownerTokenPath = writeToken(setup.ownerLogin, ownerToken, forgejoHome);
877
+ // @ts-expect-error repo may be undefined
878
+ const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerToken, requestFn);
879
+ if (!repoResult.ok) {return repoResult;}
797
880
 
798
- const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerTokenResult.token, collaboratorUsers, 'write', requestFn);
799
- if (!collaboratorResult.ok) return collaboratorResult;
881
+ // @ts-expect-error repo may be undefined
882
+ const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerToken, collaboratorUsers, 'write', requestFn);
883
+ if (!collaboratorResult.ok) {return collaboratorResult;}
800
884
  if (collaboratorResult.created.length > 0) {
801
- log(fmt.status('PASS', `Granted write access on ${setup.repo} to ${collaboratorResult.created.join(', ')}`));
885
+ log(fmt.status('PASS', `Granted write access on ${setup.repo || ''} to ${collaboratorResult.created.join(', ')}`));
802
886
  }
803
887
 
804
888
  const createdTokens = [{ user: setup.ownerLogin, path: ownerTokenPath }];
805
- const warnings = [];
889
+ const warnings = /** @type {Array<{user: string, error: string, response?: any}>} */ ([]);
806
890
  for (const agent of setup.agentPasswords) {
807
891
  const tokenResult = await createTokenWithRetries({
808
892
  ...setup,
809
- baseUrl,
893
+ baseUrl: /** @type {string} */ (baseUrl),
810
894
  }, agent.user, agent.password, {
811
895
  allowBlank: true,
812
896
  log,
@@ -817,21 +901,21 @@ async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options =
817
901
  if (!tokenResult.ok) {
818
902
  warnings.push({
819
903
  user: agent.user,
820
- error: tokenResult.error,
904
+ error: /** @type {string} */ (tokenResult.error),
821
905
  response: tokenResult.response,
822
906
  });
823
907
  continue;
824
908
  }
825
- if (tokenResult.skipped) continue;
826
- createdTokens.push({ user: agent.user, path: writeToken(agent.user, tokenResult.token, forgejoHome) });
909
+ if (tokenResult.skipped) {continue;}
910
+ createdTokens.push({ user: agent.user, path: writeToken(agent.user, /** @type {string} */ (tokenResult.token), forgejoHome) });
827
911
  }
828
912
 
829
913
  const remoteUrl = reviewRemoteUrl(rootDir);
830
914
  const remoteName = resolveReviewAdapter(rootDir).remote || 'review';
831
915
  const remoteResult = remoteUrl ? ensureReviewRemote(rootDir, remoteName, remoteUrl) : { ok: true, created: false, updated: false };
832
- if (!remoteResult.ok) return remoteResult;
916
+ if (!remoteResult.ok) {return remoteResult;}
833
917
 
834
- log(fmt.status('PASS', `Forgejo repo ${setup.repo} ${repoResult.created ? 'created' : 'already exists'}.`));
918
+ log(fmt.status('PASS', `Forgejo repo ${setup.repo} ${repoResult.created ? 'created' : 'already exists'}.`));
835
919
  for (const tokenInfo of createdTokens) {
836
920
  log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
837
921
  }
@@ -856,22 +940,25 @@ async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options =
856
940
  ownerToken = fs.readFileSync(ownerTokenPath, 'utf8').trim();
857
941
  }
858
942
  if (!ownerToken) {
859
- return { ok: false, error: `No owner token found for ${setup.ownerLogin} at ${ownerTokenPath}. A token file for an existing user (typically human) is required to bootstrap new agent tokens.` };
943
+ return { ok: false, error: `No owner token found for ${/** @type {string} */ (setup.ownerLogin)} at ${ownerTokenPath}. A token file for an existing user (typically human) is required to bootstrap new agent tokens.` };
860
944
  }
861
945
 
946
+ // @ts-expect-error repo may be undefined
862
947
  const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerToken, requestFn);
863
- if (!repoResult.ok) return repoResult;
948
+ if (!repoResult.ok) {return repoResult;}
864
949
 
950
+ // @ts-expect-error repo may be undefined
865
951
  const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerToken, collaboratorUsers, 'write', requestFn);
866
- if (!collaboratorResult.ok) return collaboratorResult;
952
+ if (!collaboratorResult.ok) {return collaboratorResult;}
867
953
  if (collaboratorResult.created.length > 0) {
868
954
  log(fmt.status('PASS', `Granted write access on ${setup.repo} to ${collaboratorResult.created.join(', ')}`));
869
955
  }
870
956
 
957
+ // @ts-expect-error repo may be undefined
871
958
  const tokensResult = tokenCreateViaOwnerToken(baseUrl, setup.repo, ownerToken, setup.agentPasswords, repoInfo, { requestFn, writeTokenFn: writeToken, forgejoHome, log });
872
- if (!tokensResult.ok) return tokensResult;
959
+ if (!tokensResult.ok) {return tokensResult;}
873
960
 
874
- const { createdTokens, warnings } = tokensResult;
961
+ const { createdTokens, warnings = [] } = /** @type {{createdTokens: Array<{user: string, path: string}>, warnings: Array<{user: string, error: string, response?: any}>}} */ (tokensResult);
875
962
  for (const tokenInfo of createdTokens) {
876
963
  log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
877
964
  }
@@ -885,14 +972,19 @@ async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options =
885
972
  const remoteUrl = reviewRemoteUrl(rootDir);
886
973
  const remoteName = resolveReviewAdapter(rootDir).remote || 'review';
887
974
  const remoteResult = remoteUrl ? ensureReviewRemote(rootDir, remoteName, remoteUrl) : { ok: true, created: false, updated: false };
888
- if (!remoteResult.ok) return remoteResult;
975
+ if (!remoteResult.ok) {return remoteResult;}
889
976
 
890
- log(fmt.status('PASS', `Forgejo repo ${setup.repo} ${repoResult.created ? 'created' : 'already exists'}.`));
977
+ log(fmt.status('PASS', `Forgejo repo ${/** @type {string} */ (setup.repo)} ${repoResult.created ? 'created' : 'already exists'}.`));
891
978
  log(fmt.status('PASS', `Review remote "${remoteName}" now points at ${remoteUrl}`));
892
979
 
893
980
  return { ok: true, warnings, createdTokens };
894
981
  }
895
982
 
983
+ /**
984
+ * @param {any[]} _args
985
+ * @param {{rootDir?: string, log?: Function, error?: Function, exit?: Function, promptFn?: Function, forgejoHome?: string}} [options]
986
+ * @returns {Promise<void>}
987
+ */
896
988
  async function setupReview(_args, options = {}) {
897
989
  const rootDir = options.rootDir || process.cwd();
898
990
  const log = options.log || fmt.log.plain;
@@ -905,7 +997,7 @@ async function setupReview(_args, options = {}) {
905
997
  forgejoHome: resolveBootstrapForgejoHome(rootDir, options.forgejoHome),
906
998
  });
907
999
  if (!result.ok) {
908
- error(fmt.status('FAIL', result.error));
1000
+ error(fmt.status('FAIL', /** @type {string} */ (result.error || '')));
909
1001
  if (result.response) {
910
1002
  error(fmt.status('INFO', `Forgejo response: ${JSON.stringify(result.response)}`));
911
1003
  }
@@ -914,8 +1006,9 @@ async function setupReview(_args, options = {}) {
914
1006
  }
915
1007
  }
916
1008
 
1009
+ /** @param {string} rootDir @param {{verifyFn?: Function}} [options] @returns {Promise<{status: number}>} */
917
1010
  async function runVerifyEnv(rootDir, options = {}) {
918
- const verifyFn = options.verifyFn || ((cwd) => spawnSync('node', ['workflow', 'verify-env'], {
1011
+ const verifyFn = options.verifyFn || /** @param {string} cwd @returns {{status: number}} */ ((/** @type {string} */ cwd) => spawnSync('node', ['workflow', 'verify-env'], {
919
1012
  cwd,
920
1013
  stdio: 'inherit',
921
1014
  encoding: 'utf8',
@@ -923,6 +1016,7 @@ async function runVerifyEnv(rootDir, options = {}) {
923
1016
  return verifyFn(rootDir);
924
1017
  }
925
1018
 
1019
+ /** @param {string} rootDir @param {{log?: Function}} [options] @returns {{productName: string, reviewProvider: string, tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string, bootstrapReview: boolean, ownerLogin?: string, baseUrl?: string, reviewRepo?: string, reviewRemote?: string, ownerPassword?: string, agentPasswords?: Array<{user: string, password: string}>, agentUsers?: string[]}} */
926
1020
  function buildNonInteractiveAnswers(rootDir, options = {}) {
927
1021
  const log = options.log || fmt.log.plain;
928
1022
  const env = process.env;
@@ -970,6 +1064,11 @@ function buildNonInteractiveAnswers(rootDir, options = {}) {
970
1064
  return answers;
971
1065
  }
972
1066
 
1067
+ /**
1068
+ * @param {any[]} _args
1069
+ * @param {{rootDir?: string, log?: Function, error?: Function, exit?: Function, promptFn?: Function, forgejoHome?: string}} [options]
1070
+ * @returns {Promise<void>}
1071
+ */
973
1072
  async function setupWizard(_args, options = {}) {
974
1073
  const rootDir = options.rootDir || process.cwd();
975
1074
  const log = options.log || fmt.log.plain;
@@ -979,8 +1078,9 @@ async function setupWizard(_args, options = {}) {
979
1078
  const forgejoHome = resolveBootstrapForgejoHome(rootDir, options.forgejoHome);
980
1079
 
981
1080
  const nonInteractive = (_args && _args.includes('--non-interactive')) || process.env.WORKFLOW_SETUP_NON_INTERACTIVE === '1';
1081
+ /** @type {{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}} */
982
1082
  const answers = nonInteractive
983
- ? buildNonInteractiveAnswers(rootDir, { log })
1083
+ ? /** @type {{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}} */ (buildNonInteractiveAnswers(rootDir, { log }))
984
1084
  : await collectWizardAnswers(rootDir, { ...options, promptFn, log });
985
1085
  const configPath = writeWorkflowConfig(rootDir, buildWorkflowConfig(answers));
986
1086
  log(fmt.status('PASS', `Wrote ${configPath}`));
@@ -1009,7 +1109,7 @@ async function setupWizard(_args, options = {}) {
1009
1109
  forgejoHome,
1010
1110
  });
1011
1111
  if (!result.ok) {
1012
- error(fmt.status('FAIL', result.error));
1112
+ error(fmt.status('FAIL', /** @type {string} */ (result.error || '')));
1013
1113
  if (result.response) {
1014
1114
  error(fmt.status('INFO', `Forgejo response: ${JSON.stringify(result.response)}`));
1015
1115
  }
@@ -1020,7 +1120,7 @@ async function setupWizard(_args, options = {}) {
1020
1120
  log(fmt.status('INFO', 'Skipped Forgejo bootstrap. You can run `px setup-review` later if needed.'));
1021
1121
  }
1022
1122
 
1023
- const verifyResult = await runVerifyEnv(rootDir, options);
1123
+ const verifyResult = await runVerifyEnv(rootDir, /** @type {{verifyFn?: Function}} */ (options));
1024
1124
  if (verifyResult && typeof verifyResult.status === 'number' && verifyResult.status !== 0) {
1025
1125
  exit(verifyResult.status);
1026
1126
  }