@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.
- package/dist/acp-host/production-host.js +19434 -18218
- package/dist/analyst-book-key.js +63 -0
- package/dist/analyst-cohort.js +167 -0
- package/dist/analyst-entry.js +320 -0
- package/dist/analyst-index.js +247 -0
- package/dist/analyst-ledger.js +561 -0
- package/dist/analyst-median.js +16 -0
- package/dist/analyst-metric-families/acceptance-success-rework.js +249 -0
- package/dist/analyst-metric-families/b2-frame-buckets-actions.js +189 -0
- package/dist/analyst-metric-families/gate-cycles.js +77 -0
- package/dist/analyst-metric-families/leg-wall-clock.js +56 -0
- package/dist/analyst-metric-families/round-timeline.js +126 -0
- package/dist/analyst-metric-families.js +23 -0
- package/dist/analyst-metric-family.js +11 -0
- package/dist/analyst-model-groups.js +135 -0
- package/dist/analyst-page.js +182 -0
- package/dist/atomic-write.js +23 -0
- package/dist/auditor-soul.js +1 -1
- package/dist/countersign-contracts.js +0 -2
- package/dist/diarist-contracts.js +0 -1
- package/dist/doctor-contracts.js +2 -2
- package/dist/gatekeeper-role.js +15 -16
- package/dist/gleaner-left-contracts.js +0 -2
- package/dist/headless-host/production-host.js +16967 -15749
- package/dist/host-contracts.js +0 -4
- package/dist/inspector-contracts.js +0 -2
- package/dist/institutional-resolution.js +4 -2
- package/dist/merger-contracts.js +0 -2
- package/dist/migrate-book-topology.js +3908 -3089
- package/dist/navigator-public-session.js +4 -3
- package/dist/notary-contracts.js +0 -2
- package/dist/package-contracts/auditor-output.js +0 -1
- package/dist/package-contracts/collector-output.js +0 -2
- package/dist/package-contracts/fixer-output.js +0 -2
- package/dist/package-contracts/gatekeeper-output.js +0 -2
- package/dist/package-contracts/judge-output.js +0 -2
- package/dist/package-contracts/navigator-output.js +0 -2
- package/dist/package-contracts/reviewer-output.js +0 -2
- package/dist/package-contracts/terminating-tools.js +32 -92
- package/dist/package-contracts/worker-output.js +0 -4
- package/dist/packaged-role-registry.js +518 -36
- package/dist/pi/role-turn-host.js +2 -1
- package/dist/public-cli/analyst-run.js +185 -0
- package/dist/public-cli/cli.js +885 -0
- package/dist/public-cli/config.js +12 -9
- package/dist/public-cli/countersign-run.js +4 -311
- package/dist/public-cli/instruction-seat-run.js +628 -158
- package/dist/public-cli/invocation.js +806 -1550
- package/dist/public-cli/main.js +6962 -9882
- package/dist/public-cli/option-definitions.js +51 -99
- package/dist/public-cli/post-admission.js +30 -2
- package/dist/public-cli/run-lifecycle.js +234 -353
- package/dist/public-cli/seat-ticket-binding.js +6 -0
- package/dist/public-cli/settlement.js +261 -675
- package/dist/public-cli/turn-request.js +76 -0
- package/dist/public-role-summons.js +38 -112
- package/dist/role-activation-flags.js +23 -45
- package/dist/secretariat-contracts.js +0 -2
- package/dist/session-identity.js +17 -8
- package/dist/session-opening-materials.js +3 -2
- package/extensions/role-runtime.ts +6 -80
- package/package.json +1 -1
- package/souls/coder.md +1 -2
- package/src/acp-host/role-turn-host.ts +4 -1
- package/src/analyst-metric-families/acceptance-success-rework.ts +3 -2
- package/src/auditor-role.ts +1 -2
- package/src/auditor-soul.ts +1 -1
- package/src/collector-role.ts +2 -2
- package/src/countersign-contracts.ts +0 -1
- package/src/countersign-role.ts +1 -1
- package/src/diarist-contracts.ts +0 -1
- package/src/diarist-role.ts +0 -1
- package/src/doctor-auditor.ts +1 -2
- package/src/doctor-contracts.ts +1 -1
- package/src/doctor-role.ts +3 -2
- package/src/external-host-turn-loop.ts +1 -1
- package/src/factory-board.ts +10 -7
- package/src/gatekeeper-role.ts +16 -10
- package/src/gleaner-left-contracts.ts +0 -1
- package/src/gleaner-left-role.ts +0 -1
- package/src/headless-host/role-turn-host.ts +7 -1
- package/src/host-contracts.ts +10 -6
- package/src/inspector-contracts.ts +0 -1
- package/src/inspector-role.ts +0 -2
- package/src/institutional-resolution.ts +6 -4
- package/src/judge-role.ts +3 -3
- package/src/merger-contracts.ts +0 -1
- package/src/merger-role.ts +2 -2
- package/src/navigator-public-session.ts +7 -6
- package/src/navigator-work-context.ts +6 -8
- package/src/notary-contracts.ts +0 -1
- package/src/notary-role.ts +2 -3
- package/src/package-contracts/auditor-output.ts +0 -1
- package/src/package-contracts/collector-output.ts +0 -1
- package/src/package-contracts/fixer-output.ts +0 -1
- package/src/package-contracts/gatekeeper-output.ts +0 -1
- package/src/package-contracts/judge-output.ts +0 -1
- package/src/package-contracts/navigator-output.ts +0 -1
- package/src/package-contracts/reviewer-output.ts +0 -1
- package/src/package-contracts/terminating-tools.ts +27 -99
- package/src/package-contracts/worker-output.ts +0 -2
- package/src/packaged-role-registry.ts +604 -35
- package/src/pi/role-turn-host.ts +2 -1
- package/src/public-cli/cli.ts +64 -259
- package/src/public-cli/config.ts +18 -10
- package/src/public-cli/countersign-run.ts +14 -449
- package/src/public-cli/instruction-seat-run.ts +758 -277
- package/src/public-cli/invocation.ts +1097 -2051
- package/src/public-cli/option-definitions.ts +62 -127
- package/src/public-cli/post-admission.ts +47 -2
- package/src/public-cli/run-lifecycle.ts +303 -613
- package/src/public-cli/seat-ticket-binding.ts +6 -0
- package/src/public-cli/settlement.ts +399 -1185
- package/src/public-cli/turn-request.ts +92 -1
- package/src/public-role-summons.ts +44 -128
- package/src/reviewer-role.ts +3 -2
- package/src/role-activation-flags.ts +20 -41
- package/src/role-runtime-dependencies.ts +25 -26
- package/src/role-runtime.ts +121 -232
- package/src/secretariat-contracts.ts +0 -1
- package/src/secretariat-role.ts +0 -2
- package/src/session-identity.ts +23 -8
- package/src/session-opening-materials.ts +2 -1
- package/src/worker-role.ts +3 -4
- package/dist/doctor-auditor.js +0 -25
- package/dist/public-cli/diarist-run.js +0 -130
- package/dist/public-cli/doctor-run.js +0 -87
- package/dist/public-cli/inspector-run.js +0 -151
- package/dist/public-cli/judge-run.js +0 -114
- package/dist/public-cli/notary-run.js +0 -174
- package/dist/public-cli/reviewer-run.js +0 -339
- package/src/public-cli/coder-run.ts +0 -255
- package/src/public-cli/collector-run.ts +0 -189
- package/src/public-cli/diarist-run.ts +0 -233
- package/src/public-cli/doctor-run.ts +0 -167
- package/src/public-cli/fixer-run.ts +0 -265
- package/src/public-cli/gleaner-left-run.ts +0 -167
- package/src/public-cli/inspector-run.ts +0 -261
- package/src/public-cli/judge-run.ts +0 -196
- package/src/public-cli/merger-run.ts +0 -294
- package/src/public-cli/notary-run.ts +0 -279
- package/src/public-cli/reviewer-run.ts +0 -480
- 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
|
+
}
|