delivery-friction-analyzer 0.4.0 → 0.6.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
 
@@ -27,6 +27,7 @@ The command reads local `friction-metrics.v1` JSON and writes deterministic `fri
27
27
  - `metricVersion`: source metrics contract version.
28
28
  - `targetRepository`: analyzed repository identity; live analysis sample size is encoded as `targetRepository.analysisPullRequestLimit` from collection metadata.
29
29
  - `analysisFilter`: optional metadata for explicit filters applied before metrics computation, including excluded PR classes and before/after PR counts.
30
+ - `configuredWorkflow`: optional user-configured workflow context from the repository profile. It is not observed GitHub evidence and does not change scoring, ranking, CSV exports, or PR class matching.
30
31
  - `summary`: repository totals and top bottleneck identifiers.
31
32
  - `coverage`: PR-open diff, workflow-run, and review-thread coverage counts plus caveats.
32
33
  - `commentSources`: total and source-grouped review comments for Copilot, human, bot, scanner, author replies, and unknown sources.
@@ -55,6 +56,7 @@ The Markdown renderer presents the same report data for human review:
55
56
  - a top-of-report focus snapshot that names focus areas, action categories, evidence reviewed, and confidence caveats before detailed bottlenecks;
56
57
  - a compact recommendation-category snapshot before detailed bottlenecks, with the full category reference retained later in the report;
57
58
  - a short "How To Read This Report" guide that distinguishes observed evidence, interpretation, recommendations, and caveats;
59
+ - a configured workflow context section only when repository profile workflow fields are present, labeled as user-configured profile context rather than observed GitHub evidence;
58
60
  - evidence-quality and coverage tables before detailed recommendations;
59
61
  - key findings that highlight top bottlenecks, strongest displayed signal, outlier caveats, PR class caveats, and coverage caveats;
60
62
  - a PR class context table that shows analyzed PR counts, changed lines, sample share, and classification sources by class;
@@ -104,6 +106,7 @@ Full live analysis writes `methodology.md` as a hybrid artifact: stable explanat
104
106
 
105
107
  - target repository and report/metric versions;
106
108
  - profile path when available;
109
+ - configured workflow context when supplied by the repository profile, labeled as user-configured context rather than observed GitHub evidence;
107
110
  - requested and collected PR counts;
108
111
  - collection coverage status and API-family diagnostics;
109
112
  - scoring, ranking, dominance, sensitivity, and limitation explanations;
@@ -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 reports can surface as configured profile context, but the analyzer does not infer these values from GitHub and does not change scoring, rankings, collection, CSV exports, or PR class matching.
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.6.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,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### 2026-06-19 — Workflow Context Surfacing
6
+
7
+ - What changed: Friction reports and methodology now show configured repository workflow context from the profile when it is present.
8
+ - Why it matters: Maintainers can see merge, release, and branch assumptions beside report evidence without mistaking them for observed GitHub data or scoring inputs.
9
+ - Who is affected: Maintainers and contributors reviewing generated reports or authoring repository profiles with `workflow` context.
10
+ - Action needed: None.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/37
12
+
13
+ ### 2026-06-18 — Workflow Profile Wizard
14
+
15
+ - What changed: Interactive setup can now create repository profiles or generated profile copies with confirmed workflow context and release PR title rules.
16
+ - Why it matters: Maintainers can capture reusable workflow assumptions during guided setup without hand-editing profile JSON or risking silent rewrites of existing profiles.
17
+ - Who is affected: Maintainers running `--interactive` for first-time repository setup or profile updates.
18
+ - Action needed: Review the generated or updated profile path printed in completion output before reusing it in automation.
19
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/36
20
+
5
21
  ### 2026-06-17 — Workflow Profile Contract
6
22
 
7
23
  - 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
 
@@ -708,7 +1123,10 @@ export async function runAnalyzeGithub(options, {
708
1123
  options.excludedPrClasses ?? [],
709
1124
  );
710
1125
  const metrics = computeRepositoryMetrics(normalized);
711
- const report = attachCollectionCoverage(generateRepositoryFrictionReport(metrics), sourceBundle);
1126
+ const report = attachCollectionCoverage(
1127
+ generateRepositoryFrictionReport(metrics, { workflowContext: repositoryProfile.workflow }),
1128
+ sourceBundle,
1129
+ );
712
1130
  const markdown = `${renderRepositoryFrictionMarkdown(report)}${collectionCoverageMarkdown(sourceBundle)}`;
713
1131
  const methodology = renderRepositoryFrictionMethodology({
714
1132
  report,
@@ -747,6 +1165,7 @@ export async function runAnalyzeGithub(options, {
747
1165
  sampledLimit: collectionLimit,
748
1166
  csv: csvEnabled,
749
1167
  analysisFilter: normalized.analysisFilter ?? null,
1168
+ savedProfilePath: options.savedProfilePath,
750
1169
  });
751
1170
  }
752
1171
 
@@ -816,6 +1235,10 @@ export function formatAnalyzeGithubCompletion(result) {
816
1235
  );
817
1236
  }
818
1237
 
1238
+ if (result.savedProfilePath) {
1239
+ lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
1240
+ }
1241
+
819
1242
  lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
820
1243
 
821
1244
  const caveats = coverageCaveats(result.collectionCoverage);
@@ -847,36 +1270,50 @@ export async function runAnalyzeGithubCli(argv, {
847
1270
  promptAdapter = null,
848
1271
  isInteractiveTerminal = Boolean(stdin?.isTTY),
849
1272
  } = {}) {
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;
1273
+ let savedProfilePath = null;
1274
+ try {
1275
+ const options = parseAnalyzeGithubArgs(argv);
1276
+ if (options.help) {
1277
+ stdout.write(USAGE);
1278
+ return null;
1279
+ }
876
1280
 
877
- const result = await runAnalyzeGithub(resolvedOptions, runOptions);
878
- writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
879
- return result;
1281
+ const resolvedOptions = options.interactive
1282
+ ? await collectInteractiveAnalyzeGithubOptions({
1283
+ ...options,
1284
+ interactivePromptDefaults: {
1285
+ dryRun: !options.dryRun,
1286
+ csv: options.csv !== false,
1287
+ json: !options.json,
1288
+ },
1289
+ }, {
1290
+ promptAdapter,
1291
+ input: stdin,
1292
+ output: stderr,
1293
+ isInteractiveTerminal,
1294
+ onSavedProfilePath(path) {
1295
+ savedProfilePath = path;
1296
+ },
1297
+ })
1298
+ : options;
1299
+ if (resolvedOptions.savedProfilePath) {
1300
+ savedProfilePath = resolvedOptions.savedProfilePath;
1301
+ }
1302
+ const runOptions = {
1303
+ onProgress: message => writeProgress(message, stderr),
1304
+ };
1305
+ if (provider !== undefined) runOptions.provider = provider;
1306
+ if (now !== undefined) runOptions.now = now;
1307
+
1308
+ const result = await runAnalyzeGithub(resolvedOptions, runOptions);
1309
+ writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
1310
+ return result;
1311
+ } catch (error) {
1312
+ if (savedProfilePath) {
1313
+ stderr.write(`Repository profile saved before failure: ${savedProfilePath}.\n`);
1314
+ }
1315
+ throw error;
1316
+ }
880
1317
  }
881
1318
 
882
1319
  async function main(argv) {
@@ -1,3 +1,9 @@
1
+ import {
2
+ CONFIGURED_WORKFLOW_NOTE,
3
+ configuredWorkflowEntries,
4
+ hasConfiguredWorkflowContext,
5
+ } from "./friction-report.js";
6
+
1
7
  const BOT_OR_SCANNER_SOURCES = new Set([
2
8
  "copilot",
3
9
  "github_actions_bot",
@@ -336,6 +342,21 @@ function formatSensitivitySummaries(report) {
336
342
  }).join("\n");
337
343
  }
338
344
 
345
+ function formatConfiguredWorkflowContext(report) {
346
+ const configuredWorkflow = report.configuredWorkflow;
347
+ if (!hasConfiguredWorkflowContext(configuredWorkflow)) return [];
348
+
349
+ return [
350
+ "## Configured Workflow Context",
351
+ "",
352
+ configuredWorkflow.note ?? CONFIGURED_WORKFLOW_NOTE,
353
+ "",
354
+ ...configuredWorkflowEntries(configuredWorkflow)
355
+ .map(entry => `- ${entry.label}: ${entry.valueLabel}`),
356
+ "",
357
+ ];
358
+ }
359
+
339
360
  export function renderRepositoryFrictionMethodology({
340
361
  report,
341
362
  sourceBundle,
@@ -373,6 +394,7 @@ export function renderRepositoryFrictionMethodology({
373
394
  "",
374
395
  "The repository profile maps file paths to categories, roles, and functional surfaces. Those classifications drive non-generated changed-line counts, support-surface summaries, planning-document signals, and low-signal weighting.",
375
396
  "",
397
+ ...formatConfiguredWorkflowContext(report),
376
398
  "## Scores And Rankings",
377
399
  "",
378
400
  "The report ranks bottlenecks by transparent component metrics from `friction-metrics.v1`: review churn, change scope (the internal changed-file-spread signal: core files touched plus directories touched plus functional surfaces touched), validation gap, planning gap, review surprise, and fix amplification. These are not an opaque composite score, and they are not individual contributor or reviewer rankings.",
@@ -61,6 +61,28 @@ const RANKING_SIGNAL_LABELS = new Map([
61
61
  ["fixAmplification", "fix amplification"],
62
62
  ]);
63
63
 
64
+ const WORKFLOW_CONTEXT_FIELDS = [
65
+ ["primaryMergeMethod", "Primary merge method"],
66
+ ["releaseStrategy", "Release strategy"],
67
+ ["branchStrategy", "Branch strategy"],
68
+ ];
69
+
70
+ const WORKFLOW_CONTEXT_VALUE_LABELS = new Map([
71
+ ["merge_commit", "Merge commit"],
72
+ ["squash_merge", "Squash merge"],
73
+ ["rebase_merge", "Rebase merge"],
74
+ ["release_prs", "Release PRs"],
75
+ ["direct_tags", "Direct tags"],
76
+ ["release_branches", "Release branches"],
77
+ ["trunk_based", "Trunk-based"],
78
+ ["main_plus_release_branches", "Main plus release branches"],
79
+ ["long_lived_development_branches", "Long-lived development branches"],
80
+ ["mixed", "Mixed"],
81
+ ["unknown", "Unknown"],
82
+ ]);
83
+
84
+ export const CONFIGURED_WORKFLOW_NOTE = "Configured workflow context comes from the repository profile. It is user-configured context, not observed GitHub evidence, and it does not change scores, rankings, CSV exports, or PR class matching.";
85
+
64
86
  const BOTTLENECK_DEFINITIONS = [
65
87
  {
66
88
  id: "review-churn",
@@ -563,6 +585,42 @@ function summarizeRecommendationCategories(bottlenecks) {
563
585
  }));
564
586
  }
565
587
 
588
+ export function normalizeConfiguredWorkflowContext(workflowContext) {
589
+ if (!workflowContext || typeof workflowContext !== "object" || Array.isArray(workflowContext)) {
590
+ return null;
591
+ }
592
+
593
+ const configuredWorkflow = {
594
+ source: "repository_profile",
595
+ note: CONFIGURED_WORKFLOW_NOTE,
596
+ };
597
+ for (const [field] of WORKFLOW_CONTEXT_FIELDS) {
598
+ if (workflowContext[field] !== undefined) {
599
+ configuredWorkflow[field] = workflowContext[field];
600
+ }
601
+ }
602
+
603
+ return WORKFLOW_CONTEXT_FIELDS.some(([field]) => configuredWorkflow[field] !== undefined)
604
+ ? configuredWorkflow
605
+ : null;
606
+ }
607
+
608
+ export function configuredWorkflowEntries(configuredWorkflow) {
609
+ if (!configuredWorkflow || typeof configuredWorkflow !== "object") return [];
610
+ return WORKFLOW_CONTEXT_FIELDS
611
+ .filter(([field]) => configuredWorkflow[field] !== undefined)
612
+ .map(([field, label]) => ({
613
+ field,
614
+ label,
615
+ value: configuredWorkflow[field],
616
+ valueLabel: WORKFLOW_CONTEXT_VALUE_LABELS.get(configuredWorkflow[field]) ?? configuredWorkflow[field],
617
+ }));
618
+ }
619
+
620
+ export function hasConfiguredWorkflowContext(configuredWorkflow) {
621
+ return configuredWorkflowEntries(configuredWorkflow).length > 0;
622
+ }
623
+
566
624
  function evidenceSignature(bottleneck) {
567
625
  return (bottleneck.observedData ?? [])
568
626
  .map(evidence => evidence.number)
@@ -712,16 +770,19 @@ function summarizeSensitivity(metricsSummary, baselineBottlenecks) {
712
770
  };
713
771
  }
714
772
 
715
- export function generateRepositoryFrictionReport(metricsSummary) {
773
+ export function generateRepositoryFrictionReport(metricsSummary, options = {}) {
774
+ const { workflowContext } = options ?? {};
716
775
  const prClasses = summarizePrClasses(metricsSummary);
717
776
  const bottlenecksWithSharedSignalKeys = summarizeBottlenecks(metricsSummary, prClasses);
718
777
  const sharedSignals = summarizeSharedSignals(bottlenecksWithSharedSignalKeys);
719
778
  const bottlenecks = bottlenecksWithSharedSignalKeys.map(({ rankingKey, ...bottleneck }) => bottleneck);
779
+ const configuredWorkflow = normalizeConfiguredWorkflowContext(workflowContext);
720
780
  return {
721
781
  reportVersion: FRICTION_REPORT_VERSION,
722
782
  metricVersion: metricsSummary.metricVersion,
723
783
  targetRepository: metricsSummary.targetRepository,
724
784
  ...(metricsSummary.analysisFilter ? { analysisFilter: metricsSummary.analysisFilter } : {}),
785
+ ...(configuredWorkflow ? { configuredWorkflow } : {}),
725
786
  summary: {
726
787
  pullRequests: metricsSummary.totals?.pullRequests ?? 0,
727
788
  changedLines: metricsSummary.totals?.changedLines ?? 0,
@@ -1152,6 +1213,23 @@ function renderPrClassContext(prClasses) {
1152
1213
  ].join("\n");
1153
1214
  }
1154
1215
 
1216
+ function renderConfiguredWorkflowContext(configuredWorkflow) {
1217
+ const entries = configuredWorkflowEntries(configuredWorkflow);
1218
+ if (!entries.length) return "";
1219
+
1220
+ return [
1221
+ "## Configured Workflow Context",
1222
+ "",
1223
+ configuredWorkflow.note ?? CONFIGURED_WORKFLOW_NOTE,
1224
+ "",
1225
+ renderMarkdownTable(
1226
+ ["Field", "Configured value"],
1227
+ entries.map(entry => [entry.label, entry.valueLabel]),
1228
+ ),
1229
+ "",
1230
+ ].join("\n");
1231
+ }
1232
+
1155
1233
  function classDominanceCaveat(bottleneck) {
1156
1234
  return bottleneck.classDominance?.status === "single_class_dominates"
1157
1235
  ? bottleneck.classDominance.note
@@ -1299,7 +1377,13 @@ export function renderRepositoryFrictionMarkdown(report) {
1299
1377
  "- Interpretation is the analyzer's explanation of what the observed evidence suggests.",
1300
1378
  "- Recommendation is a workflow intervention to consider; the report does not modify repositories.",
1301
1379
  "- Confidence and caveats call out outliers, missing coverage, and evidence-quality limits before you act.",
1380
+ ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1381
+ ? ["- Configured workflow context, when shown, comes from the repository profile and is not observed GitHub evidence."]
1382
+ : []),
1302
1383
  "",
1384
+ ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1385
+ ? [renderConfiguredWorkflowContext(report.configuredWorkflow)]
1386
+ : []),
1303
1387
  "## Evidence Quality And Coverage",
1304
1388
  "",
1305
1389
  renderCoverageSummary(report.coverage),
@@ -1370,6 +1454,9 @@ export function renderRepositoryFrictionMarkdown(report) {
1370
1454
  "- Bottlenecks are ranked by their strongest representative observed signal, with stable category order only used to break ties.",
1371
1455
  "- Recommendations are inferred from transparent component evidence and representative PR examples; they are not automated changes.",
1372
1456
  "- Missing or partial GitHub data remains visible in coverage tables rather than being inferred from unrelated fields.",
1457
+ ...(hasConfiguredWorkflowContext(report.configuredWorkflow)
1458
+ ? ["- Configured workflow context is user-configured repository-profile context; it does not change scoring, ranking, CSV exports, or PR class matching."]
1459
+ : []),
1373
1460
  "- Sensitivity analysis, when present, excludes one dominant representative PR at a time to show robustness context without changing the baseline ranking.",
1374
1461
  report.analysisFilter?.excludedPrClasses?.length
1375
1462
  ? "- PR class filtering was explicitly applied before metrics and ranking; PR class context still supports interpretation of the filtered sample."