@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,429 @@
|
|
|
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, type Static } from "typebox";
|
|
15
|
+
|
|
16
|
+
import { physicalPathIdentity, resolveActivationLedgerHome } from "./activation-ledger-topology.ts";
|
|
17
|
+
import {
|
|
18
|
+
runTaishiCohortMode,
|
|
19
|
+
type TaishiCohortModeInput,
|
|
20
|
+
type TaishiCohortModeResult,
|
|
21
|
+
} from "./taishi-cohort.ts";
|
|
22
|
+
import {
|
|
23
|
+
scanTaishiIssueRuns,
|
|
24
|
+
type TaishiReadableRunFacts,
|
|
25
|
+
type TaishiScopedRunScan,
|
|
26
|
+
} from "./taishi-ledger.ts";
|
|
27
|
+
import {
|
|
28
|
+
mergeTaishiLibraryIndexRows,
|
|
29
|
+
rowFromIssueMetricsPage,
|
|
30
|
+
type TaishiLibraryIndexPage,
|
|
31
|
+
} from "./taishi-index.ts";
|
|
32
|
+
import {
|
|
33
|
+
buildTaishiModelGroupsPage,
|
|
34
|
+
type TaishiModelGroupsPage,
|
|
35
|
+
} from "./taishi-model-groups.ts";
|
|
36
|
+
import {
|
|
37
|
+
assertTaishiChangedLinesInput,
|
|
38
|
+
buildTaishiIssueMetricsPage,
|
|
39
|
+
taishiIssuePagePath,
|
|
40
|
+
writeTaishiIssueMetricsPage,
|
|
41
|
+
type TaishiIssueMetricsPage,
|
|
42
|
+
type TaishiUnreadableRun,
|
|
43
|
+
} from "./taishi-page.ts";
|
|
44
|
+
|
|
45
|
+
/** #338 compute-if-missing failure — issue identity + real cause (CLI → ControlledFailure). */
|
|
46
|
+
export class TaishiIssueComputeError extends Error {
|
|
47
|
+
readonly code = "taishi-issue-compute-failed" as const;
|
|
48
|
+
readonly projectRoot: string;
|
|
49
|
+
readonly issueNumber?: number;
|
|
50
|
+
|
|
51
|
+
constructor(input: {
|
|
52
|
+
readonly projectRoot: string;
|
|
53
|
+
readonly issueNumber?: number;
|
|
54
|
+
readonly cause: unknown;
|
|
55
|
+
}) {
|
|
56
|
+
const root = physicalPathIdentity(input.projectRoot);
|
|
57
|
+
const causeText =
|
|
58
|
+
input.cause instanceof Error
|
|
59
|
+
? input.cause.message || input.cause.name
|
|
60
|
+
: String(input.cause);
|
|
61
|
+
const issueFace =
|
|
62
|
+
input.issueNumber === undefined
|
|
63
|
+
? `projectRoot ${root}`
|
|
64
|
+
: `issue ${input.issueNumber} (projectRoot ${root})`;
|
|
65
|
+
super(`taishi compute failed for ${issueFace}: ${causeText}`, {
|
|
66
|
+
cause: input.cause,
|
|
67
|
+
});
|
|
68
|
+
this.name = "TaishiIssueComputeError";
|
|
69
|
+
this.projectRoot = root;
|
|
70
|
+
if (input.issueNumber !== undefined) {
|
|
71
|
+
this.issueNumber = input.issueNumber;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isMissingPathError(error: unknown): boolean {
|
|
77
|
+
return (
|
|
78
|
+
error instanceof Error
|
|
79
|
+
&& "code" in error
|
|
80
|
+
&& (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Issue-mode typed input — single-issue scope via projectRoot mechanical key. */
|
|
85
|
+
export type TaishiIssueModeInput = {
|
|
86
|
+
readonly mode: "issue";
|
|
87
|
+
readonly projectRoot: string;
|
|
88
|
+
/**
|
|
89
|
+
* C4: caller typed ticket face (#176). When set, issue 圈定 prefers matching
|
|
90
|
+
* invocation.ticketNumber; runs without ticketNumber fall back to projectRoot.
|
|
91
|
+
*/
|
|
92
|
+
readonly ticketNumber?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Losing caller projectRoot when typed ticket/index root already won
|
|
95
|
+
* (public CLI dual-param: --ticket index hit over concurrent --project-root).
|
|
96
|
+
* When set and identity-distinct from projectRoot, page records the C4
|
|
97
|
+
* typed-ticketNumber-over-projectRoot fact for this call — no ledger alien run required.
|
|
98
|
+
*/
|
|
99
|
+
readonly conflictingProjectRoot?: string;
|
|
100
|
+
/**
|
|
101
|
+
* 排除后改动行数 — optional caller typed input.
|
|
102
|
+
* Omit or 0 → page retains typed 空缺 for LOC and 耗时/千行.
|
|
103
|
+
*/
|
|
104
|
+
readonly changedLines?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Caller typed issue number — retained on the metrics page for cohort index join.
|
|
107
|
+
* Page addressing remains projectRoot (ADR 0068); issueNumber is not the key.
|
|
108
|
+
* When present, issue mode also maintains the unique issueNumber→projectRoot index row.
|
|
109
|
+
*/
|
|
110
|
+
readonly issueNumber?: number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Sole sweep-mode input contract (#298/#329/#337).
|
|
115
|
+
* Schema is the single definition; TS types are derived (no parallel hand shape).
|
|
116
|
+
* projectRoot = string (not nonempty); changedLines optional finite non-negative;
|
|
117
|
+
* 0 remains typed 空缺; no extra keys.
|
|
118
|
+
*/
|
|
119
|
+
export const taishiSweepModeInputSchema = Type.Object(
|
|
120
|
+
{
|
|
121
|
+
mode: Type.Literal("sweep"),
|
|
122
|
+
mergedPullRequests: Type.Array(
|
|
123
|
+
Type.Object(
|
|
124
|
+
{
|
|
125
|
+
projectRoot: Type.String(),
|
|
126
|
+
/** 排除后改动行数 — omit or 0 → typed 空缺; finite ≥ 0 only. */
|
|
127
|
+
changedLines: Type.Optional(
|
|
128
|
+
Type.Number({ minimum: 0, maximum: Number.MAX_VALUE }),
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
{ additionalProperties: false },
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
{ additionalProperties: false },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/** Sweep-mode typed input — 已并 PR 清单 + LOC → 补算缺页 + 维护全库索引. */
|
|
139
|
+
export type TaishiSweepModeInput = Static<typeof taishiSweepModeInputSchema>;
|
|
140
|
+
|
|
141
|
+
/** One merged-PR / issue entry for sweep-mode typed input. */
|
|
142
|
+
export type TaishiMergedPullRequest =
|
|
143
|
+
TaishiSweepModeInput["mergedPullRequests"][number];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Model-groups mode typed input — caller-supplied issue set (+ optional alias map).
|
|
147
|
+
* Scope is never guessed; empty projectRoots → empty groups.
|
|
148
|
+
*/
|
|
149
|
+
export type TaishiModelGroupsModeInput = {
|
|
150
|
+
readonly mode: "model-groups";
|
|
151
|
+
/** Issue set (projectRoot mechanical keys) defining the stats scope. */
|
|
152
|
+
readonly projectRoots: readonly string[];
|
|
153
|
+
/**
|
|
154
|
+
* Optional combination mapping: raw group key → display alias only.
|
|
155
|
+
* Must not merge groups or change denominators; unmapped keys keep raw name.
|
|
156
|
+
*/
|
|
157
|
+
readonly combinationMapping?: Readonly<Record<string, string>>;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type TaishiInput =
|
|
161
|
+
| TaishiIssueModeInput
|
|
162
|
+
| TaishiSweepModeInput
|
|
163
|
+
| TaishiCohortModeInput
|
|
164
|
+
| TaishiModelGroupsModeInput;
|
|
165
|
+
|
|
166
|
+
export type TaishiIssueModeResult = {
|
|
167
|
+
readonly mode: "issue";
|
|
168
|
+
readonly page: TaishiIssueMetricsPage;
|
|
169
|
+
readonly pagePath: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type TaishiSweepModeResult = {
|
|
173
|
+
readonly mode: "sweep";
|
|
174
|
+
/** Per-issue page results in input order (duplicates collapse on disk by key). */
|
|
175
|
+
readonly issuePages: readonly TaishiIssueModeResult[];
|
|
176
|
+
readonly index: TaishiLibraryIndexPage;
|
|
177
|
+
readonly indexPath: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export type TaishiModelGroupsModeResult = {
|
|
181
|
+
readonly mode: "model-groups";
|
|
182
|
+
/** Query output — not persisted (PRD ④ is on-demand typed output). */
|
|
183
|
+
readonly page: TaishiModelGroupsPage;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export type TaishiResult =
|
|
187
|
+
| TaishiIssueModeResult
|
|
188
|
+
| TaishiSweepModeResult
|
|
189
|
+
| TaishiCohortModeResult
|
|
190
|
+
| TaishiModelGroupsModeResult;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* #338 retrieval primitive (sync): use persisted page when present; otherwise
|
|
194
|
+
* await the sole issue compute kernel (runTaishiIssueMode) which writes via the
|
|
195
|
+
* existing page entry, then return the full result. No pending/async envelope.
|
|
196
|
+
* Compute failures throw TaishiIssueComputeError (issue identity + real cause)
|
|
197
|
+
* and terminate this pull — never washed to absent/partial success.
|
|
198
|
+
* Single-run unreadable/damaged stays page-local exclusion (PRD #298), not a
|
|
199
|
+
* whole-compute failure. Sweep / explicit recompute still use runTaishiIssueMode.
|
|
200
|
+
*/
|
|
201
|
+
/**
|
|
202
|
+
* Cached page may be reused only under bidirectional ticket-scope equality.
|
|
203
|
+
* - requested ticket present: page.issueNumber must equal it
|
|
204
|
+
* - requested ticket absent: only reuse a page that also lacks issueNumber
|
|
205
|
+
* (a narrower ticket page must not stand in for the full root page)
|
|
206
|
+
* projectRoot path alone is not scope identity either direction.
|
|
207
|
+
*/
|
|
208
|
+
function cachedPageMatchesRequestedScope(
|
|
209
|
+
page: TaishiIssueMetricsPage,
|
|
210
|
+
input: TaishiIssueModeInput,
|
|
211
|
+
): boolean {
|
|
212
|
+
const requestedTicket = input.ticketNumber ?? input.issueNumber;
|
|
213
|
+
if (requestedTicket === undefined) {
|
|
214
|
+
return page.issueNumber === undefined;
|
|
215
|
+
}
|
|
216
|
+
return page.issueNumber === requestedTicket;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function readOrComputeTaishiIssuePage(
|
|
220
|
+
input: TaishiIssueModeInput,
|
|
221
|
+
): Promise<TaishiIssueModeResult> {
|
|
222
|
+
const ledgerHome = resolveActivationLedgerHome();
|
|
223
|
+
const projectRoot = physicalPathIdentity(input.projectRoot);
|
|
224
|
+
const pagePath = taishiIssuePagePath(ledgerHome, projectRoot);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const raw = await readFile(pagePath, "utf8");
|
|
228
|
+
const page = JSON.parse(raw) as TaishiIssueMetricsPage;
|
|
229
|
+
if (cachedPageMatchesRequestedScope(page, input)) {
|
|
230
|
+
return { mode: "issue", page, pagePath };
|
|
231
|
+
}
|
|
232
|
+
// Existing page is for a different / absent ticket scope — same kernel recompute.
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (!isMissingPathError(error)) {
|
|
235
|
+
// Corrupt / blocked page path — loud with issue identity, not absent.
|
|
236
|
+
throw new TaishiIssueComputeError({
|
|
237
|
+
projectRoot,
|
|
238
|
+
...(input.issueNumber === undefined ? {} : { issueNumber: input.issueNumber }),
|
|
239
|
+
cause: error,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
return await runTaishiIssueMode(input);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof TaishiIssueComputeError) throw error;
|
|
248
|
+
throw new TaishiIssueComputeError({
|
|
249
|
+
projectRoot,
|
|
250
|
+
...(input.issueNumber === undefined ? {} : { issueNumber: input.issueNumber }),
|
|
251
|
+
cause: error,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runTaishiIssueMode(
|
|
257
|
+
input: TaishiIssueModeInput | TaishiMergedPullRequest,
|
|
258
|
+
/** Caller-supplied scan facts — skip a second ledger walk when already scanned. */
|
|
259
|
+
precomputedScan?: TaishiScopedRunScan,
|
|
260
|
+
): Promise<TaishiIssueModeResult> {
|
|
261
|
+
// Programmatic issue/sweep entry boundary — same finite non-negative rule as attach schema.
|
|
262
|
+
assertTaishiChangedLinesInput(input.changedLines);
|
|
263
|
+
const ledgerHome = resolveActivationLedgerHome();
|
|
264
|
+
const projectRoot = input.projectRoot;
|
|
265
|
+
// Sweep entries carry projectRoot only; issue mode may add ticketNumber (C4).
|
|
266
|
+
const ticketNumber =
|
|
267
|
+
"ticketNumber" in input ? input.ticketNumber : undefined;
|
|
268
|
+
|
|
269
|
+
const scan = precomputedScan ??
|
|
270
|
+
(ticketNumber === undefined
|
|
271
|
+
? await scanTaishiIssueRuns({ projectRoot })
|
|
272
|
+
: await scanTaishiIssueRuns({ projectRoot, ticketNumber }));
|
|
273
|
+
|
|
274
|
+
// exactOptionalPropertyTypes: only pass optional faces when caller supplied them.
|
|
275
|
+
const issueNumber =
|
|
276
|
+
"issueNumber" in input ? input.issueNumber : undefined;
|
|
277
|
+
const conflictingProjectRoot =
|
|
278
|
+
"conflictingProjectRoot" in input ? input.conflictingProjectRoot : undefined;
|
|
279
|
+
|
|
280
|
+
// Caller dual-param conflict (ticket/index root already won): record C4 fact
|
|
281
|
+
// from the call faces themselves — independent of ledger alien runs.
|
|
282
|
+
const scopeConflicts = [...scan.scopeConflicts];
|
|
283
|
+
if (conflictingProjectRoot !== undefined && ticketNumber !== undefined) {
|
|
284
|
+
const losingRoot = physicalPathIdentity(conflictingProjectRoot);
|
|
285
|
+
const winningRoot = physicalPathIdentity(projectRoot);
|
|
286
|
+
if (losingRoot !== winningRoot) {
|
|
287
|
+
scopeConflicts.push({
|
|
288
|
+
ticketNumber,
|
|
289
|
+
projectRoot: losingRoot,
|
|
290
|
+
fact: "typed-ticketNumber-over-projectRoot",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Page build discovers metric families first — missing tree fails before write.
|
|
296
|
+
const page = await buildTaishiIssueMetricsPage({
|
|
297
|
+
projectRoot,
|
|
298
|
+
runs: scan.runs,
|
|
299
|
+
unreadable: scan.unreadable,
|
|
300
|
+
scopeConflicts,
|
|
301
|
+
...(input.changedLines === undefined ? {} : { changedLines: input.changedLines }),
|
|
302
|
+
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const pagePath = await writeTaishiIssueMetricsPage(ledgerHome, page);
|
|
306
|
+
|
|
307
|
+
// Issue number present → maintain the unique issueNumber→projectRoot index row
|
|
308
|
+
// so cohort can join without a second addressing kernel (ADR 0068 page key unchanged).
|
|
309
|
+
// Row carries C1 efficiency columns from the page (single index shape, no second kernel).
|
|
310
|
+
// Locked read→upsert→write so concurrent issue/sweep CLI writers do not drop rows.
|
|
311
|
+
if (issueNumber !== undefined) {
|
|
312
|
+
await mergeTaishiLibraryIndexRows(ledgerHome, [
|
|
313
|
+
rowFromIssueMetricsPage(page),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { mode: "issue", page, pagePath };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function runTaishiSweepMode(
|
|
321
|
+
input: TaishiSweepModeInput,
|
|
322
|
+
): Promise<TaishiSweepModeResult> {
|
|
323
|
+
const ledgerHome = resolveActivationLedgerHome();
|
|
324
|
+
|
|
325
|
+
const issuePages: TaishiIssueModeResult[] = [];
|
|
326
|
+
for (const entry of input.mergedPullRequests) {
|
|
327
|
+
issuePages.push(await runTaishiIssueMode(entry));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const upserts = issuePages.map((result) => rowFromIssueMetricsPage(result.page));
|
|
331
|
+
const { index, indexPath } = await mergeTaishiLibraryIndexRows(ledgerHome, upserts);
|
|
332
|
+
|
|
333
|
+
return { mode: "sweep", issuePages, index, indexPath };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function runTaishiModelGroupsMode(
|
|
337
|
+
input: TaishiModelGroupsModeInput,
|
|
338
|
+
): Promise<TaishiModelGroupsModeResult> {
|
|
339
|
+
const ledgerHome = resolveActivationLedgerHome();
|
|
340
|
+
|
|
341
|
+
const runs: TaishiReadableRunFacts[] = [];
|
|
342
|
+
const unreadable: TaishiUnreadableRun[] = [];
|
|
343
|
+
// Dedupe scope roots by physical identity while preserving caller order for scan.
|
|
344
|
+
const seen = new Set<string>();
|
|
345
|
+
const projectRoots: string[] = [];
|
|
346
|
+
for (const root of input.projectRoots) {
|
|
347
|
+
const identity = physicalPathIdentity(root);
|
|
348
|
+
if (seen.has(identity)) continue;
|
|
349
|
+
seen.add(identity);
|
|
350
|
+
projectRoots.push(identity);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// One ledger scan per root — shared by #338 ensure-page and model-group aggregate.
|
|
354
|
+
// No second scan pass; sole issue kernel + existing writer when page is missing.
|
|
355
|
+
for (const projectRoot of projectRoots) {
|
|
356
|
+
const scan = await scanTaishiIssueRuns({ projectRoot });
|
|
357
|
+
runs.push(...scan.runs);
|
|
358
|
+
unreadable.push(...scan.unreadable);
|
|
359
|
+
|
|
360
|
+
const pagePath = taishiIssuePagePath(ledgerHome, projectRoot);
|
|
361
|
+
try {
|
|
362
|
+
const raw = await readFile(pagePath, "utf8");
|
|
363
|
+
JSON.parse(raw); // present page must parse (same loud face as readOrCompute)
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!isMissingPathError(error)) {
|
|
366
|
+
throw new TaishiIssueComputeError({ projectRoot, cause: error });
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
// Reuse this root's scan facts — no second ledger walk on compute-if-missing.
|
|
370
|
+
await runTaishiIssueMode({ mode: "issue", projectRoot }, scan);
|
|
371
|
+
} catch (computeError) {
|
|
372
|
+
if (computeError instanceof TaishiIssueComputeError) throw computeError;
|
|
373
|
+
throw new TaishiIssueComputeError({ projectRoot, cause: computeError });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// exactOptionalPropertyTypes: only pass mapping when caller supplied it.
|
|
379
|
+
const page = input.combinationMapping === undefined
|
|
380
|
+
? buildTaishiModelGroupsPage({ projectRoots, runs, unreadable })
|
|
381
|
+
: buildTaishiModelGroupsPage({
|
|
382
|
+
projectRoots,
|
|
383
|
+
runs,
|
|
384
|
+
unreadable,
|
|
385
|
+
combinationMapping: input.combinationMapping,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return { mode: "model-groups", page };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Sole taishi entry.
|
|
393
|
+
* - Issue mode: scope → scan (typed facts) → family compose → atomic replace.
|
|
394
|
+
* When issueNumber present, also upsert library index (C2 cohort join face).
|
|
395
|
+
* - Sweep mode: for each merged PR entry run issue kernel, upsert library index.
|
|
396
|
+
* - Cohort mode: join library index by issueNumber → ensure pages (#338) → contrast.
|
|
397
|
+
* - Model-groups mode: issue-set scope → one scan/root (ensure page #338 + aggregate).
|
|
398
|
+
* Metric-family kernels (B/C waves) drop a module under taishi-metric-families/
|
|
399
|
+
* and consume scan facts without opening a second entry or second parse kernel.
|
|
400
|
+
* Machine home is package-owned (ADR 0048) — never an invocation field.
|
|
401
|
+
* Retrieval compute-if-missing is readOrComputeTaishiIssuePage (not a second kernel).
|
|
402
|
+
*/
|
|
403
|
+
export async function runTaishi(input: TaishiIssueModeInput): Promise<TaishiIssueModeResult>;
|
|
404
|
+
export async function runTaishi(input: TaishiSweepModeInput): Promise<TaishiSweepModeResult>;
|
|
405
|
+
export async function runTaishi(input: TaishiCohortModeInput): Promise<TaishiCohortModeResult>;
|
|
406
|
+
export async function runTaishi(
|
|
407
|
+
input: TaishiModelGroupsModeInput,
|
|
408
|
+
): Promise<TaishiModelGroupsModeResult>;
|
|
409
|
+
export async function runTaishi(input: TaishiInput): Promise<TaishiResult>;
|
|
410
|
+
export async function runTaishi(input: TaishiInput): Promise<TaishiResult> {
|
|
411
|
+
if (input.mode === "sweep") {
|
|
412
|
+
return runTaishiSweepMode(input);
|
|
413
|
+
}
|
|
414
|
+
if (input.mode === "cohort") {
|
|
415
|
+
const ledgerHome = resolveActivationLedgerHome();
|
|
416
|
+
return runTaishiCohortMode(ledgerHome, input, async ({ projectRoot, issueNumber }) => {
|
|
417
|
+
const ensured = await readOrComputeTaishiIssuePage({
|
|
418
|
+
mode: "issue",
|
|
419
|
+
projectRoot,
|
|
420
|
+
issueNumber,
|
|
421
|
+
});
|
|
422
|
+
return ensured.page;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (input.mode === "model-groups") {
|
|
426
|
+
return runTaishiModelGroupsMode(input);
|
|
427
|
+
}
|
|
428
|
+
return runTaishiIssueMode(input);
|
|
429
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taishi library index page (ADR 0068 / PRD #298 output ②).
|
|
3
|
+
*
|
|
4
|
+
* Sweep mode maintains one self-sufficient row per issue:
|
|
5
|
+
* 完全耗时 / 排除后改动行数 / 耗时每千行 / 末次活动时间戳.
|
|
6
|
+
* Rows carry their own sort keys — readers choose order (Story 6/8).
|
|
7
|
+
*
|
|
8
|
+
* Cross-book one row per issue. C2 joins cohort groups by issueNumber →
|
|
9
|
+
* projectRoot page reference (ADR 0068 mechanical key). Missing row =
|
|
10
|
+
* typed vacancy entry — never silent skip, never live recompute.
|
|
11
|
+
*
|
|
12
|
+
* Multi-process issue/sweep writers coordinate the whole read→upsert→write
|
|
13
|
+
* on one exclusive lock next to the index (atomic rename still prevents torn
|
|
14
|
+
* JSON; the lock prevents lost-update across concurrent CLI processes).
|
|
15
|
+
*/
|
|
16
|
+
import { open, readFile, unlink } from "node:fs/promises";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { writeFileAtomically } from "./atomic-write.ts";
|
|
20
|
+
import {
|
|
21
|
+
assertLedgerFileInsideHome,
|
|
22
|
+
ensureRealDirectoryTree,
|
|
23
|
+
} from "./activation-ledger-topology.ts";
|
|
24
|
+
import type {
|
|
25
|
+
TaishiIssueMetricsPage,
|
|
26
|
+
TaishiOptionalMetricNumber,
|
|
27
|
+
TaishiOptionalTimestamp,
|
|
28
|
+
} from "./taishi-page.ts";
|
|
29
|
+
|
|
30
|
+
const LIBRARY_INDEX_LOCK_NAME = ".library-index.lock";
|
|
31
|
+
const LIBRARY_INDEX_LOCK_TIMEOUT_MS = 30_000;
|
|
32
|
+
const LIBRARY_INDEX_LOCK_RETRY_MS = 15;
|
|
33
|
+
|
|
34
|
+
function sleep(ms: number): Promise<void> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
setTimeout(resolve, ms);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Exclusive create lock for the library-index read→upsert→write critical section.
|
|
42
|
+
* Same-directory sibling of the index file; not a second index and not a daemon.
|
|
43
|
+
*/
|
|
44
|
+
async function withTaishiLibraryIndexLock<T>(
|
|
45
|
+
ledgerHome: string,
|
|
46
|
+
fn: () => Promise<T>,
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
const indexPath = taishiLibraryIndexPath(ledgerHome);
|
|
49
|
+
ensureRealDirectoryTree(ledgerHome, dirname(indexPath));
|
|
50
|
+
const lockPath = join(dirname(indexPath), LIBRARY_INDEX_LOCK_NAME);
|
|
51
|
+
assertLedgerFileInsideHome(lockPath, ledgerHome);
|
|
52
|
+
const startedAt = Date.now();
|
|
53
|
+
while (true) {
|
|
54
|
+
try {
|
|
55
|
+
const handle = await open(lockPath, "wx");
|
|
56
|
+
try {
|
|
57
|
+
await handle.writeFile(`${process.pid}\n`, "utf8");
|
|
58
|
+
return await fn();
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close().catch(() => undefined);
|
|
61
|
+
await unlink(lockPath).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const code =
|
|
65
|
+
error instanceof Error && "code" in error
|
|
66
|
+
? (error as NodeJS.ErrnoException).code
|
|
67
|
+
: undefined;
|
|
68
|
+
if (code !== "EEXIST") throw error;
|
|
69
|
+
if (Date.now() - startedAt > LIBRARY_INDEX_LOCK_TIMEOUT_MS) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`taishi library-index lock timeout after ${LIBRARY_INDEX_LOCK_TIMEOUT_MS}ms: ${lockPath}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
await sleep(LIBRARY_INDEX_LOCK_RETRY_MS);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One issue row on the cross-book library index.
|
|
81
|
+
* projectRoot = page addressing key (ADR 0068).
|
|
82
|
+
* issueNumber = optional caller typed field retained for cohort join.
|
|
83
|
+
* C1 four columns make the row self-sufficient for cross-issue listing.
|
|
84
|
+
*/
|
|
85
|
+
export type TaishiLibraryIndexRow = {
|
|
86
|
+
readonly projectRoot: string;
|
|
87
|
+
/** Caller typed issue number — present when issue-mode supplied it for cohort join. */
|
|
88
|
+
readonly issueNumber?: number;
|
|
89
|
+
/** 完全耗时 — Σ readable leg wall clocks. */
|
|
90
|
+
readonly totalElapsedMs: number;
|
|
91
|
+
/** 排除后改动行数 — caller typed input; may be typed 空缺. */
|
|
92
|
+
readonly changedLines: TaishiOptionalMetricNumber;
|
|
93
|
+
/** 耗时/千行 — absent when LOC absent/0 (never 0 or ∞ stand-in). */
|
|
94
|
+
readonly msPerKLines: TaishiOptionalMetricNumber;
|
|
95
|
+
/** 末次活动时间戳 — max end-frame across ALL runs (incl. unreadable available). */
|
|
96
|
+
readonly lastActivityAt: TaishiOptionalTimestamp;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Library index page — unique true source for cross-issue listing / cohort join.
|
|
101
|
+
* No md second projection; render on demand.
|
|
102
|
+
*/
|
|
103
|
+
export type TaishiLibraryIndexPage = {
|
|
104
|
+
readonly kind: "taishi-library-index";
|
|
105
|
+
readonly rows: readonly TaishiLibraryIndexRow[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function taishiLibraryIndexPath(ledgerHome: string): string {
|
|
109
|
+
return join(ledgerHome, "taishi", "library-index.json");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function rowFromIssueMetricsPage(
|
|
113
|
+
page: TaishiIssueMetricsPage,
|
|
114
|
+
): TaishiLibraryIndexRow {
|
|
115
|
+
return {
|
|
116
|
+
projectRoot: page.projectRoot,
|
|
117
|
+
// exactOptionalPropertyTypes: only materialize when page carries it.
|
|
118
|
+
...(page.issueNumber === undefined ? {} : { issueNumber: page.issueNumber }),
|
|
119
|
+
totalElapsedMs: page.totalElapsedMs,
|
|
120
|
+
changedLines: page.changedLines,
|
|
121
|
+
msPerKLines: page.msPerKLines,
|
|
122
|
+
lastActivityAt: page.lastActivityAt,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sortRows(
|
|
127
|
+
rows: readonly TaishiLibraryIndexRow[],
|
|
128
|
+
): TaishiLibraryIndexRow[] {
|
|
129
|
+
// Stable projectRoot sort (C1 listing). Cohort join is by issueNumber find,
|
|
130
|
+
// not row order — issueNumber secondary keeps C2 rows deterministic too.
|
|
131
|
+
return [...rows].sort((a, b) => {
|
|
132
|
+
const byRoot = a.projectRoot.localeCompare(b.projectRoot);
|
|
133
|
+
if (byRoot !== 0) return byRoot;
|
|
134
|
+
const aNum = a.issueNumber;
|
|
135
|
+
const bNum = b.issueNumber;
|
|
136
|
+
if (aNum === undefined && bNum === undefined) return 0;
|
|
137
|
+
if (aNum === undefined) return 1;
|
|
138
|
+
if (bNum === undefined) return -1;
|
|
139
|
+
return aNum - bNum;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build a fresh index from the given rows (stable projectRoot, then issueNumber). */
|
|
144
|
+
export function buildTaishiLibraryIndexPage(
|
|
145
|
+
rows: readonly TaishiLibraryIndexRow[],
|
|
146
|
+
): TaishiLibraryIndexPage {
|
|
147
|
+
return {
|
|
148
|
+
kind: "taishi-library-index",
|
|
149
|
+
rows: sortRows(rows),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Look up the first index row for an issue number.
|
|
155
|
+
* Absence is a lawful cohort vacancy signal — not an error.
|
|
156
|
+
*/
|
|
157
|
+
export function findTaishiLibraryIndexRow(
|
|
158
|
+
index: TaishiLibraryIndexPage | undefined,
|
|
159
|
+
issueNumber: number,
|
|
160
|
+
): TaishiLibraryIndexRow | undefined {
|
|
161
|
+
if (index === undefined) return undefined;
|
|
162
|
+
return index.rows.find((row) => row.issueNumber === issueNumber);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Upsert issue rows into an existing index (or empty).
|
|
167
|
+
* - One row per projectRoot — re-sweep overwrites that issue's row only (C1).
|
|
168
|
+
* - When issueNumber is present, also unique per issueNumber — re-issue
|
|
169
|
+
* overwrites that number's row only (C2 issueNumber→projectRoot join).
|
|
170
|
+
*/
|
|
171
|
+
export function upsertTaishiLibraryIndexRows(
|
|
172
|
+
existing: TaishiLibraryIndexPage | undefined,
|
|
173
|
+
upserts: readonly TaishiLibraryIndexRow[],
|
|
174
|
+
): TaishiLibraryIndexPage {
|
|
175
|
+
const byRoot = new Map<string, TaishiLibraryIndexRow>();
|
|
176
|
+
const rootByIssue = new Map<number, string>();
|
|
177
|
+
|
|
178
|
+
const ingest = (row: TaishiLibraryIndexRow): void => {
|
|
179
|
+
// C2 uniqueness: one row per issueNumber — drop prior root if number moved.
|
|
180
|
+
if (row.issueNumber !== undefined) {
|
|
181
|
+
const priorRoot = rootByIssue.get(row.issueNumber);
|
|
182
|
+
if (priorRoot !== undefined && priorRoot !== row.projectRoot) {
|
|
183
|
+
byRoot.delete(priorRoot);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// C1 uniqueness: one row per projectRoot — drop prior issue map if root reused.
|
|
187
|
+
const prior = byRoot.get(row.projectRoot);
|
|
188
|
+
if (
|
|
189
|
+
prior !== undefined
|
|
190
|
+
&& prior.issueNumber !== undefined
|
|
191
|
+
&& prior.issueNumber !== row.issueNumber
|
|
192
|
+
) {
|
|
193
|
+
rootByIssue.delete(prior.issueNumber);
|
|
194
|
+
}
|
|
195
|
+
byRoot.set(row.projectRoot, row);
|
|
196
|
+
if (row.issueNumber !== undefined) {
|
|
197
|
+
rootByIssue.set(row.issueNumber, row.projectRoot);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (existing !== undefined) {
|
|
202
|
+
for (const row of existing.rows) {
|
|
203
|
+
ingest(row);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const row of upserts) {
|
|
207
|
+
ingest(row);
|
|
208
|
+
}
|
|
209
|
+
return buildTaishiLibraryIndexPage([...byRoot.values()]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read existing library index, or undefined when absent.
|
|
214
|
+
* Single typed producer writes this file — JSON.parse failure is loud;
|
|
215
|
+
* no bespoke shape validator on the self-read path.
|
|
216
|
+
*/
|
|
217
|
+
export async function readTaishiLibraryIndexPage(
|
|
218
|
+
ledgerHome: string,
|
|
219
|
+
): Promise<TaishiLibraryIndexPage | undefined> {
|
|
220
|
+
const path = taishiLibraryIndexPath(ledgerHome);
|
|
221
|
+
let raw: string;
|
|
222
|
+
try {
|
|
223
|
+
raw = await readFile(path, "utf8");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (
|
|
226
|
+
error instanceof Error
|
|
227
|
+
&& "code" in error
|
|
228
|
+
&& (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
229
|
+
) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
return JSON.parse(raw) as TaishiLibraryIndexPage;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Atomically replace the library index page.
|
|
239
|
+
* Directory creation goes through ledger home physical containment.
|
|
240
|
+
* Prefer {@link mergeTaishiLibraryIndexRows} for multi-writer updates — bare
|
|
241
|
+
* write has no read→merge coordination.
|
|
242
|
+
*/
|
|
243
|
+
export async function writeTaishiLibraryIndexPage(
|
|
244
|
+
ledgerHome: string,
|
|
245
|
+
page: TaishiLibraryIndexPage,
|
|
246
|
+
): Promise<string> {
|
|
247
|
+
const path = taishiLibraryIndexPath(ledgerHome);
|
|
248
|
+
ensureRealDirectoryTree(ledgerHome, dirname(path));
|
|
249
|
+
assertLedgerFileInsideHome(path, ledgerHome);
|
|
250
|
+
await writeFileAtomically(path, `${JSON.stringify(page, null, 2)}\n`);
|
|
251
|
+
return path;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Sole multi-writer coordination seam for library-index updates.
|
|
256
|
+
* Holds one exclusive lock across read → upsert → atomic write so concurrent
|
|
257
|
+
* issue/sweep CLI processes cannot drop each other's new rows.
|
|
258
|
+
*/
|
|
259
|
+
export async function mergeTaishiLibraryIndexRows(
|
|
260
|
+
ledgerHome: string,
|
|
261
|
+
upserts: readonly TaishiLibraryIndexRow[],
|
|
262
|
+
): Promise<{ readonly index: TaishiLibraryIndexPage; readonly indexPath: string }> {
|
|
263
|
+
return withTaishiLibraryIndexLock(ledgerHome, async () => {
|
|
264
|
+
const existing = await readTaishiLibraryIndexPage(ledgerHome);
|
|
265
|
+
const index = upsertTaishiLibraryIndexRows(existing, upserts);
|
|
266
|
+
const indexPath = await writeTaishiLibraryIndexPage(ledgerHome, index);
|
|
267
|
+
return { index, indexPath };
|
|
268
|
+
});
|
|
269
|
+
}
|