delivery-friction-analyzer 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -50,7 +50,7 @@ For a guided first run in a local terminal, use the opt-in interactive flow:
50
50
  npm run analyze:github -- --interactive
51
51
  ```
52
52
 
53
- Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
53
+ Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. It can also create a missing repository profile path or write a generated profile copy with confirmed workflow context and release PR title rules. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
54
54
 
55
55
  ## Repository Profiles
56
56
 
@@ -61,7 +61,8 @@ Profiles can define:
61
61
  - file categories such as code, tests, docs, generated files, infrastructure, or config;
62
62
  - file roles such as core product code, release notes, fixtures, planning docs, or generated docs;
63
63
  - functional surfaces such as runtime, test suite, release notes, or user docs;
64
- - PR classes such as release, dependency, feature, or other repository-specific groups.
64
+ - PR classes such as release, dependency, feature, or other repository-specific groups;
65
+ - workflow context such as merge method, release strategy, and branch strategy.
65
66
 
66
67
  Use `fixtures/github/mcp-writing/profile.json` as a starting point, then save a copy for the repository you want to analyze. The full profile format is documented in `docs/reference/repository-profile.md`, and the schema lives at `schemas/repository-profile.schema.json`.
67
68
 
@@ -54,9 +54,11 @@ If both matchers are present on one rule, both must match. If no rule matches, t
54
54
 
55
55
  Class identifiers are validated as lower-kebab-case or lower_snake_case strings. Profile validation rejects duplicate PR class rule IDs, empty match objects, invalid class identifiers, and invalid title regexes.
56
56
 
57
+ Interactive setup can add a release PR class rule from a confirmed title convention using the current title-only matcher shape. Branch strategy answers stay in `workflow` context only; they do not create branch-based PR class matching.
58
+
57
59
  ## Workflow Context
58
60
 
59
- `workflow` is optional user-configured context. It records repository workflow assumptions that later setup and report milestones can rely on, but M2 does not infer these values from GitHub and does not change scoring, rankings, collection, PR class matching, or report wording.
61
+ `workflow` is optional user-configured context. It records repository workflow assumptions that later setup and report milestones can rely on, but the analyzer does not infer these values from GitHub and does not change scoring, rankings, collection, PR class matching, or report wording.
60
62
 
61
63
  When provided, `workflow` must include at least one supported field.
62
64
 
@@ -79,3 +81,5 @@ Example:
79
81
  ```
80
82
 
81
83
  Use stable identifiers exactly as shown above. Display labels such as "squash merges" or "release PRs" belong in CLI prompts or documentation, not in profile data.
84
+
85
+ When interactive setup writes profile changes, it preserves deterministic two-space JSON formatting in place. If an existing profile uses other formatting, setup writes a generated profile copy and prints that generated path in completion output instead of rewriting the original file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local GitHub pull request analytics for delivery friction reports.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/release-log.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### 2026-06-18 — Workflow Profile Wizard
6
+
7
+ - What changed: Interactive setup can now create repository profiles or generated profile copies with confirmed workflow context and release PR title rules.
8
+ - Why it matters: Maintainers can capture reusable workflow assumptions during guided setup without hand-editing profile JSON or risking silent rewrites of existing profiles.
9
+ - Who is affected: Maintainers running `--interactive` for first-time repository setup or profile updates.
10
+ - Action needed: Review the generated or updated profile path printed in completion output before reusing it in automation.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/36
12
+
5
13
  ### 2026-06-17 — Workflow Profile Contract
6
14
 
7
15
  - What changed: Repository profiles can now declare optional workflow context for merge method, release strategy, and branch strategy using validated stable identifiers.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { constants, realpathSync } from "node:fs";
