@akagilnc/pi-workflow-roles 0.1.4800 → 0.1.4870

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 (143) hide show
  1. package/dist/acp-host/production-host.js +19434 -18218
  2. package/dist/analyst-book-key.js +63 -0
  3. package/dist/analyst-cohort.js +167 -0
  4. package/dist/analyst-entry.js +320 -0
  5. package/dist/analyst-index.js +247 -0
  6. package/dist/analyst-ledger.js +561 -0
  7. package/dist/analyst-median.js +16 -0
  8. package/dist/analyst-metric-families/acceptance-success-rework.js +249 -0
  9. package/dist/analyst-metric-families/b2-frame-buckets-actions.js +189 -0
  10. package/dist/analyst-metric-families/gate-cycles.js +77 -0
  11. package/dist/analyst-metric-families/leg-wall-clock.js +56 -0
  12. package/dist/analyst-metric-families/round-timeline.js +126 -0
  13. package/dist/analyst-metric-families.js +23 -0
  14. package/dist/analyst-metric-family.js +11 -0
  15. package/dist/analyst-model-groups.js +135 -0
  16. package/dist/analyst-page.js +182 -0
  17. package/dist/atomic-write.js +23 -0
  18. package/dist/auditor-soul.js +1 -1
  19. package/dist/countersign-contracts.js +0 -2
  20. package/dist/diarist-contracts.js +0 -1
  21. package/dist/doctor-contracts.js +2 -2
  22. package/dist/gatekeeper-role.js +15 -16
  23. package/dist/gleaner-left-contracts.js +0 -2
  24. package/dist/headless-host/production-host.js +16967 -15749
  25. package/dist/host-contracts.js +0 -4
  26. package/dist/inspector-contracts.js +0 -2
  27. package/dist/institutional-resolution.js +4 -2
  28. package/dist/merger-contracts.js +0 -2
  29. package/dist/migrate-book-topology.js +3908 -3089
  30. package/dist/navigator-public-session.js +4 -3
  31. package/dist/notary-contracts.js +0 -2
  32. package/dist/package-contracts/auditor-output.js +0 -1
  33. package/dist/package-contracts/collector-output.js +0 -2
  34. package/dist/package-contracts/fixer-output.js +0 -2
  35. package/dist/package-contracts/gatekeeper-output.js +0 -2
  36. package/dist/package-contracts/judge-output.js +0 -2
  37. package/dist/package-contracts/navigator-output.js +0 -2
  38. package/dist/package-contracts/reviewer-output.js +0 -2
  39. package/dist/package-contracts/terminating-tools.js +32 -92
  40. package/dist/package-contracts/worker-output.js +0 -4
  41. package/dist/packaged-role-registry.js +518 -36
  42. package/dist/pi/role-turn-host.js +2 -1
  43. package/dist/public-cli/analyst-run.js +185 -0
  44. package/dist/public-cli/cli.js +885 -0
  45. package/dist/public-cli/config.js +12 -9
  46. package/dist/public-cli/countersign-run.js +4 -311
  47. package/dist/public-cli/instruction-seat-run.js +628 -158
  48. package/dist/public-cli/invocation.js +806 -1550
  49. package/dist/public-cli/main.js +6962 -9882
  50. package/dist/public-cli/option-definitions.js +51 -99
  51. package/dist/public-cli/post-admission.js +30 -2
  52. package/dist/public-cli/run-lifecycle.js +234 -353
  53. package/dist/public-cli/seat-ticket-binding.js +6 -0
  54. package/dist/public-cli/settlement.js +261 -675
  55. package/dist/public-cli/turn-request.js +76 -0
  56. package/dist/public-role-summons.js +38 -112
  57. package/dist/role-activation-flags.js +23 -45
  58. package/dist/secretariat-contracts.js +0 -2
  59. package/dist/session-identity.js +17 -8
  60. package/dist/session-opening-materials.js +3 -2
  61. package/extensions/role-runtime.ts +6 -80
  62. package/package.json +1 -1
  63. package/souls/coder.md +1 -2
  64. package/src/acp-host/role-turn-host.ts +4 -1
  65. package/src/analyst-metric-families/acceptance-success-rework.ts +3 -2
  66. package/src/auditor-role.ts +1 -2
  67. package/src/auditor-soul.ts +1 -1
  68. package/src/collector-role.ts +2 -2
  69. package/src/countersign-contracts.ts +0 -1
  70. package/src/countersign-role.ts +1 -1
  71. package/src/diarist-contracts.ts +0 -1
  72. package/src/diarist-role.ts +0 -1
  73. package/src/doctor-auditor.ts +1 -2
  74. package/src/doctor-contracts.ts +1 -1
  75. package/src/doctor-role.ts +3 -2
  76. package/src/external-host-turn-loop.ts +1 -1
  77. package/src/factory-board.ts +10 -7
  78. package/src/gatekeeper-role.ts +16 -10
  79. package/src/gleaner-left-contracts.ts +0 -1
  80. package/src/gleaner-left-role.ts +0 -1
  81. package/src/headless-host/role-turn-host.ts +7 -1
  82. package/src/host-contracts.ts +10 -6
  83. package/src/inspector-contracts.ts +0 -1
  84. package/src/inspector-role.ts +0 -2
  85. package/src/institutional-resolution.ts +6 -4
  86. package/src/judge-role.ts +3 -3
  87. package/src/merger-contracts.ts +0 -1
  88. package/src/merger-role.ts +2 -2
  89. package/src/navigator-public-session.ts +7 -6
  90. package/src/navigator-work-context.ts +6 -8
  91. package/src/notary-contracts.ts +0 -1
  92. package/src/notary-role.ts +2 -3
  93. package/src/package-contracts/auditor-output.ts +0 -1
  94. package/src/package-contracts/collector-output.ts +0 -1
  95. package/src/package-contracts/fixer-output.ts +0 -1
  96. package/src/package-contracts/gatekeeper-output.ts +0 -1
  97. package/src/package-contracts/judge-output.ts +0 -1
  98. package/src/package-contracts/navigator-output.ts +0 -1
  99. package/src/package-contracts/reviewer-output.ts +0 -1
  100. package/src/package-contracts/terminating-tools.ts +27 -99
  101. package/src/package-contracts/worker-output.ts +0 -2
  102. package/src/packaged-role-registry.ts +604 -35
  103. package/src/pi/role-turn-host.ts +2 -1
  104. package/src/public-cli/cli.ts +64 -259
  105. package/src/public-cli/config.ts +18 -10
  106. package/src/public-cli/countersign-run.ts +14 -449
  107. package/src/public-cli/instruction-seat-run.ts +758 -277
  108. package/src/public-cli/invocation.ts +1097 -2051
  109. package/src/public-cli/option-definitions.ts +62 -127
  110. package/src/public-cli/post-admission.ts +47 -2
  111. package/src/public-cli/run-lifecycle.ts +303 -613
  112. package/src/public-cli/seat-ticket-binding.ts +6 -0
  113. package/src/public-cli/settlement.ts +399 -1185
  114. package/src/public-cli/turn-request.ts +92 -1
  115. package/src/public-role-summons.ts +44 -128
  116. package/src/reviewer-role.ts +3 -2
  117. package/src/role-activation-flags.ts +20 -41
  118. package/src/role-runtime-dependencies.ts +25 -26
  119. package/src/role-runtime.ts +121 -232
  120. package/src/secretariat-contracts.ts +0 -1
  121. package/src/secretariat-role.ts +0 -2
  122. package/src/session-identity.ts +23 -8
  123. package/src/session-opening-materials.ts +2 -1
  124. package/src/worker-role.ts +3 -4
  125. package/dist/doctor-auditor.js +0 -25
  126. package/dist/public-cli/diarist-run.js +0 -130
  127. package/dist/public-cli/doctor-run.js +0 -87
  128. package/dist/public-cli/inspector-run.js +0 -151
  129. package/dist/public-cli/judge-run.js +0 -114
  130. package/dist/public-cli/notary-run.js +0 -174
  131. package/dist/public-cli/reviewer-run.js +0 -339
  132. package/src/public-cli/coder-run.ts +0 -255
  133. package/src/public-cli/collector-run.ts +0 -189
  134. package/src/public-cli/diarist-run.ts +0 -233
  135. package/src/public-cli/doctor-run.ts +0 -167
  136. package/src/public-cli/fixer-run.ts +0 -265
  137. package/src/public-cli/gleaner-left-run.ts +0 -167
  138. package/src/public-cli/inspector-run.ts +0 -261
  139. package/src/public-cli/judge-run.ts +0 -196
  140. package/src/public-cli/merger-run.ts +0 -294
  141. package/src/public-cli/notary-run.ts +0 -279
  142. package/src/public-cli/reviewer-run.ts +0 -480
  143. package/src/public-cli/secretariat-run.ts +0 -265
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Single true source for the Analyst projectRoot→bookKey rule (#399 / ADR 0048).
3
+ * Git-resolvable → git common-dir host directory name; otherwise the stable
4
+ * synthetic `root:<projectRoot identity>` so read/write page paths agree
5
+ * without a prior scan. Issue/sweep derivation and legacy library-index
6
+ * healing must both call this rule — never a second copy of the fallback.
7
+ *
8
+ * Failure honesty: a real existing directory that is a *confirmed* non-repository
9
+ * (git's own "not a git repository" verdict, plus the structurally-identical
10
+ * absent root / non-directory root / ENOTDIR mid-path faces — no repository can
11
+ * ever live there) keeps the r4-adjudicated legal `root:<identity>` fallback.
12
+ * Unconfirmed git failures — dubious ownership exit 128 and every other
13
+ * diagnostic that does not certify "non repository" — propagate loudly with
14
+ * their real cause (#413 r2 U5): washing them into a synthetic key would be
15
+ * silent identity drift. Git infrastructure failures (missing binary → ENOENT)
16
+ * stay loud too. The confirmed/unconfirmed classification is implemented once,
17
+ * in the shared resolver owner.
18
+ */
19
+ import { statSync } from "node:fs";
20
+ import { isAbsolute } from "node:path";
21
+ import { ActivationGitRepositoryRequiredError, resolveBookKeyFromGit, } from "./activation-ledger-git.js";
22
+ import { errnoCode, physicalPathIdentity } from "./activation-ledger-topology.js";
23
+ export function resolveAnalystBookKey(projectRoot) {
24
+ const identity = physicalPathIdentity(projectRoot);
25
+ let stats;
26
+ try {
27
+ stats = statSync(identity);
28
+ }
29
+ catch (error) {
30
+ // Absent root AND plain file mid-path both mean "this path can never be a
31
+ // directory, hence never a Git repository" — same synthetic fallback.
32
+ const code = errnoCode(error);
33
+ if (code === "ENOENT" || code === "ENOTDIR")
34
+ return `root:${identity}`;
35
+ throw error;
36
+ }
37
+ if (!stats.isDirectory())
38
+ return `root:${identity}`;
39
+ // #413 r2 U5 boundary at the Analyst seam: only git's own confirmed
40
+ // "not a git repository" verdict may fall back to `root:<identity>`;
41
+ // unconfirmed nonzero exits (dubious ownership etc.) stay loud.
42
+ try {
43
+ return resolveBookKeyFromGit(identity);
44
+ }
45
+ catch (error) {
46
+ if (error instanceof ActivationGitRepositoryRequiredError
47
+ && error.confirmedNonRepository) {
48
+ return `root:${identity}`;
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+ /**
54
+ * #413 r2 U3: synthetic keys are exactly `root:` + an absolute path identity
55
+ * (the physicalPathIdentity face). A real Git book whose basename is literally
56
+ * `root:foo` is NOT synthetic — its remainder is not an absolute path. The
57
+ * check is bidirectional: real books are never misclassified by the prefix,
58
+ * and existing synthetic keys keep their path-scope meaning.
59
+ */
60
+ export function isSyntheticAnalystBookKey(bookKey) {
61
+ return bookKey.startsWith("root:")
62
+ && isAbsolute(bookKey.slice("root:".length));
63
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Analyst cohort contrast aggregation (ADR 0068 / PRD #298 output ③ / #330 / #338).
3
+ *
4
+ * Query product — joins the library index, ensures each hit has a metrics page
5
+ * (compute-if-missing via caller-supplied sole issue kernel), then folds pages.
6
+ * No second ledger scan, no second parse kernel, no persistence of the contrast.
7
+ *
8
+ * Aggregation nails (ticket #330):
9
+ * - ratios (first-pass / success / rework): merge numerators & denominators
10
+ * - convergence rounds sample = one per lane×role (page byRole.convergenceRounds)
11
+ * - leg wall-clock median sample = one per leg (page legWallClock.ranking)
12
+ * - missing index row / zero denominator → typed 空缺 (LOC vacancy shape)
13
+ * - index hit + missing page → sync ensure (compute-if-missing); ensure failure is
14
+ * typed terminal for this pull (never washed into absent/pending) — #338.
15
+ * - single-run unreadable on a page stays page-local exclusion, not whole failure.
16
+ */
17
+ import { findAnalystLibraryIndexRow, readAnalystLibraryIndexPage, } from "./analyst-index.js";
18
+ import { medianNumber } from "./analyst-median.js";
19
+ const ABSENT = { status: "absent" };
20
+ function presentMetric(value) {
21
+ return { status: "present", value };
22
+ }
23
+ function rateMetric(numerator, denominator) {
24
+ if (denominator === 0)
25
+ return ABSENT;
26
+ return presentMetric(numerator / denominator);
27
+ }
28
+ function optionalMedian(values) {
29
+ const median = medianNumber(values);
30
+ return median === undefined ? ABSENT : presentMetric(median);
31
+ }
32
+ function emptyRoleAccum() {
33
+ return {
34
+ convergenceRounds: [],
35
+ firstPassLaneCount: 0,
36
+ appearanceLaneCount: 0,
37
+ successCount: 0,
38
+ successEligibleCount: 0,
39
+ };
40
+ }
41
+ function absorbRole(accum, stats) {
42
+ accum.convergenceRounds.push(...stats.convergenceRounds);
43
+ accum.firstPassLaneCount += stats.firstPassLaneCount;
44
+ accum.appearanceLaneCount += stats.appearanceLaneCount;
45
+ accum.successCount += stats.successCount;
46
+ accum.successEligibleCount += stats.successEligibleCount;
47
+ }
48
+ function finishRole(role, accum) {
49
+ return {
50
+ role,
51
+ convergenceRounds: accum.convergenceRounds,
52
+ convergenceRoundsMedian: optionalMedian(accum.convergenceRounds),
53
+ firstPassRate: rateMetric(accum.firstPassLaneCount, accum.appearanceLaneCount),
54
+ successRate: rateMetric(accum.successCount, accum.successEligibleCount),
55
+ };
56
+ }
57
+ function emptyGateOfficerNumeratorAccum() {
58
+ return { rounds: 0, bounceCount: 0, passCount: 0, wallSum: 0 };
59
+ }
60
+ function absorbGateOfficerSummary(accum, summary) {
61
+ accum.rounds += summary.rounds;
62
+ accum.bounceCount += summary.bounceCount;
63
+ accum.passCount += summary.passCount;
64
+ if (summary.meanOfficerWallMs !== undefined) {
65
+ accum.wallSum += summary.meanOfficerWallMs * summary.rounds;
66
+ }
67
+ }
68
+ function finishGateOfficerNumerators(officer, accum) {
69
+ return {
70
+ officer,
71
+ rounds: accum.rounds,
72
+ bounceCount: accum.bounceCount,
73
+ passCount: accum.passCount,
74
+ bounceRate: rateMetric(accum.bounceCount, accum.rounds),
75
+ meanOfficerWallMs: accum.rounds === 0 ? ABSENT : presentMetric(accum.wallSum / accum.rounds),
76
+ };
77
+ }
78
+ async function aggregateGroup(index, input, ensureIssuePage) {
79
+ const issueEntries = [];
80
+ const roleAccums = new Map();
81
+ const gateOfficerAccums = new Map();
82
+ let reworkWallMs = 0;
83
+ let totalWallMs = 0;
84
+ let hasReworkSample = false;
85
+ const legWalls = [];
86
+ for (const ref of input.issues) {
87
+ const { issueNumber, bookKey } = ref;
88
+ const row = findAnalystLibraryIndexRow(index, issueNumber, bookKey);
89
+ if (row === undefined) {
90
+ // Only "index has no such row in this book" is typed vacancy.
91
+ // The requested bookKey rides along so book-a:12 and book-b:12 absences
92
+ // are distinguishable (#413 r2 U4).
93
+ issueEntries.push({ issueNumber, status: "absent", bookKey });
94
+ continue;
95
+ }
96
+ // Index hit: ensure page via sole compute-if-missing kernel (read or compute).
97
+ // Ensure failure stays loud with issue identity — never washed to absent.
98
+ // row.bookKey is normalized at index read — present projection always carries it (F3).
99
+ const page = (await ensureIssuePage({
100
+ projectRoot: row.projectRoot,
101
+ issueNumber,
102
+ bookKey: row.bookKey,
103
+ }));
104
+ issueEntries.push({
105
+ issueNumber,
106
+ status: "present",
107
+ bookKey: row.bookKey,
108
+ projectRoot: row.projectRoot,
109
+ });
110
+ const acceptance = page.acceptanceSuccessRework;
111
+ if (acceptance !== undefined) {
112
+ for (const roleStats of acceptance.byRole) {
113
+ const accum = roleAccums.get(roleStats.role) ?? emptyRoleAccum();
114
+ absorbRole(accum, roleStats);
115
+ roleAccums.set(roleStats.role, accum);
116
+ }
117
+ reworkWallMs += acceptance.rework.reworkWallMs;
118
+ totalWallMs += acceptance.rework.totalWallMs;
119
+ hasReworkSample = true;
120
+ }
121
+ const legWallClock = page.legWallClock;
122
+ if (legWallClock !== undefined) {
123
+ for (const leg of legWallClock.ranking) {
124
+ legWalls.push(leg.wallMs);
125
+ }
126
+ }
127
+ // Gate-cycle fold: merge page-projected byOfficer numerators (no rescan,
128
+ // no second status→bounce/pass classifier — sole owner is gate-cycles family).
129
+ const gateCycles = page.gateCycles;
130
+ if (gateCycles !== undefined) {
131
+ for (const summary of gateCycles.byOfficer) {
132
+ const accum = gateOfficerAccums.get(summary.officer) ?? emptyGateOfficerNumeratorAccum();
133
+ absorbGateOfficerSummary(accum, summary);
134
+ gateOfficerAccums.set(summary.officer, accum);
135
+ }
136
+ }
137
+ }
138
+ const byRole = [...roleAccums.keys()]
139
+ .sort((a, b) => a.localeCompare(b))
140
+ .map((role) => finishRole(role, roleAccums.get(role)));
141
+ const gateCyclesByOfficer = ["inspector", "notary"]
142
+ .filter((officer) => gateOfficerAccums.has(officer))
143
+ .map((officer) => finishGateOfficerNumerators(officer, gateOfficerAccums.get(officer)));
144
+ return {
145
+ groupLabel: input.groupLabel,
146
+ issues: issueEntries,
147
+ byRole,
148
+ reworkRatio: hasReworkSample ? rateMetric(reworkWallMs, totalWallMs) : ABSENT,
149
+ medianWallMs: optionalMedian(legWalls),
150
+ gateCyclesByOfficer,
151
+ };
152
+ }
153
+ /**
154
+ * Run cohort contrast: join index by (bookKey, issueNumber), ensure pages (#338), fold,
155
+ * emit two side-by-side group results. Page writes happen only through the
156
+ * injected ensurer (sole issue kernel + existing writer) — cohort itself is
157
+ * not a second compute kernel or projection.
158
+ */
159
+ export async function runAnalystCohortMode(ledgerHome, input, ensureIssuePage) {
160
+ const index = await readAnalystLibraryIndexPage(ledgerHome);
161
+ const group0 = await aggregateGroup(index, input.groups[0], ensureIssuePage);
162
+ const group1 = await aggregateGroup(index, input.groups[1], ensureIssuePage);
163
+ return {
164
+ mode: "cohort",
165
+ groups: [group0, group1],
166
+ };
167
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * 太史 sole entry (ADR 0068 / PRD #298).
3
+ * Deterministic analysis seat: read ledger records, write sibling metrics pages.
4
+ * A1/A2: issue-mode typed input; C1 adds sweep mode on this same seam.
5
+ * A2: scan retains typed per-run facts; page builder folds registered metric families.
6
+ * C1: sweep = merged PR list + LOC → backfill issue pages + maintain library index.
7
+ * C2: cohort = two issue-number groups → join library index → contrast query output.
8
+ * C3: model-groups = caller issue set → scan union → per-leg model aggregate.
9
+ * #338: retrieval compute-if-missing — sync wait for sole kernel, then full result.
10
+ * Whole-compute failure is typed terminal for this pull (no pending envelope).
11
+ * "Unobtrusive / non-blocking" binds #337 merge auto-trigger only, not user query.
12
+ */
13
+ import { readFile } from "node:fs/promises";
14
+ import { Type } from "typebox";
15
+ import { physicalPathIdentity, resolveActivationLedgerHome } from "./activation-ledger-topology.js";
16
+ import { isSyntheticAnalystBookKey, resolveAnalystBookKey } from "./analyst-book-key.js";
17
+ import { runAnalystCohortMode, } from "./analyst-cohort.js";
18
+ import { scanAnalystIssueRuns, } from "./analyst-ledger.js";
19
+ import { mergeAnalystLibraryIndexRows, rowFromIssueMetricsPage, } from "./analyst-index.js";
20
+ import { buildAnalystModelGroupsPage, } from "./analyst-model-groups.js";
21
+ import { assertAnalystChangedLinesInput, buildAnalystIssueMetricsPage, analystIssuePagePath, writeAnalystIssueMetricsPage, } from "./analyst-page.js";
22
+ /** #338 compute-if-missing failure — issue identity + real cause (CLI → ControlledFailure). */
23
+ export class AnalystIssueComputeError extends Error {
24
+ code = "analyst-issue-compute-failed";
25
+ bookKey;
26
+ projectRoot;
27
+ issueNumber;
28
+ constructor(input) {
29
+ const root = physicalPathIdentity(input.projectRoot);
30
+ const causeText = input.cause instanceof Error
31
+ ? input.cause.message || input.cause.name
32
+ : String(input.cause);
33
+ const issueFace = input.issueNumber === undefined
34
+ ? `book ${input.bookKey} (projectRoot ${root})`
35
+ : `issue ${input.issueNumber} book ${input.bookKey} (projectRoot ${root})`;
36
+ super(`analyst compute failed for ${issueFace}: ${causeText}`, {
37
+ cause: input.cause,
38
+ });
39
+ this.name = "AnalystIssueComputeError";
40
+ this.bookKey = input.bookKey;
41
+ this.projectRoot = root;
42
+ if (input.issueNumber !== undefined) {
43
+ this.issueNumber = input.issueNumber;
44
+ }
45
+ }
46
+ }
47
+ function isMissingPathError(error) {
48
+ return (error instanceof Error
49
+ && "code" in error
50
+ && (error.code === "ENOENT" || error.code === "ENOTDIR"));
51
+ }
52
+ /**
53
+ * Sole sweep-mode input contract (#298/#329/#337).
54
+ * Schema is the single definition; TS types are derived (no parallel hand shape).
55
+ * projectRoot = string (not nonempty); changedLines optional finite non-negative;
56
+ * 0 remains typed 空缺; no extra keys.
57
+ */
58
+ export const analystSweepModeInputSchema = Type.Object({
59
+ mode: Type.Literal("sweep"),
60
+ mergedPullRequests: Type.Array(Type.Object({
61
+ projectRoot: Type.String(),
62
+ /** 排除后改动行数 — omit or 0 → typed 空缺; finite ≥ 0 only. */
63
+ changedLines: Type.Optional(Type.Number({ minimum: 0, maximum: Number.MAX_VALUE })),
64
+ }, { additionalProperties: false })),
65
+ }, { additionalProperties: false });
66
+ /**
67
+ * #338 retrieval primitive (sync): use persisted page when present; otherwise
68
+ * await the sole issue compute kernel (runAnalystIssueMode) which writes via the
69
+ * existing page entry, then return the full result. No pending/async envelope.
70
+ * Compute failures throw AnalystIssueComputeError (issue identity + real cause)
71
+ * and terminate this pull — never washed to absent/partial success.
72
+ * Single-run unreadable/damaged stays page-local exclusion (PRD #298), not a
73
+ * whole-compute failure. Sweep / explicit recompute still use runAnalystIssueMode.
74
+ */
75
+ /**
76
+ * Cached page may be reused only under bidirectional book×ticket scope equality.
77
+ * - requested ticket present: page.issueNumber must equal it and bookKey matches
78
+ * - requested ticket absent: only reuse a page that also lacks issueNumber
79
+ * (a narrower ticket page must not stand in for the full book page)
80
+ */
81
+ function cachedPageMatchesRequestedScope(page, input) {
82
+ if (page.bookKey !== input.bookKey)
83
+ return false;
84
+ const requestedTicket = input.ticketNumber ?? input.issueNumber;
85
+ if (requestedTicket === undefined) {
86
+ return page.issueNumber === undefined;
87
+ }
88
+ return page.issueNumber === requestedTicket;
89
+ }
90
+ /**
91
+ * Resolve page/scan book identity for issue mode (#399).
92
+ * CLI supplies bookKey from cwd git common-dir.
93
+ * Sweep/legacy without bookKey falls back to the single shared
94
+ * projectRoot→bookKey rule (git common-dir, else `root:<identity>`).
95
+ */
96
+ function resolveIssueBookKey(input) {
97
+ if (input.bookKey !== undefined && input.bookKey.trim() !== "") {
98
+ return input.bookKey;
99
+ }
100
+ return resolveAnalystBookKey(input.projectRoot);
101
+ }
102
+ export async function readOrComputeAnalystIssuePage(input,
103
+ /**
104
+ * Cohort ensure only (#412): narrow the cache-miss recompute scan to this
105
+ * root inside the already-selected book — a miss must never widen to a
106
+ * whole-book scan for one index row. Not a public CLI face.
107
+ */
108
+ options) {
109
+ const ledgerHome = resolveActivationLedgerHome(options?.home);
110
+ const projectRoot = physicalPathIdentity(input.projectRoot);
111
+ const bookKey = resolveIssueBookKey(input);
112
+ const issueNumber = input.ticketNumber ?? input.issueNumber;
113
+ const pagePath = analystIssuePagePath(ledgerHome, {
114
+ bookKey,
115
+ ...(issueNumber === undefined ? {} : { issueNumber }),
116
+ // Sweep/legacy path-narrow pages (no ticket, no explicit CLI book-only scope).
117
+ ...(issueNumber === undefined && input.bookKey === undefined
118
+ ? { scopeRootIdentity: projectRoot }
119
+ : {}),
120
+ });
121
+ try {
122
+ const raw = await readFile(pagePath, "utf8");
123
+ const page = JSON.parse(raw);
124
+ if (cachedPageMatchesRequestedScope(page, { bookKey, ...input })) {
125
+ // #855 ghost legs are live observations — always fresh, never from the page cache.
126
+ const ghostScan = await scanAnalystIssueRuns({
127
+ bookKey,
128
+ ...(issueNumber === undefined ? {} : { ticketNumber: issueNumber }),
129
+ ...(options?.scanProjectRoot === undefined
130
+ ? {}
131
+ : { projectRoot: options.scanProjectRoot }),
132
+ ...(options?.home === undefined ? {} : { home: options.home }),
133
+ });
134
+ return { mode: "issue", page, pagePath, ghostLegs: ghostScan.ghostLegs };
135
+ }
136
+ // Existing page is for a different / absent ticket scope — same kernel recompute.
137
+ }
138
+ catch (error) {
139
+ if (!isMissingPathError(error)) {
140
+ // Corrupt / blocked page path — loud with issue identity, not absent.
141
+ throw new AnalystIssueComputeError({
142
+ bookKey,
143
+ projectRoot,
144
+ ...(issueNumber === undefined ? {} : { issueNumber }),
145
+ cause: error,
146
+ });
147
+ }
148
+ }
149
+ try {
150
+ return await runAnalystIssueMode(input, undefined, options?.scanProjectRoot, options?.home);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof AnalystIssueComputeError)
154
+ throw error;
155
+ throw new AnalystIssueComputeError({
156
+ bookKey,
157
+ projectRoot,
158
+ ...(issueNumber === undefined ? {} : { issueNumber }),
159
+ cause: error,
160
+ });
161
+ }
162
+ }
163
+ async function runAnalystIssueMode(input,
164
+ /** Caller-supplied scan facts — skip a second ledger walk when already scanned. */
165
+ precomputedScan,
166
+ /** Cohort ensure conjunction (#412): scan this root inside the selected book. */
167
+ scanProjectRoot, home) {
168
+ // Programmatic issue/sweep entry boundary — same finite non-negative rule as attach schema.
169
+ assertAnalystChangedLinesInput(input.changedLines);
170
+ const ledgerHome = resolveActivationLedgerHome(home);
171
+ const projectRoot = input.projectRoot;
172
+ // Sweep entries carry projectRoot only; issue mode may add ticketNumber (C4).
173
+ const ticketNumber = "ticketNumber" in input ? input.ticketNumber : undefined;
174
+ const inputBookKey = "bookKey" in input && typeof input.bookKey === "string" && input.bookKey.trim() !== ""
175
+ ? input.bookKey
176
+ : undefined;
177
+ const scan = precomputedScan ??
178
+ (inputBookKey !== undefined
179
+ ? await scanAnalystIssueRuns({
180
+ bookKey: inputBookKey,
181
+ ...(scanProjectRoot === undefined ? {} : { projectRoot: scanProjectRoot }),
182
+ ...(ticketNumber === undefined ? {} : { ticketNumber }),
183
+ ...(home === undefined ? {} : { home }),
184
+ })
185
+ : ticketNumber === undefined
186
+ ? await scanAnalystIssueRuns({ projectRoot, ...(home === undefined ? {} : { home }) })
187
+ : await scanAnalystIssueRuns({ projectRoot, ticketNumber, ...(home === undefined ? {} : { home }) }));
188
+ // exactOptionalPropertyTypes: only pass optional faces when caller supplied them.
189
+ const issueNumber = "issueNumber" in input ? input.issueNumber : undefined;
190
+ const bookKey = resolveIssueBookKey({
191
+ ...(inputBookKey === undefined ? {} : { bookKey: inputBookKey }),
192
+ projectRoot,
193
+ });
194
+ // CLI book/ticket pages: no scopeRootIdentity.
195
+ // Sweep/legacy path-narrow (no explicit bookKey, no ticket): address includes root.
196
+ const scopeRootIdentity = inputBookKey === undefined && ticketNumber === undefined && issueNumber === undefined
197
+ ? physicalPathIdentity(projectRoot)
198
+ : undefined;
199
+ // Page build discovers metric families first — missing tree fails before write.
200
+ const page = await buildAnalystIssueMetricsPage({
201
+ bookKey,
202
+ projectRoot,
203
+ runs: scan.runs,
204
+ unreadable: scan.unreadable,
205
+ scopeConflicts: scan.scopeConflicts,
206
+ ...(input.changedLines === undefined ? {} : { changedLines: input.changedLines }),
207
+ ...(issueNumber === undefined ? {} : { issueNumber }),
208
+ ...(scopeRootIdentity === undefined ? {} : { scopeRootIdentity }),
209
+ });
210
+ const pagePath = await writeAnalystIssueMetricsPage(ledgerHome, page);
211
+ // Issue number present → maintain library-index row for cohort join (sole remaining
212
+ // consumer of the index; ticket CLI path never reads it — #399 D9).
213
+ // Row carries bookKey so cross-book same ticket numbers do not merge (D5).
214
+ // Locked read→upsert→write so concurrent issue/sweep CLI writers do not drop rows.
215
+ if (issueNumber !== undefined) {
216
+ await mergeAnalystLibraryIndexRows(ledgerHome, [
217
+ rowFromIssueMetricsPage(page),
218
+ ]);
219
+ }
220
+ return { mode: "issue", page, pagePath, ghostLegs: scan.ghostLegs };
221
+ }
222
+ async function runAnalystSweepMode(input, home) {
223
+ const ledgerHome = resolveActivationLedgerHome(home);
224
+ const issuePages = [];
225
+ for (const entry of input.mergedPullRequests) {
226
+ issuePages.push(await runAnalystIssueMode(entry, undefined, undefined, home));
227
+ }
228
+ const upserts = issuePages.map((result) => rowFromIssueMetricsPage(result.page));
229
+ const { index, indexPath } = await mergeAnalystLibraryIndexRows(ledgerHome, upserts);
230
+ return { mode: "sweep", issuePages, index, indexPath };
231
+ }
232
+ async function runAnalystModelGroupsMode(input, home) {
233
+ const ledgerHome = resolveActivationLedgerHome(home);
234
+ const runs = [];
235
+ const unreadable = [];
236
+ // Dedupe scope roots by physical identity while preserving caller order for scan.
237
+ const seen = new Set();
238
+ const projectRoots = [];
239
+ for (const root of input.projectRoots) {
240
+ const identity = physicalPathIdentity(root);
241
+ if (seen.has(identity))
242
+ continue;
243
+ seen.add(identity);
244
+ projectRoots.push(identity);
245
+ }
246
+ // One ledger scan per root — shared by #338 ensure-page and model-group aggregate.
247
+ // No second scan pass; sole issue kernel + existing writer when page is missing.
248
+ for (const projectRoot of projectRoots) {
249
+ const scan = await scanAnalystIssueRuns({ projectRoot, ...(home === undefined ? {} : { home }) });
250
+ runs.push(...scan.runs);
251
+ unreadable.push(...scan.unreadable);
252
+ const bookKey = resolveIssueBookKey({ projectRoot });
253
+ const pagePath = analystIssuePagePath(ledgerHome, {
254
+ bookKey,
255
+ scopeRootIdentity: projectRoot,
256
+ });
257
+ try {
258
+ const raw = await readFile(pagePath, "utf8");
259
+ JSON.parse(raw); // present page must parse (same loud face as readOrCompute)
260
+ }
261
+ catch (error) {
262
+ if (!isMissingPathError(error)) {
263
+ throw new AnalystIssueComputeError({ bookKey, projectRoot, cause: error });
264
+ }
265
+ try {
266
+ // Reuse this root's scan facts — no second ledger walk on compute-if-missing.
267
+ await runAnalystIssueMode({ mode: "issue", projectRoot }, scan, undefined, home);
268
+ }
269
+ catch (computeError) {
270
+ if (computeError instanceof AnalystIssueComputeError)
271
+ throw computeError;
272
+ throw new AnalystIssueComputeError({ bookKey, projectRoot, cause: computeError });
273
+ }
274
+ }
275
+ }
276
+ // exactOptionalPropertyTypes: only pass mapping when caller supplied it.
277
+ const page = input.combinationMapping === undefined
278
+ ? buildAnalystModelGroupsPage({ projectRoots, runs, unreadable })
279
+ : buildAnalystModelGroupsPage({
280
+ projectRoots,
281
+ runs,
282
+ unreadable,
283
+ combinationMapping: input.combinationMapping,
284
+ });
285
+ return { mode: "model-groups", page };
286
+ }
287
+ export async function runAnalyst(input, options) {
288
+ if (input.mode === "sweep") {
289
+ return runAnalystSweepMode(input, options?.home);
290
+ }
291
+ if (input.mode === "cohort") {
292
+ const ledgerHome = resolveActivationLedgerHome(options?.home);
293
+ return runAnalystCohortMode(ledgerHome, input, async ({ projectRoot, issueNumber, bookKey }) => {
294
+ // Real ledger book keys drive book scope. Only `root:` + an absolute path
295
+ // is a synthetic sweep/legacy address key; a real book basename may be
296
+ // literally `root:foo` and must keep its book scope (U3, non-ambiguous
297
+ // bidirectional check — never a bare prefix test).
298
+ const realBookKey = bookKey !== undefined && !isSyntheticAnalystBookKey(bookKey)
299
+ ? bookKey
300
+ : undefined;
301
+ // T4 revised (#413 r2 U2 owner decision, per #399 book×ticket identity):
302
+ // cohort issueNumber IS the ticketNumber. A cache-miss recompute filters
303
+ // by bookKey ∧ projectRoot ∧ invocation.ticketNumber; legacy runs without
304
+ // a typed ticket are excluded from the recompute — never merged into the
305
+ // issue page by path alone.
306
+ const ensured = await readOrComputeAnalystIssuePage({
307
+ mode: "issue",
308
+ projectRoot,
309
+ issueNumber,
310
+ ticketNumber: issueNumber,
311
+ ...(realBookKey === undefined ? {} : { bookKey: realBookKey }),
312
+ }, { scanProjectRoot: projectRoot, ...(options?.home === undefined ? {} : { home: options.home }) });
313
+ return ensured.page;
314
+ });
315
+ }
316
+ if (input.mode === "model-groups") {
317
+ return runAnalystModelGroupsMode(input, options?.home);
318
+ }
319
+ return runAnalystIssueMode(input, undefined, undefined, options?.home);
320
+ }