@akagilnc/pi-workflow-roles 0.1.1941 → 0.1.2004
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/public-cli/main.js +2448 -75
- package/package.json +1 -1
- package/src/atomic-write.ts +26 -0
- package/src/ledger-session-read.ts +260 -0
- package/src/public-cli/cli.ts +24 -0
- package/src/public-cli/invocation.ts +352 -1
- package/src/public-cli/main.ts +21 -2
- package/src/public-cli/registry.ts +18 -1
- package/src/public-cli/settlement.ts +9 -0
- package/src/public-cli/taishi-run.ts +235 -0
- package/src/run-terminal-artifacts.ts +231 -0
- package/src/taishi-cohort.ts +232 -0
- package/src/taishi-entry.ts +429 -0
- package/src/taishi-index.ts +269 -0
- package/src/taishi-ledger.ts +466 -0
- package/src/taishi-median.ts +15 -0
- package/src/taishi-metric-families/acceptance-success-rework.ts +346 -0
- package/src/taishi-metric-families/b2-frame-buckets-actions.ts +274 -0
- package/src/taishi-metric-families/leg-wall-clock.ts +90 -0
- package/src/taishi-metric-families/round-timeline.ts +201 -0
- package/src/taishi-metric-families.ts +36 -0
- package/src/taishi-metric-family.ts +41 -0
- package/src/taishi-model-groups.ts +198 -0
- package/src/taishi-page.ts +320 -0
- package/src/ticket-trajectory.ts +9 -62
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical reader for run-directory typed terminal artifacts.
|
|
3
|
+
* Layout owner is settlement publish*Artifacts (report.json / error.json /
|
|
4
|
+
* audit-incomplete.json under artifacts/, plus the same publisher's durable
|
|
5
|
+
* failure fallbacks). This module only reads presence and structural
|
|
6
|
+
* readability — it does not re-derive role outcomes or invent a second
|
|
7
|
+
* candidate algorithm.
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const RUN_TERMINAL_ARTIFACT_FILES = [
|
|
13
|
+
"report.json",
|
|
14
|
+
"error.json",
|
|
15
|
+
"audit-incomplete.json",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type RunTerminalArtifactFile = (typeof RUN_TERMINAL_ARTIFACT_FILES)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fixed durable failure paths publishFailureArtifacts may settle when the
|
|
22
|
+
* conventional artifacts/error.json name cannot be written. Shared face so the
|
|
23
|
+
* reader follows the publisher — not a parallel search algorithm.
|
|
24
|
+
* Relative to the run directory.
|
|
25
|
+
*/
|
|
26
|
+
export const RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS = [
|
|
27
|
+
"artifacts/error.settlement.json",
|
|
28
|
+
"error.settlement.json",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/** Unique open-ended failure names: error.<uuid>.json (publisher stem + uuid). */
|
|
32
|
+
const UNIQUE_ERROR_FALLBACK_NAME =
|
|
33
|
+
/^error\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/i;
|
|
34
|
+
|
|
35
|
+
export type RunTerminalArtifactRead =
|
|
36
|
+
| { readonly status: "absent" }
|
|
37
|
+
| {
|
|
38
|
+
readonly status: "present";
|
|
39
|
+
readonly file: RunTerminalArtifactFile;
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly body: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
readonly status: "unreadable";
|
|
45
|
+
readonly file: RunTerminalArtifactFile;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function isMissingPathError(error: unknown): boolean {
|
|
51
|
+
return (
|
|
52
|
+
error instanceof Error
|
|
53
|
+
&& "code" in error
|
|
54
|
+
&& (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function errorText(error: unknown): string {
|
|
59
|
+
return error instanceof Error ? error.message : String(error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Minimum producer-owned face shared by settlement terminal artifacts
|
|
68
|
+
* (report / error / audit-incomplete). Consumer-driven: enough to identify a
|
|
69
|
+
* usable typed terminal artifact; null, arrays, primitives, and role-less
|
|
70
|
+
* objects are unreadable (ADR 0043).
|
|
71
|
+
*/
|
|
72
|
+
function readUsableTerminalArtifactBody(
|
|
73
|
+
body: unknown,
|
|
74
|
+
): { readonly ok: true; readonly body: Record<string, unknown> } | { readonly ok: false; readonly reason: string } {
|
|
75
|
+
if (body === null) {
|
|
76
|
+
return { ok: false, reason: "terminal artifact JSON value is null" };
|
|
77
|
+
}
|
|
78
|
+
if (!isRecord(body)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: `terminal artifact JSON value is not a typed object (${Array.isArray(body) ? "array" : typeof body})`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (typeof body.role !== "string" || body.role.trim() === "") {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: "terminal artifact missing nonblank producer-owned role field",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, body };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readTerminalArtifactAtPath(
|
|
94
|
+
path: string,
|
|
95
|
+
file: RunTerminalArtifactFile,
|
|
96
|
+
): Promise<RunTerminalArtifactRead | undefined> {
|
|
97
|
+
let raw: string;
|
|
98
|
+
try {
|
|
99
|
+
raw = await readFile(path, "utf8");
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isMissingPathError(error)) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
status: "unreadable",
|
|
104
|
+
file,
|
|
105
|
+
path,
|
|
106
|
+
reason: errorText(error),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return {
|
|
114
|
+
status: "unreadable",
|
|
115
|
+
file,
|
|
116
|
+
path,
|
|
117
|
+
reason:
|
|
118
|
+
error instanceof Error
|
|
119
|
+
? error.message
|
|
120
|
+
: `terminal artifact JSON parse failed: ${String(error)}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const usable = readUsableTerminalArtifactBody(parsed);
|
|
124
|
+
if (!usable.ok) {
|
|
125
|
+
return {
|
|
126
|
+
status: "unreadable",
|
|
127
|
+
file,
|
|
128
|
+
path,
|
|
129
|
+
reason: usable.reason,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { status: "present", file, path, body: usable.body };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function listUniqueErrorFallbackPaths(
|
|
136
|
+
directories: readonly string[],
|
|
137
|
+
): Promise<string[]> {
|
|
138
|
+
const found: string[] = [];
|
|
139
|
+
for (const dir of directories) {
|
|
140
|
+
let names: string[];
|
|
141
|
+
try {
|
|
142
|
+
names = await readdir(dir);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (isMissingPathError(error)) continue;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
for (const name of names.sort((a, b) => a.localeCompare(b))) {
|
|
148
|
+
if (!UNIQUE_ERROR_FALLBACK_NAME.test(name)) continue;
|
|
149
|
+
found.push(join(dir, name));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Publisher run-directory face is `<runId>@<role>`. Parent-directory unique
|
|
157
|
+
* fallbacks are shared across sibling runs, so binding uses this runId only.
|
|
158
|
+
*/
|
|
159
|
+
function runIdFromRunDirectory(runDirectory: string): string | undefined {
|
|
160
|
+
const name = basename(runDirectory);
|
|
161
|
+
const at = name.lastIndexOf("@");
|
|
162
|
+
if (at <= 0 || at === name.length - 1) return undefined;
|
|
163
|
+
return name.slice(0, at);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Shared parent-directory unique fallback may be adopted only when the
|
|
168
|
+
* publisher-owned body.runId equals this run directory's runId. Same-run
|
|
169
|
+
* artifactsDir / runDirectory candidates keep path ownership and skip this.
|
|
170
|
+
*/
|
|
171
|
+
function presentUniqueFallbackBoundToRun(
|
|
172
|
+
body: Record<string, unknown>,
|
|
173
|
+
expectedRunId: string | undefined,
|
|
174
|
+
): boolean {
|
|
175
|
+
if (expectedRunId === undefined) return false;
|
|
176
|
+
return typeof body.runId === "string" && body.runId === expectedRunId;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read the first present typed terminal artifact for a run directory.
|
|
181
|
+
* Order:
|
|
182
|
+
* 1) conventional artifacts/{report,error,audit-incomplete}.json
|
|
183
|
+
* 2) publisher fixed failure fallbacks (error.settlement.json faces)
|
|
184
|
+
* 3) publisher unique error.<uuid>.json fallbacks under same-run dirs
|
|
185
|
+
* 4) shared parent-directory unique fallbacks bound by body.runId
|
|
186
|
+
*
|
|
187
|
+
* Absence of every known durable face is a valid no-receipt state (not unreadable).
|
|
188
|
+
* A present file that cannot be parsed as a usable typed JSON object is unreadable.
|
|
189
|
+
*/
|
|
190
|
+
export async function readRunTerminalArtifact(
|
|
191
|
+
runDirectory: string,
|
|
192
|
+
): Promise<RunTerminalArtifactRead> {
|
|
193
|
+
const artifactsDir = join(runDirectory, "artifacts");
|
|
194
|
+
for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
|
|
195
|
+
const path = join(artifactsDir, file);
|
|
196
|
+
const read = await readTerminalArtifactAtPath(path, file);
|
|
197
|
+
if (read !== undefined) return read;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Publisher settled a durable failure outside the conventional error.json name.
|
|
201
|
+
for (const relative of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) {
|
|
202
|
+
const path = join(runDirectory, relative);
|
|
203
|
+
const read = await readTerminalArtifactAtPath(path, "error.json");
|
|
204
|
+
if (read !== undefined) return read;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Same-run unique faces: path ownership is the run itself — no cross-run risk.
|
|
208
|
+
for (const path of await listUniqueErrorFallbackPaths([artifactsDir, runDirectory])) {
|
|
209
|
+
const read = await readTerminalArtifactAtPath(path, "error.json");
|
|
210
|
+
if (read !== undefined) return read;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Shared parent (runs/) unique faces: require publisher runId binding.
|
|
214
|
+
const expectedRunId = runIdFromRunDirectory(runDirectory);
|
|
215
|
+
for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) {
|
|
216
|
+
const read = await readTerminalArtifactAtPath(path, "error.json");
|
|
217
|
+
if (read === undefined) continue;
|
|
218
|
+
if (read.status === "present") {
|
|
219
|
+
if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId)) continue;
|
|
220
|
+
return read;
|
|
221
|
+
}
|
|
222
|
+
// Unreadable parent unique file cannot prove run identity — do not adopt.
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { status: "absent" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Test/helper: basename face of a unique fallback path, if any. */
|
|
229
|
+
export function isUniqueErrorFallbackName(name: string): boolean {
|
|
230
|
+
return UNIQUE_ERROR_FALLBACK_NAME.test(basename(name));
|
|
231
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taishi 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 {
|
|
18
|
+
findTaishiLibraryIndexRow,
|
|
19
|
+
readTaishiLibraryIndexPage,
|
|
20
|
+
type TaishiLibraryIndexPage,
|
|
21
|
+
} from "./taishi-index.ts";
|
|
22
|
+
import { medianNumber } from "./taishi-median.ts";
|
|
23
|
+
import type { TaishiIssueMetricsPage } from "./taishi-page.ts";
|
|
24
|
+
import type { TaishiRoleAcceptanceStats } from "./taishi-metric-families/acceptance-success-rework.ts";
|
|
25
|
+
import type { TaishiLegWallClockSection } from "./taishi-metric-families/leg-wall-clock.ts";
|
|
26
|
+
import type { TaishiAcceptanceSuccessReworkSection } from "./taishi-metric-families/acceptance-success-rework.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* #338 page ensurer — read existing page or compute via sole issue kernel.
|
|
30
|
+
* Injected by the entry so cohort never opens a second compute route.
|
|
31
|
+
*/
|
|
32
|
+
export type TaishiIssuePageEnsuring = (input: {
|
|
33
|
+
readonly projectRoot: string;
|
|
34
|
+
readonly issueNumber: number;
|
|
35
|
+
}) => Promise<TaishiIssueMetricsPage>;
|
|
36
|
+
|
|
37
|
+
/** LOC-style optional metric — never encode absence as 0 or Infinity. */
|
|
38
|
+
export type TaishiCohortOptionalMetric =
|
|
39
|
+
| { readonly status: "present"; readonly value: number }
|
|
40
|
+
| { readonly status: "absent" };
|
|
41
|
+
|
|
42
|
+
export type TaishiCohortGroupInput = {
|
|
43
|
+
readonly groupLabel: string;
|
|
44
|
+
/** Issue numbers (caller typed); join key into the library index. */
|
|
45
|
+
readonly issues: readonly number[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type TaishiCohortModeInput = {
|
|
49
|
+
readonly mode: "cohort";
|
|
50
|
+
/** Exactly two groups side-by-side (before/after etc. — caller labels). */
|
|
51
|
+
readonly groups: readonly [TaishiCohortGroupInput, TaishiCohortGroupInput];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Per-issue join face: index hit + readable page → present; index miss → typed vacancy. */
|
|
55
|
+
export type TaishiCohortIssueEntry =
|
|
56
|
+
| {
|
|
57
|
+
readonly issueNumber: number;
|
|
58
|
+
readonly status: "present";
|
|
59
|
+
readonly projectRoot: string;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
readonly issueNumber: number;
|
|
63
|
+
readonly status: "absent";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Per-role contrast stats within one cohort group. */
|
|
67
|
+
export type TaishiCohortRoleStats = {
|
|
68
|
+
readonly role: string;
|
|
69
|
+
/** Concatenated lane×role call-count samples across present issues. */
|
|
70
|
+
readonly convergenceRounds: readonly number[];
|
|
71
|
+
readonly convergenceRoundsMedian: TaishiCohortOptionalMetric;
|
|
72
|
+
readonly firstPassRate: TaishiCohortOptionalMetric;
|
|
73
|
+
readonly successRate: TaishiCohortOptionalMetric;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type TaishiCohortGroupResult = {
|
|
77
|
+
readonly groupLabel: string;
|
|
78
|
+
/** One entry per input issue number, in input order (vacancy single-listed). */
|
|
79
|
+
readonly issues: readonly TaishiCohortIssueEntry[];
|
|
80
|
+
readonly byRole: readonly TaishiCohortRoleStats[];
|
|
81
|
+
readonly reworkRatio: TaishiCohortOptionalMetric;
|
|
82
|
+
readonly medianWallMs: TaishiCohortOptionalMetric;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type TaishiCohortModeResult = {
|
|
86
|
+
readonly mode: "cohort";
|
|
87
|
+
readonly groups: readonly [TaishiCohortGroupResult, TaishiCohortGroupResult];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Issue page shape cohort actually reads (envelope + B-family sections). */
|
|
91
|
+
type TaishiCohortSourcePage = TaishiIssueMetricsPage & {
|
|
92
|
+
readonly acceptanceSuccessRework?: TaishiAcceptanceSuccessReworkSection;
|
|
93
|
+
readonly legWallClock?: TaishiLegWallClockSection;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const ABSENT: TaishiCohortOptionalMetric = { status: "absent" };
|
|
97
|
+
|
|
98
|
+
function presentMetric(value: number): TaishiCohortOptionalMetric {
|
|
99
|
+
return { status: "present", value };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rateMetric(numerator: number, denominator: number): TaishiCohortOptionalMetric {
|
|
103
|
+
if (denominator === 0) return ABSENT;
|
|
104
|
+
return presentMetric(numerator / denominator);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function optionalMedian(values: readonly number[]): TaishiCohortOptionalMetric {
|
|
108
|
+
const median = medianNumber(values);
|
|
109
|
+
return median === undefined ? ABSENT : presentMetric(median);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type RoleAccum = {
|
|
113
|
+
convergenceRounds: number[];
|
|
114
|
+
firstPassLaneCount: number;
|
|
115
|
+
appearanceLaneCount: number;
|
|
116
|
+
successCount: number;
|
|
117
|
+
successEligibleCount: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function emptyRoleAccum(): RoleAccum {
|
|
121
|
+
return {
|
|
122
|
+
convergenceRounds: [],
|
|
123
|
+
firstPassLaneCount: 0,
|
|
124
|
+
appearanceLaneCount: 0,
|
|
125
|
+
successCount: 0,
|
|
126
|
+
successEligibleCount: 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function absorbRole(accum: RoleAccum, stats: TaishiRoleAcceptanceStats): void {
|
|
131
|
+
accum.convergenceRounds.push(...stats.convergenceRounds);
|
|
132
|
+
accum.firstPassLaneCount += stats.firstPassLaneCount;
|
|
133
|
+
accum.appearanceLaneCount += stats.appearanceLaneCount;
|
|
134
|
+
accum.successCount += stats.successCount;
|
|
135
|
+
accum.successEligibleCount += stats.successEligibleCount;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function finishRole(role: string, accum: RoleAccum): TaishiCohortRoleStats {
|
|
139
|
+
return {
|
|
140
|
+
role,
|
|
141
|
+
convergenceRounds: accum.convergenceRounds,
|
|
142
|
+
convergenceRoundsMedian: optionalMedian(accum.convergenceRounds),
|
|
143
|
+
firstPassRate: rateMetric(accum.firstPassLaneCount, accum.appearanceLaneCount),
|
|
144
|
+
successRate: rateMetric(accum.successCount, accum.successEligibleCount),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function aggregateGroup(
|
|
149
|
+
index: TaishiLibraryIndexPage | undefined,
|
|
150
|
+
input: TaishiCohortGroupInput,
|
|
151
|
+
ensureIssuePage: TaishiIssuePageEnsuring,
|
|
152
|
+
): Promise<TaishiCohortGroupResult> {
|
|
153
|
+
const issueEntries: TaishiCohortIssueEntry[] = [];
|
|
154
|
+
const roleAccums = new Map<string, RoleAccum>();
|
|
155
|
+
let reworkWallMs = 0;
|
|
156
|
+
let totalWallMs = 0;
|
|
157
|
+
let hasReworkSample = false;
|
|
158
|
+
const legWalls: number[] = [];
|
|
159
|
+
|
|
160
|
+
for (const issueNumber of input.issues) {
|
|
161
|
+
const row = findTaishiLibraryIndexRow(index, issueNumber);
|
|
162
|
+
if (row === undefined) {
|
|
163
|
+
// Only "index has no such row" is typed vacancy.
|
|
164
|
+
issueEntries.push({ issueNumber, status: "absent" });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Index hit: ensure page via sole compute-if-missing kernel (read or compute).
|
|
169
|
+
// Ensure failure stays loud with issue identity — never washed to absent.
|
|
170
|
+
const page = (await ensureIssuePage({
|
|
171
|
+
projectRoot: row.projectRoot,
|
|
172
|
+
issueNumber,
|
|
173
|
+
})) as TaishiCohortSourcePage;
|
|
174
|
+
|
|
175
|
+
issueEntries.push({
|
|
176
|
+
issueNumber,
|
|
177
|
+
status: "present",
|
|
178
|
+
projectRoot: row.projectRoot,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const acceptance = page.acceptanceSuccessRework;
|
|
182
|
+
if (acceptance !== undefined) {
|
|
183
|
+
for (const roleStats of acceptance.byRole) {
|
|
184
|
+
const accum = roleAccums.get(roleStats.role) ?? emptyRoleAccum();
|
|
185
|
+
absorbRole(accum, roleStats);
|
|
186
|
+
roleAccums.set(roleStats.role, accum);
|
|
187
|
+
}
|
|
188
|
+
reworkWallMs += acceptance.rework.reworkWallMs;
|
|
189
|
+
totalWallMs += acceptance.rework.totalWallMs;
|
|
190
|
+
hasReworkSample = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const legWallClock = page.legWallClock;
|
|
194
|
+
if (legWallClock !== undefined) {
|
|
195
|
+
for (const leg of legWallClock.ranking) {
|
|
196
|
+
legWalls.push(leg.wallMs);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const byRole = [...roleAccums.keys()]
|
|
202
|
+
.sort((a, b) => a.localeCompare(b))
|
|
203
|
+
.map((role) => finishRole(role, roleAccums.get(role)!));
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
groupLabel: input.groupLabel,
|
|
207
|
+
issues: issueEntries,
|
|
208
|
+
byRole,
|
|
209
|
+
reworkRatio: hasReworkSample ? rateMetric(reworkWallMs, totalWallMs) : ABSENT,
|
|
210
|
+
medianWallMs: optionalMedian(legWalls),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run cohort contrast: join index by issueNumber, ensure pages (#338), fold,
|
|
216
|
+
* emit two side-by-side group results. Page writes happen only through the
|
|
217
|
+
* injected ensurer (sole issue kernel + existing writer) — cohort itself is
|
|
218
|
+
* not a second compute kernel or projection.
|
|
219
|
+
*/
|
|
220
|
+
export async function runTaishiCohortMode(
|
|
221
|
+
ledgerHome: string,
|
|
222
|
+
input: TaishiCohortModeInput,
|
|
223
|
+
ensureIssuePage: TaishiIssuePageEnsuring,
|
|
224
|
+
): Promise<TaishiCohortModeResult> {
|
|
225
|
+
const index = await readTaishiLibraryIndexPage(ledgerHome);
|
|
226
|
+
const group0 = await aggregateGroup(index, input.groups[0], ensureIssuePage);
|
|
227
|
+
const group1 = await aggregateGroup(index, input.groups[1], ensureIssuePage);
|
|
228
|
+
return {
|
|
229
|
+
mode: "cohort",
|
|
230
|
+
groups: [group0, group1],
|
|
231
|
+
};
|
|
232
|
+
}
|