3
- import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { access, lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -17,7 +17,12 @@ import {
17
17
  renderRepositoryFrictionMarkdown,
18
18
  } from "../report/friction-report.js";
19
19
  import { assertValidPrClassRules } from "../profile/pr-class.js";
20
- import { assertValidWorkflowContext } from "../profile/workflow.js";
20
+ import {
21
+ WORKFLOW_BRANCH_STRATEGIES,
22
+ WORKFLOW_PRIMARY_MERGE_METHODS,
23
+ WORKFLOW_RELEASE_STRATEGIES,
24
+ assertValidWorkflowContext,
25
+ } from "../profile/workflow.js";
21
26
 
22
27
  const ALLOWED_OPTIONS = new Set([
23
28
  "repo",
@@ -183,25 +188,6 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
183
188
  throw new Error(`exclude-pr-class must name configured PR class(es): ${unconfigured.join(", ")}.${available}`);
184
189
  }
185
190
 
186
- async function readProfile(profilePath) {
187
- let profile;
188
- try {
189
- profile = JSON.parse(await readFile(profilePath, "utf8"));
190
- } catch (error) {
191
- if (error instanceof SyntaxError) {
192
- throw new Error(`profile must be valid JSON: ${error.message}`);
193
- }
194
- throw new Error(`profile could not be read: ${error.message}`);
195
- }
196
- try {
197
- assertValidPrClassRules(profile);
198
- assertValidWorkflowContext(profile);
199
- } catch (error) {
200
- throw new Error(`profile is invalid: ${error.message}`);
201
- }
202
- return profile;
203
- }
204
-
205
191
  function configuredPrClassList(repositoryProfile) {
206
192
  return [...configuredPrClasses(repositoryProfile)].sort();
207
193
  }
@@ -223,6 +209,9 @@ function formatInteractivePrompt(prompt) {
223
209
  if (prompt.type === "multi-select" && prompt.choices?.length) {
224
210
  return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
225
211
  }
212
+ if (prompt.type === "select" && prompt.choices?.length) {
213
+ return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
214
+ }
226
215
  return `${prompt.message}${suffix}: `;
227
216
  }
228
217
 
@@ -286,22 +275,418 @@ function normalizeConfirmAnswer(raw, prompt) {
286
275
  throw new Error("Answer yes or no.");
287
276
  }
288
277
 
278
+ function normalizeChoiceAnswer(raw, prompt) {
279
+ const value = normalizeTextAnswer(raw, prompt);
280
+ if (prompt.choices?.includes(value)) return value;
281
+ throw new Error(`${prompt.id} must be one of: ${prompt.choices.join(", ")}`);
282
+ }
283
+
289
284
  function normalizeMultiSelectAnswer(raw, prompt) {
290
285
  const value = String(raw ?? "").trim();
291
286
  if (!value) return prompt.defaultValue ?? [];
292
287
  return normalizeExcludedPrClasses([value]);
293
288
  }
294
289
 
290
+ function validateProfile(profile) {
291
+ assertValidPrClassRules(profile);
292
+ assertValidWorkflowContext(profile);
293
+ }
294
+
295
+ function parseProfileJson(text) {
296
+ try {
297
+ return JSON.parse(text);
298
+ } catch (error) {
299
+ if (error instanceof SyntaxError) {
300
+ throw new Error(`profile must be valid JSON: ${error.message}`);
301
+ }
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ function hasTrailingPathSeparator(profilePath) {
307
+ return /[/\\]$/.test(profilePath);
308
+ }
309
+
310
+ async function inspectProfilePath(profilePath) {
311
+ if (hasTrailingPathSeparator(profilePath)) {
312
+ throw new Error("profile path must be a JSON file path, not a directory or special file.");
313
+ }
314
+ let profileLinkStat;
315
+ try {
316
+ profileLinkStat = await lstat(profilePath);
317
+ } catch (error) {
318
+ if (error.code === "ENOENT") {
319
+ return { exists: false, profile: null, text: null, isSymbolicLink: false };
320
+ }
321
+ throw error;
322
+ }
323
+ const isSymbolicLink = profileLinkStat.isSymbolicLink();
324
+ const profileStat = isSymbolicLink ? await stat(profilePath) : profileLinkStat;
325
+ if (!profileStat.isFile()) {
326
+ throw new Error("profile path must be a JSON file path, not a directory or special file.");
327
+ }
328
+ const text = await readFile(profilePath, "utf8");
329
+ const profile = parseProfileJson(text);
330
+ validateProfile(profile);
331
+ return { exists: true, profile, text, isSymbolicLink };
332
+ }
333
+
334
+ async function readProfile(profilePath) {
335
+ let inspected;
336
+ try {
337
+ inspected = await inspectProfilePath(profilePath);
338
+ } catch (error) {
339
+ if (error.message?.startsWith("profile must be valid JSON")) {
340
+ throw error;
341
+ }
342
+ if (error.message?.startsWith("invalid ")) {
343
+ throw new Error(`profile is invalid: ${error.message}`);
344
+ }
345
+ throw new Error(`profile could not be read: ${error.message}`);
346
+ }
347
+ if (!inspected.exists) {
348
+ throw new Error("profile could not be read: no such file or directory");
349
+ }
350
+ return inspected.profile;
351
+ }
352
+
353
+ function cloneJson(value) {
354
+ return JSON.parse(JSON.stringify(value));
355
+ }
356
+
357
+ function repositoryProfileFromSlug(repository) {
358
+ const match = REPOSITORY_SLUG.exec(repository);
359
+ if (!match) {
360
+ throw new Error("repo must use owner/name with GitHub-safe owner and name segments.");
361
+ }
362
+ return {
363
+ schemaVersion: "repository-profile.v1",
364
+ repository: {
365
+ owner: match[1],
366
+ name: match[2],
367
+ },
368
+ rules: [],
369
+ };
370
+ }
371
+
372
+ function formatProfile(profile) {
373
+ return `${JSON.stringify(profile, null, 2)}\n`;
374
+ }
375
+
376
+ function generatedProfilePath(profilePath, index = 1) {
377
+ const suffix = index === 1 ? ".generated.json" : `.generated-${index}.json`;
378
+ return /\.json$/i.test(profilePath)
379
+ ? profilePath.replace(/\.json$/i, suffix)
380
+ : `${profilePath}${suffix}`;
381
+ }
382
+
383
+ async function writeProfileFile(profilePath, profile) {
384
+ await mkdir(dirname(profilePath), { recursive: true });
385
+ await writeFile(profilePath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
386
+ }
387
+
388
+ function shouldWriteGeneratedProfileAfterCreateFailure(error) {
389
+ return ["EEXIST", "EISDIR", "ELOOP"].includes(error.code);
390
+ }
391
+
392
+ function shouldRetryProfileReplaceAfterRenameFailure(error) {
393
+ return ["EEXIST", "EPERM"].includes(error.code);
394
+ }
395
+
396
+ async function replaceProfileFile(profilePath, profile, originalText) {
397
+ const tempPath = `${profilePath}.${process.pid}.${Date.now()}.tmp`;
398
+ let shouldCleanupTemp = true;
399
+ try {
400
+ await writeFile(tempPath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
401
+ try {
402
+ await rename(tempPath, profilePath);
403
+ shouldCleanupTemp = false;
404
+ return true;
405
+ } catch (error) {
406
+ if (!shouldRetryProfileReplaceAfterRenameFailure(error)) {
407
+ throw error;
408
+ }
409
+ if (!await profileStillMatchesOriginal(profilePath, originalText)) {
410
+ return false;
411
+ }
412
+ await rm(profilePath, { force: true });
413
+ await rename(tempPath, profilePath);
414
+ shouldCleanupTemp = false;
415
+ return true;
416
+ }
417
+ } finally {
418
+ if (shouldCleanupTemp) {
419
+ await rm(tempPath, { force: true });
420
+ }
421
+ }
422
+ }
423
+
424
+ async function profileStillMatchesOriginal(profilePath, originalText) {
425
+ try {
426
+ const profileLinkStat = await lstat(profilePath);
427
+ if (profileLinkStat.isSymbolicLink() || !profileLinkStat.isFile()) return false;
428
+ return await readFile(profilePath, "utf8") === originalText;
429
+ } catch (error) {
430
+ if (error.code === "ENOENT") return false;
431
+ throw error;
432
+ }
433
+ }
434
+
435
+ async function writeGeneratedProfileFile(profilePath, profile) {
436
+ for (let index = 1; ; index += 1) {
437
+ const writePath = generatedProfilePath(profilePath, index);
438
+ try {
439
+ await mkdir(dirname(writePath), { recursive: true });
440
+ await writeFile(writePath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
441
+ return writePath;
442
+ } catch (error) {
443
+ if (error.code === "EEXIST") continue;
444
+ throw error;
445
+ }
446
+ }
447
+ }
448
+
449
+ async function writeInteractiveProfile(profilePath, profile, {
450
+ exists,
451
+ originalProfile,
452
+ originalText,
453
+ originalIsSymbolicLink,
454
+ }) {
455
+ const canRewriteExisting = exists
456
+ && !originalIsSymbolicLink
457
+ && originalText === formatProfile(originalProfile);
458
+ if (exists && !canRewriteExisting) {
459
+ return writeGeneratedProfileFile(profilePath, profile);
460
+ }
461
+ if (!exists) {
462
+ try {
463
+ await writeProfileFile(profilePath, profile);
464
+ return profilePath;
465
+ } catch (error) {
466
+ if (shouldWriteGeneratedProfileAfterCreateFailure(error)) {
467
+ return writeGeneratedProfileFile(profilePath, profile);
468
+ }
469
+ throw error;
470
+ }
471
+ }
472
+ if (!await profileStillMatchesOriginal(profilePath, originalText)) {
473
+ return writeGeneratedProfileFile(profilePath, profile);
474
+ }
475
+ if (!await replaceProfileFile(profilePath, profile, originalText)) {
476
+ return writeGeneratedProfileFile(profilePath, profile);
477
+ }
478
+ return profilePath;
479
+ }
480
+
481
+ function releaseClassRuleIndexes(profile) {
482
+ if (!Array.isArray(profile.prClasses)) return [];
483
+ return profile.prClasses
484
+ .map((rule, index) => rule.class === "release" ? index : -1)
485
+ .filter(index => index >= 0);
486
+ }
487
+
488
+ function updateableReleaseClassRuleIndex(profile) {
489
+ const releaseIndexes = releaseClassRuleIndexes(profile);
490
+ if (releaseIndexes.length !== 1) return -1;
491
+ const rule = profile.prClasses[releaseIndexes[0]];
492
+ const match = rule.match ?? {};
493
+ const hasTitleIncludes = typeof match.titleIncludes === "string" && match.titleIncludes.length > 0;
494
+ const hasTitleRegex = typeof match.titleRegex === "string" && match.titleRegex.length > 0;
495
+ return hasTitleIncludes && !hasTitleRegex ? releaseIndexes[0] : -1;
496
+ }
497
+
498
+ function defaultReleaseTitleIncludes(profile) {
499
+ const index = updateableReleaseClassRuleIndex(profile);
500
+ const existingIncludes = index >= 0 ? profile.prClasses[index]?.match?.titleIncludes : null;
501
+ return typeof existingIncludes === "string" && existingIncludes.length ? existingIncludes : "Release";
502
+ }
503
+
504
+ function nextPrClassRuleId(profile, baseId) {
505
+ const existingIds = new Set((profile.prClasses ?? []).map(rule => rule.id));
506
+ if (!existingIds.has(baseId)) return baseId;
507
+ for (let index = 2; ; index += 1) {
508
+ const candidate = `${baseId}-${index}`;
509
+ if (!existingIds.has(candidate)) return candidate;
510
+ }
511
+ }
512
+
513
+ function releasePrClassRule(profile, titleIncludes, existingRule = null) {
514
+ return {
515
+ id: existingRule?.id ?? nextPrClassRuleId(profile, "release-title"),
516
+ class: "release",
517
+ match: { ...(existingRule?.match ?? {}), titleIncludes },
518
+ notes: existingRule?.notes ?? "Generated by interactive setup from the configured release PR title convention.",
519
+ };
520
+ }
521
+
522
+ async function promptWorkflowField(promptAdapter, output, { id, message, choices, defaultValue }) {
523
+ return askUntilValid(promptAdapter, {
524
+ id,
525
+ type: "select",
526
+ message,
527
+ choices,
528
+ defaultValue,
529
+ }, {
530
+ output,
531
+ normalize: normalizeChoiceAnswer,
532
+ validate() {},
533
+ });
534
+ }
535
+
536
+ async function promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile }) {
537
+ const updated = cloneJson(profile);
538
+ updated.workflow = {
539
+ primaryMergeMethod: await promptWorkflowField(promptAdapter, output, {
540
+ id: "primaryMergeMethod",
541
+ message: "Primary merge method",
542
+ choices: WORKFLOW_PRIMARY_MERGE_METHODS,
543
+ defaultValue: updated.workflow?.primaryMergeMethod ?? "unknown",
544
+ }),
545
+ releaseStrategy: await promptWorkflowField(promptAdapter, output, {
546
+ id: "releaseStrategy",
547
+ message: "Release strategy",
548
+ choices: WORKFLOW_RELEASE_STRATEGIES,
549
+ defaultValue: updated.workflow?.releaseStrategy ?? "unknown",
550
+ }),
551
+ branchStrategy: await promptWorkflowField(promptAdapter, output, {
552
+ id: "branchStrategy",
553
+ message: "Branch strategy",
554
+ choices: WORKFLOW_BRANCH_STRATEGIES,
555
+ defaultValue: updated.workflow?.branchStrategy ?? "unknown",
556
+ }),
557
+ };
558
+
559
+ if (updated.workflow.releaseStrategy === "release_prs") {
560
+ const suggestedReleaseTitle = defaultReleaseTitleIncludes(updated);
561
+ const titleIncludes = await askUntilValid(promptAdapter, {
562
+ id: "releasePrTitleIncludes",
563
+ type: "text",
564
+ message: `Release PR title includes (blank to skip PR class rule; suggested: ${suggestedReleaseTitle})`,
565
+ }, {
566
+ output,
567
+ normalize: normalizeTextAnswer,
568
+ validate() {},
569
+ });
570
+
571
+ if (titleIncludes) {
572
+ updated.prClasses = Array.isArray(updated.prClasses) ? [...updated.prClasses] : [];
573
+ const updateableReleaseIndex = updateableReleaseClassRuleIndex(updated);
574
+ if (updateableReleaseIndex >= 0) {
575
+ const shouldUpdateReleaseRule = await askUntilValid(promptAdapter, {
576
+ id: "updateReleasePrClass",
577
+ type: "confirm",
578
+ message: "Update existing title-based release PR class rule",
579
+ defaultValue: false,
580
+ }, {
581
+ output,
582
+ normalize: normalizeConfirmAnswer,
583
+ validate() {},
584
+ });
585
+ if (shouldUpdateReleaseRule) {
586
+ updated.prClasses[updateableReleaseIndex] = releasePrClassRule(
587
+ updated,
588
+ titleIncludes,
589
+ updated.prClasses[updateableReleaseIndex],
590
+ );
591
+ }
592
+ } else {
593
+ const shouldAddReleaseRule = isNewProfile
594
+ ? true
595
+ : await askUntilValid(promptAdapter, {
596
+ id: "addReleasePrClass",
597
+ type: "confirm",
598
+ message: "Add release PR class rule from title convention",
599
+ defaultValue: true,
600
+ }, {
601
+ output,
602
+ normalize: normalizeConfirmAnswer,
603
+ validate() {},
604
+ });
605
+ if (shouldAddReleaseRule) {
606
+ updated.prClasses.push(releasePrClassRule(updated, titleIncludes));
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ validateProfile(updated);
613
+ return updated;
614
+ }
615
+
616
+ async function maybeConfigureInteractiveProfile(promptAdapter, output, profileState, repository) {
617
+ const isNewProfile = !profileState.exists;
618
+ const originalProfile = profileState.profile;
619
+ let profile = isNewProfile ? repositoryProfileFromSlug(repository) : cloneJson(originalProfile);
620
+
621
+ if (isNewProfile) {
622
+ const shouldCreateProfile = await askUntilValid(promptAdapter, {
623
+ id: "createProfile",
624
+ type: "confirm",
625
+ message: "Create repository profile at this path",
626
+ defaultValue: true,
627
+ }, {
628
+ output,
629
+ normalize: normalizeConfirmAnswer,
630
+ validate() {},
631
+ });
632
+ if (!shouldCreateProfile) {
633
+ throw new Error("Repository profile is required.");
634
+ }
635
+ } else {
636
+ const shouldConfigureWorkflow = await askUntilValid(promptAdapter, {
637
+ id: "configureWorkflow",
638
+ type: "confirm",
639
+ message: "Configure repository workflow profile fields",
640
+ defaultValue: false,
641
+ }, {
642
+ output,
643
+ normalize: normalizeConfirmAnswer,
644
+ validate() {},
645
+ });
646
+ if (!shouldConfigureWorkflow) {
647
+ return {
648
+ profile,
649
+ profilePath: profileState.profilePath,
650
+ savedProfilePath: null,
651
+ };
652
+ }
653
+ }
654
+
655
+ profile = await promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile });
656
+ const savedProfilePath = await writeInteractiveProfile(profileState.profilePath, profile, {
657
+ exists: profileState.exists,
658
+ originalProfile,
659
+ originalText: profileState.text,
660
+ originalIsSymbolicLink: profileState.isSymbolicLink,
661
+ });
662
+ return {
663
+ profile,
664
+ profilePath: savedProfilePath,
665
+ savedProfilePath,
666
+ };
667
+ }
668
+
295
669
  async function promptProfilePath(promptAdapter, output, prompt) {
296
- let profile = null;
670
+ let profileState = null;
297
671
  const profilePath = await askUntilValid(promptAdapter, prompt, {
298
672
  output,
299
673
  normalize: normalizeTextAnswer,
300
674
  async validate(value) {
301
- profile = await readProfile(value);
675
+ if (!value) throw new Error("Repository profile path is required.");
676
+ try {
677
+ profileState = await inspectProfilePath(value);
678
+ } catch (error) {
679
+ if (error.message?.startsWith("profile must be valid JSON")) {
680
+ throw error;
681
+ }
682
+ if (error.message?.startsWith("invalid ")) {
683
+ throw new Error(`profile is invalid: ${error.message}`);
684
+ }
685
+ throw new Error(`profile could not be read: ${error.message}`);
686
+ }
302
687
  },
303
688
  });
304
- return { profilePath, profile };
689
+ return { profilePath, ...profileState };
305
690
  }
306
691
 
307
692
  function hasOwnOption(options, key) {
@@ -321,6 +706,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
321
706
  input = process.stdin,
322
707
  output = process.stderr,
323
708
  isInteractiveTerminal = Boolean(input?.isTTY),
709
+ onSavedProfilePath = null,
324
710
  } = {}) {
325
711
  if (!isInteractiveTerminal) {
326
712
  throw new Error("interactive mode requires a terminal. Re-run with --repo <owner/name> --limit <1-100> --profile <path> --out <directory>, or provide a TTY for prompts.");
@@ -331,6 +717,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
331
717
  const resolved = { ...options };
332
718
  delete resolved.interactivePromptDefaults;
333
719
  let repositoryProfile = null;
720
+ let profileState = null;
334
721
 
335
722
  try {
336
723
  if (!resolved.repository) {
@@ -364,11 +751,34 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
364
751
  type: "path",
365
752
  message: "Repository profile path",
366
753
  });
367
- resolved.profilePath = prompted.profilePath;
368
- repositoryProfile = prompted.profile;
754
+ profileState = prompted;
369
755
  } else {
370
- repositoryProfile = await readProfile(resolved.profilePath);
756
+ try {
757
+ profileState = {
758
+ profilePath: resolved.profilePath,
759
+ ...await inspectProfilePath(resolved.profilePath),
760
+ };
761
+ } catch (error) {
762
+ if (error.message?.startsWith("profile must be valid JSON")) {
763
+ throw error;
764
+ }
765
+ if (error.message?.startsWith("invalid ")) {
766
+ throw new Error(`profile is invalid: ${error.message}`);
767
+ }
768
+ throw new Error(`profile could not be read: ${error.message}`);
769
+ }
770
+ }
771
+ resolved.profilePath = profileState.profilePath;
772
+
773
+ const profileUpdate = await maybeConfigureInteractiveProfile(adapter, output, profileState, resolved.repository);
774
+ resolved.profilePath = profileUpdate.profilePath;
775
+ if (profileUpdate.savedProfilePath) {
776
+ resolved.savedProfilePath = profileUpdate.savedProfilePath;
777
+ if (typeof onSavedProfilePath === "function") {
778
+ onSavedProfilePath(profileUpdate.savedProfilePath);
779
+ }
371
780
  }
781
+ repositoryProfile = profileUpdate.profile;
372
782
 
373
783
  if (!resolved.outDir) {
374
784
  resolved.outDir = await askUntilValid(adapter, {
@@ -612,8 +1022,8 @@ function attachCollectionCoverage(report, sourceBundle) {
612
1022
  };
613
1023
  }
614
1024
 
615
- function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter }) {
616
- return {
1025
+ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath }) {
1026
+ const summary = {
617
1027
  ok: true,
618
1028
  dryRun,
619
1029
  csvArtifactsEnabled: Boolean(csv),
@@ -628,6 +1038,10 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
628
1038
  totals: metrics?.totals ?? null,
629
1039
  topBottleneckIds: report?.summary?.topBottleneckIds ?? null,
630
1040
  };
1041
+ if (savedProfilePath) {
1042
+ summary.savedProfilePath = savedProfilePath;
1043
+ }
1044
+ return summary;
631
1045
  }
632
1046
 
633
1047
  function applyPrClassFilter(normalized, excludedPrClasses = []) {
@@ -699,6 +1113,7 @@ export async function runAnalyzeGithub(options, {
699
1113
  sampledLimit: collectionLimit,
700
1114
  csv: false,
701
1115
  analysisFilter: null,
1116
+ savedProfilePath: options.savedProfilePath,
702
1117
  });
703
1118
  }
704
1119
 
@@ -747,6 +1162,7 @@ export async function runAnalyzeGithub(options, {
747
1162
  sampledLimit: collectionLimit,
748
1163
  csv: csvEnabled,
749
1164
  analysisFilter: normalized.analysisFilter ?? null,
1165
+ savedProfilePath: options.savedProfilePath,
750
1166
  });
751
1167
  }
752
1168
 
@@ -816,6 +1232,10 @@ export function formatAnalyzeGithubCompletion(result) {
816
1232
  );
817
1233
  }
818
1234
 
1235
+ if (result.savedProfilePath) {
1236
+ lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
1237
+ }
1238
+
819
1239
  lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
820
1240
 
821
1241
  const caveats = coverageCaveats(result.collectionCoverage);
@@ -847,36 +1267,50 @@ export async function runAnalyzeGithubCli(argv, {
847
1267
  promptAdapter = null,
848
1268
  isInteractiveTerminal = Boolean(stdin?.isTTY),
849
1269
  } = {}) {
850
- const options = parseAnalyzeGithubArgs(argv);
851
- if (options.help) {
852
- stdout.write(USAGE);
853
- return null;
854
- }
855
-
856
- const resolvedOptions = options.interactive
857
- ? await collectInteractiveAnalyzeGithubOptions({
858
- ...options,
859
- interactivePromptDefaults: {
860
- dryRun: !options.dryRun,
861
- csv: options.csv !== false,
862
- json: !options.json,
863
- },
864
- }, {
865
- promptAdapter,
866
- input: stdin,
867
- output: stderr,
868
- isInteractiveTerminal,
869
- })
870
- : options;
871
- const runOptions = {
872
- onProgress: message => writeProgress(message, stderr),
873
- };
874
- if (provider !== undefined) runOptions.provider = provider;
875
- if (now !== undefined) runOptions.now = now;
1270
+ let savedProfilePath = null;
1271
+ try {
1272
+ const options = parseAnalyzeGithubArgs(argv);
1273
+ if (options.help) {
1274
+ stdout.write(USAGE);
1275
+ return null;
1276
+ }
876
1277
 
877
- const result = await runAnalyzeGithub(resolvedOptions, runOptions);
878
- writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
879
- return result;
1278
+ const resolvedOptions = options.interactive
1279
+ ? await collectInteractiveAnalyzeGithubOptions({
1280
+ ...options,
1281
+ interactivePromptDefaults: {
1282
+ dryRun: !options.dryRun,
1283
+ csv: options.csv !== false,
1284
+ json: !options.json,
1285
+ },
1286
+ }, {
1287
+ promptAdapter,
1288
+ input: stdin,
1289
+ output: stderr,
1290
+ isInteractiveTerminal,
1291
+ onSavedProfilePath(path) {
1292
+ savedProfilePath = path;
1293
+ },
1294
+ })
1295
+ : options;
1296
+ if (resolvedOptions.savedProfilePath) {
1297
+ savedProfilePath = resolvedOptions.savedProfilePath;
1298
+ }
1299
+ const runOptions = {
1300
+ onProgress: message => writeProgress(message, stderr),
1301
+ };
1302
+ if (provider !== undefined) runOptions.provider = provider;
1303
+ if (now !== undefined) runOptions.now = now;
1304
+
1305
+ const result = await runAnalyzeGithub(resolvedOptions, runOptions);
1306
+ writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
1307
+ return result;
1308
+ } catch (error) {
1309
+ if (savedProfilePath) {
1310
+ stderr.write(`Repository profile saved before failure: ${savedProfilePath}.\n`);
1311
+ }
1312
+ throw error;
1313
+ }
880
1314
  }
881
1315
 
882
1316
  async function main(argv) {