@akagilnc/pi-workflow-roles 0.1.2120 → 0.1.2124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -4
- package/README.zh-CN.md +2 -4
- package/dist/public-cli/main.js +214 -165
- package/package.json +1 -1
- package/src/public-cli/invocation.ts +18 -28
- package/src/public-cli/option-definitions.ts +35 -62
- package/src/public-cli/taishi-run.ts +41 -52
- package/src/taishi-cohort.ts +4 -0
- package/src/taishi-entry.ts +111 -49
- package/src/taishi-index.ts +39 -20
- package/src/taishi-ledger.ts +90 -45
- package/src/taishi-page.ts +64 -8
package/src/taishi-entry.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
buildTaishiModelGroupsPage,
|
|
34
34
|
type TaishiModelGroupsPage,
|
|
35
35
|
} from "./taishi-model-groups.ts";
|
|
36
|
+
import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
|
|
36
37
|
import {
|
|
37
38
|
assertTaishiChangedLinesInput,
|
|
38
39
|
buildTaishiIssueMetricsPage,
|
|
@@ -45,10 +46,12 @@ import {
|
|
|
45
46
|
/** #338 compute-if-missing failure — issue identity + real cause (CLI → ControlledFailure). */
|
|
46
47
|
export class TaishiIssueComputeError extends Error {
|
|
47
48
|
readonly code = "taishi-issue-compute-failed" as const;
|
|
49
|
+
readonly bookKey: string;
|
|
48
50
|
readonly projectRoot: string;
|
|
49
51
|
readonly issueNumber?: number;
|
|
50
52
|
|
|
51
53
|
constructor(input: {
|
|
54
|
+
readonly bookKey: string;
|
|
52
55
|
readonly projectRoot: string;
|
|
53
56
|
readonly issueNumber?: number;
|
|
54
57
|
readonly cause: unknown;
|
|
@@ -60,12 +63,13 @@ export class TaishiIssueComputeError extends Error {
|
|
|
60
63
|
: String(input.cause);
|
|
61
64
|
const issueFace =
|
|
62
65
|
input.issueNumber === undefined
|
|
63
|
-
? `projectRoot ${root}`
|
|
64
|
-
: `issue ${input.issueNumber} (projectRoot ${root})`;
|
|
66
|
+
? `book ${input.bookKey} (projectRoot ${root})`
|
|
67
|
+
: `issue ${input.issueNumber} book ${input.bookKey} (projectRoot ${root})`;
|
|
65
68
|
super(`taishi compute failed for ${issueFace}: ${causeText}`, {
|
|
66
69
|
cause: input.cause,
|
|
67
70
|
});
|
|
68
71
|
this.name = "TaishiIssueComputeError";
|
|
72
|
+
this.bookKey = input.bookKey;
|
|
69
73
|
this.projectRoot = root;
|
|
70
74
|
if (input.issueNumber !== undefined) {
|
|
71
75
|
this.issueNumber = input.issueNumber;
|
|
@@ -81,22 +85,24 @@ function isMissingPathError(error: unknown): boolean {
|
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
/** Issue-mode typed input —
|
|
88
|
+
/** Issue-mode typed input — book × ticket scope (#399). */
|
|
85
89
|
export type TaishiIssueModeInput = {
|
|
86
90
|
readonly mode: "issue";
|
|
87
|
-
readonly projectRoot: string;
|
|
88
91
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
92
|
+
* Ledger book identity (git common-dir key). Required for CLI issue query.
|
|
93
|
+
* When omitted, sweep/legacy may supply projectRoot alone (path-narrow / git resolve).
|
|
91
94
|
*/
|
|
92
|
-
readonly
|
|
95
|
+
readonly bookKey?: string;
|
|
93
96
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
97
|
+
* Recording/display face and sweep/legacy path-narrow pointer.
|
|
98
|
+
* Not the CLI issue-query mechanical key after #399 (ADR 0068 revised).
|
|
98
99
|
*/
|
|
99
|
-
readonly
|
|
100
|
+
readonly projectRoot: string;
|
|
101
|
+
/**
|
|
102
|
+
* C4/#399: caller typed ticket face (#176). When set, issue 圈定 admits only
|
|
103
|
+
* matching invocation.ticketNumber — no silent projectRoot fallback.
|
|
104
|
+
*/
|
|
105
|
+
readonly ticketNumber?: number;
|
|
100
106
|
/**
|
|
101
107
|
* 排除后改动行数 — optional caller typed input.
|
|
102
108
|
* Omit or 0 → page retains typed 空缺 for LOC and 耗时/千行.
|
|
@@ -104,8 +110,8 @@ export type TaishiIssueModeInput = {
|
|
|
104
110
|
readonly changedLines?: number;
|
|
105
111
|
/**
|
|
106
112
|
* Caller typed issue number — retained on the metrics page for cohort index join.
|
|
107
|
-
* Page
|
|
108
|
-
* When present, issue mode also maintains the
|
|
113
|
+
* Page address = book + ticket when present (#399); not a global bare number key.
|
|
114
|
+
* When present, issue mode also maintains the library-index row (cohort consumer).
|
|
109
115
|
*/
|
|
110
116
|
readonly issueNumber?: number;
|
|
111
117
|
};
|
|
@@ -199,16 +205,16 @@ export type TaishiResult =
|
|
|
199
205
|
* whole-compute failure. Sweep / explicit recompute still use runTaishiIssueMode.
|
|
200
206
|
*/
|
|
201
207
|
/**
|
|
202
|
-
* Cached page may be reused only under bidirectional
|
|
203
|
-
* - requested ticket present: page.issueNumber must equal it
|
|
208
|
+
* Cached page may be reused only under bidirectional book×ticket scope equality.
|
|
209
|
+
* - requested ticket present: page.issueNumber must equal it and bookKey matches
|
|
204
210
|
* - requested ticket absent: only reuse a page that also lacks issueNumber
|
|
205
|
-
* (a narrower ticket page must not stand in for the full
|
|
206
|
-
* projectRoot path alone is not scope identity either direction.
|
|
211
|
+
* (a narrower ticket page must not stand in for the full book page)
|
|
207
212
|
*/
|
|
208
213
|
function cachedPageMatchesRequestedScope(
|
|
209
214
|
page: TaishiIssueMetricsPage,
|
|
210
|
-
input:
|
|
215
|
+
input: { readonly bookKey: string; readonly issueNumber?: number; readonly ticketNumber?: number },
|
|
211
216
|
): boolean {
|
|
217
|
+
if (page.bookKey !== input.bookKey) return false;
|
|
212
218
|
const requestedTicket = input.ticketNumber ?? input.issueNumber;
|
|
213
219
|
if (requestedTicket === undefined) {
|
|
214
220
|
return page.issueNumber === undefined;
|
|
@@ -216,17 +222,52 @@ function cachedPageMatchesRequestedScope(
|
|
|
216
222
|
return page.issueNumber === requestedTicket;
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
function tryResolveBookKey(projectRoot: string): string | undefined {
|
|
226
|
+
try {
|
|
227
|
+
return resolveBookKeyFromGit(projectRoot);
|
|
228
|
+
} catch {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve page/scan book identity for issue mode (#399).
|
|
235
|
+
* CLI supplies bookKey from cwd git common-dir.
|
|
236
|
+
* Sweep/legacy without bookKey: git common-dir when possible; else stable synthetic
|
|
237
|
+
* `root:<projectRoot identity>` so read/write page paths agree without a prior scan.
|
|
238
|
+
*/
|
|
239
|
+
function resolveIssueBookKey(input: {
|
|
240
|
+
readonly bookKey?: string;
|
|
241
|
+
readonly projectRoot: string;
|
|
242
|
+
}): string {
|
|
243
|
+
if (input.bookKey !== undefined && input.bookKey.trim() !== "") {
|
|
244
|
+
return input.bookKey;
|
|
245
|
+
}
|
|
246
|
+
const fromGit = tryResolveBookKey(input.projectRoot);
|
|
247
|
+
if (fromGit !== undefined) return fromGit;
|
|
248
|
+
return `root:${physicalPathIdentity(input.projectRoot)}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
219
251
|
export async function readOrComputeTaishiIssuePage(
|
|
220
252
|
input: TaishiIssueModeInput,
|
|
221
253
|
): Promise<TaishiIssueModeResult> {
|
|
222
254
|
const ledgerHome = resolveActivationLedgerHome();
|
|
223
255
|
const projectRoot = physicalPathIdentity(input.projectRoot);
|
|
224
|
-
const
|
|
256
|
+
const bookKey = resolveIssueBookKey(input);
|
|
257
|
+
const issueNumber = input.ticketNumber ?? input.issueNumber;
|
|
258
|
+
const pagePath = taishiIssuePagePath(ledgerHome, {
|
|
259
|
+
bookKey,
|
|
260
|
+
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
261
|
+
// Sweep/legacy path-narrow pages (no ticket, no explicit CLI book-only scope).
|
|
262
|
+
...(issueNumber === undefined && input.bookKey === undefined
|
|
263
|
+
? { scopeRootIdentity: projectRoot }
|
|
264
|
+
: {}),
|
|
265
|
+
});
|
|
225
266
|
|
|
226
267
|
try {
|
|
227
268
|
const raw = await readFile(pagePath, "utf8");
|
|
228
269
|
const page = JSON.parse(raw) as TaishiIssueMetricsPage;
|
|
229
|
-
if (cachedPageMatchesRequestedScope(page, input)) {
|
|
270
|
+
if (cachedPageMatchesRequestedScope(page, { bookKey, ...input })) {
|
|
230
271
|
return { mode: "issue", page, pagePath };
|
|
231
272
|
}
|
|
232
273
|
// Existing page is for a different / absent ticket scope — same kernel recompute.
|
|
@@ -234,8 +275,9 @@ export async function readOrComputeTaishiIssuePage(
|
|
|
234
275
|
if (!isMissingPathError(error)) {
|
|
235
276
|
// Corrupt / blocked page path — loud with issue identity, not absent.
|
|
236
277
|
throw new TaishiIssueComputeError({
|
|
278
|
+
bookKey,
|
|
237
279
|
projectRoot,
|
|
238
|
-
...(
|
|
280
|
+
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
239
281
|
cause: error,
|
|
240
282
|
});
|
|
241
283
|
}
|
|
@@ -246,8 +288,9 @@ export async function readOrComputeTaishiIssuePage(
|
|
|
246
288
|
} catch (error) {
|
|
247
289
|
if (error instanceof TaishiIssueComputeError) throw error;
|
|
248
290
|
throw new TaishiIssueComputeError({
|
|
291
|
+
bookKey,
|
|
249
292
|
projectRoot,
|
|
250
|
-
...(
|
|
293
|
+
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
251
294
|
cause: error,
|
|
252
295
|
});
|
|
253
296
|
}
|
|
@@ -265,48 +308,54 @@ async function runTaishiIssueMode(
|
|
|
265
308
|
// Sweep entries carry projectRoot only; issue mode may add ticketNumber (C4).
|
|
266
309
|
const ticketNumber =
|
|
267
310
|
"ticketNumber" in input ? input.ticketNumber : undefined;
|
|
311
|
+
const inputBookKey =
|
|
312
|
+
"bookKey" in input && typeof input.bookKey === "string" && input.bookKey.trim() !== ""
|
|
313
|
+
? input.bookKey
|
|
314
|
+
: undefined;
|
|
268
315
|
|
|
269
316
|
const scan = precomputedScan ??
|
|
270
|
-
(
|
|
317
|
+
(inputBookKey !== undefined
|
|
318
|
+
? await scanTaishiIssueRuns({
|
|
319
|
+
bookKey: inputBookKey,
|
|
320
|
+
...(ticketNumber === undefined ? {} : { ticketNumber }),
|
|
321
|
+
})
|
|
322
|
+
: ticketNumber === undefined
|
|
271
323
|
? await scanTaishiIssueRuns({ projectRoot })
|
|
272
324
|
: await scanTaishiIssueRuns({ projectRoot, ticketNumber }));
|
|
273
325
|
|
|
274
326
|
// exactOptionalPropertyTypes: only pass optional faces when caller supplied them.
|
|
275
327
|
const issueNumber =
|
|
276
328
|
"issueNumber" in input ? input.issueNumber : undefined;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
projectRoot: losingRoot,
|
|
290
|
-
fact: "typed-ticketNumber-over-projectRoot",
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
}
|
|
329
|
+
|
|
330
|
+
const bookKey = resolveIssueBookKey({
|
|
331
|
+
...(inputBookKey === undefined ? {} : { bookKey: inputBookKey }),
|
|
332
|
+
projectRoot,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// CLI book/ticket pages: no scopeRootIdentity.
|
|
336
|
+
// Sweep/legacy path-narrow (no explicit bookKey, no ticket): address includes root.
|
|
337
|
+
const scopeRootIdentity =
|
|
338
|
+
inputBookKey === undefined && ticketNumber === undefined && issueNumber === undefined
|
|
339
|
+
? physicalPathIdentity(projectRoot)
|
|
340
|
+
: undefined;
|
|
294
341
|
|
|
295
342
|
// Page build discovers metric families first — missing tree fails before write.
|
|
296
343
|
const page = await buildTaishiIssueMetricsPage({
|
|
344
|
+
bookKey,
|
|
297
345
|
projectRoot,
|
|
298
346
|
runs: scan.runs,
|
|
299
347
|
unreadable: scan.unreadable,
|
|
300
|
-
scopeConflicts,
|
|
348
|
+
scopeConflicts: scan.scopeConflicts,
|
|
301
349
|
...(input.changedLines === undefined ? {} : { changedLines: input.changedLines }),
|
|
302
350
|
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
351
|
+
...(scopeRootIdentity === undefined ? {} : { scopeRootIdentity }),
|
|
303
352
|
});
|
|
304
353
|
|
|
305
354
|
const pagePath = await writeTaishiIssueMetricsPage(ledgerHome, page);
|
|
306
355
|
|
|
307
|
-
// Issue number present → maintain
|
|
308
|
-
//
|
|
309
|
-
// Row carries
|
|
356
|
+
// Issue number present → maintain library-index row for cohort join (sole remaining
|
|
357
|
+
// consumer of the index; ticket CLI path never reads it — #399 D9).
|
|
358
|
+
// Row carries bookKey so cross-book same ticket numbers do not merge (D5).
|
|
310
359
|
// Locked read→upsert→write so concurrent issue/sweep CLI writers do not drop rows.
|
|
311
360
|
if (issueNumber !== undefined) {
|
|
312
361
|
await mergeTaishiLibraryIndexRows(ledgerHome, [
|
|
@@ -357,20 +406,24 @@ async function runTaishiModelGroupsMode(
|
|
|
357
406
|
runs.push(...scan.runs);
|
|
358
407
|
unreadable.push(...scan.unreadable);
|
|
359
408
|
|
|
360
|
-
const
|
|
409
|
+
const bookKey = resolveIssueBookKey({ projectRoot });
|
|
410
|
+
const pagePath = taishiIssuePagePath(ledgerHome, {
|
|
411
|
+
bookKey,
|
|
412
|
+
scopeRootIdentity: projectRoot,
|
|
413
|
+
});
|
|
361
414
|
try {
|
|
362
415
|
const raw = await readFile(pagePath, "utf8");
|
|
363
416
|
JSON.parse(raw); // present page must parse (same loud face as readOrCompute)
|
|
364
417
|
} catch (error) {
|
|
365
418
|
if (!isMissingPathError(error)) {
|
|
366
|
-
throw new TaishiIssueComputeError({ projectRoot, cause: error });
|
|
419
|
+
throw new TaishiIssueComputeError({ bookKey, projectRoot, cause: error });
|
|
367
420
|
}
|
|
368
421
|
try {
|
|
369
422
|
// Reuse this root's scan facts — no second ledger walk on compute-if-missing.
|
|
370
423
|
await runTaishiIssueMode({ mode: "issue", projectRoot }, scan);
|
|
371
424
|
} catch (computeError) {
|
|
372
425
|
if (computeError instanceof TaishiIssueComputeError) throw computeError;
|
|
373
|
-
throw new TaishiIssueComputeError({ projectRoot, cause: computeError });
|
|
426
|
+
throw new TaishiIssueComputeError({ bookKey, projectRoot, cause: computeError });
|
|
374
427
|
}
|
|
375
428
|
}
|
|
376
429
|
}
|
|
@@ -413,11 +466,20 @@ export async function runTaishi(input: TaishiInput): Promise<TaishiResult> {
|
|
|
413
466
|
}
|
|
414
467
|
if (input.mode === "cohort") {
|
|
415
468
|
const ledgerHome = resolveActivationLedgerHome();
|
|
416
|
-
return runTaishiCohortMode(ledgerHome, input, async ({ projectRoot, issueNumber }) => {
|
|
469
|
+
return runTaishiCohortMode(ledgerHome, input, async ({ projectRoot, issueNumber, bookKey }) => {
|
|
470
|
+
// Real ledger book keys drive book scope. Synthetic `root:<id>` address keys
|
|
471
|
+
// (sweep/legacy path-narrow) must not be used as books/ directory names.
|
|
472
|
+
// issueNumber labels the page/index join only — not a ticketNumber scan filter
|
|
473
|
+
// (cohort fixtures historically bind by projectRoot path, not typed ticket).
|
|
474
|
+
const realBookKey =
|
|
475
|
+
bookKey !== undefined && !bookKey.startsWith("root:")
|
|
476
|
+
? bookKey
|
|
477
|
+
: undefined;
|
|
417
478
|
const ensured = await readOrComputeTaishiIssuePage({
|
|
418
479
|
mode: "issue",
|
|
419
480
|
projectRoot,
|
|
420
481
|
issueNumber,
|
|
482
|
+
...(realBookKey === undefined ? {} : { bookKey: realBookKey }),
|
|
421
483
|
});
|
|
422
484
|
return ensured.page;
|
|
423
485
|
});
|
package/src/taishi-index.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* 完全耗时 / 排除后改动行数 / 耗时每千行 / 末次活动时间戳.
|
|
6
6
|
* Rows carry their own sort keys — readers choose order (Story 6/8).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* #399 D9: retained solely for cohort (and sweep producers that feed it).
|
|
9
|
+
* Ticket CLI query path must not read this index (no bootstrap prerequisite).
|
|
10
|
+
* Row address includes book identity so cross-book same ticket numbers do not merge (D5).
|
|
11
|
+
* C2 joins cohort groups by issueNumber → page reference. Missing row =
|
|
10
12
|
* typed vacancy entry — never silent skip, never live recompute.
|
|
11
13
|
*
|
|
12
14
|
* Multi-process issue/sweep writers coordinate the whole read→upsert→write
|
|
@@ -77,12 +79,14 @@ async function withTaishiLibraryIndexLock<T>(
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/**
|
|
80
|
-
* One issue row on the
|
|
81
|
-
*
|
|
82
|
+
* One issue row on the library index.
|
|
83
|
+
* bookKey = book identity (page address component; #399 D5).
|
|
84
|
+
* projectRoot = retained recording/display face (not sole address key after ADR 0068 revision).
|
|
82
85
|
* issueNumber = optional caller typed field retained for cohort join.
|
|
83
86
|
* C1 four columns make the row self-sufficient for cross-issue listing.
|
|
84
87
|
*/
|
|
85
88
|
export type TaishiLibraryIndexRow = {
|
|
89
|
+
readonly bookKey: string;
|
|
86
90
|
readonly projectRoot: string;
|
|
87
91
|
/** Caller typed issue number — present when issue-mode supplied it for cohort join. */
|
|
88
92
|
readonly issueNumber?: number;
|
|
@@ -113,6 +117,7 @@ export function rowFromIssueMetricsPage(
|
|
|
113
117
|
page: TaishiIssueMetricsPage,
|
|
114
118
|
): TaishiLibraryIndexRow {
|
|
115
119
|
return {
|
|
120
|
+
bookKey: page.bookKey,
|
|
116
121
|
projectRoot: page.projectRoot,
|
|
117
122
|
// exactOptionalPropertyTypes: only materialize when page carries it.
|
|
118
123
|
...(page.issueNumber === undefined ? {} : { issueNumber: page.issueNumber }),
|
|
@@ -126,9 +131,11 @@ export function rowFromIssueMetricsPage(
|
|
|
126
131
|
function sortRows(
|
|
127
132
|
rows: readonly TaishiLibraryIndexRow[],
|
|
128
133
|
): TaishiLibraryIndexRow[] {
|
|
129
|
-
// Stable projectRoot sort (C1 listing). Cohort join is by issueNumber find,
|
|
134
|
+
// Stable book → projectRoot sort (C1 listing). Cohort join is by issueNumber find,
|
|
130
135
|
// not row order — issueNumber secondary keeps C2 rows deterministic too.
|
|
131
136
|
return [...rows].sort((a, b) => {
|
|
137
|
+
const byBook = a.bookKey.localeCompare(b.bookKey);
|
|
138
|
+
if (byBook !== 0) return byBook;
|
|
132
139
|
const byRoot = a.projectRoot.localeCompare(b.projectRoot);
|
|
133
140
|
if (byRoot !== 0) return byRoot;
|
|
134
141
|
const aNum = a.issueNumber;
|
|
@@ -162,39 +169,51 @@ export function findTaishiLibraryIndexRow(
|
|
|
162
169
|
return index.rows.find((row) => row.issueNumber === issueNumber);
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
/** Row map key: book + root keeps cross-book same-ticket rows distinct (D5). */
|
|
173
|
+
function indexRowKey(row: Pick<TaishiLibraryIndexRow, "bookKey" | "projectRoot">): string {
|
|
174
|
+
return `${row.bookKey}\0${row.projectRoot}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Within one book, issueNumber stays unique for cohort join. */
|
|
178
|
+
function issueBookKey(bookKey: string, issueNumber: number): string {
|
|
179
|
+
return `${bookKey}\0${issueNumber}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
/**
|
|
166
183
|
* 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,
|
|
169
|
-
* overwrites that number's row only (C2
|
|
184
|
+
* - One row per (bookKey, projectRoot) — re-sweep overwrites that issue's row only (C1).
|
|
185
|
+
* - When issueNumber is present, unique per (bookKey, issueNumber) — re-issue
|
|
186
|
+
* overwrites that number's row only within the book (C2; D5 cross-book safe).
|
|
170
187
|
*/
|
|
171
188
|
export function upsertTaishiLibraryIndexRows(
|
|
172
189
|
existing: TaishiLibraryIndexPage | undefined,
|
|
173
190
|
upserts: readonly TaishiLibraryIndexRow[],
|
|
174
191
|
): TaishiLibraryIndexPage {
|
|
175
|
-
const
|
|
176
|
-
const
|
|
192
|
+
const byKey = new Map<string, TaishiLibraryIndexRow>();
|
|
193
|
+
const keyByIssue = new Map<string, string>();
|
|
177
194
|
|
|
178
195
|
const ingest = (row: TaishiLibraryIndexRow): void => {
|
|
179
|
-
|
|
196
|
+
const key = indexRowKey(row);
|
|
197
|
+
// C2 uniqueness within book: one row per issueNumber — drop prior if number moved.
|
|
180
198
|
if (row.issueNumber !== undefined) {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
199
|
+
const issueKey = issueBookKey(row.bookKey, row.issueNumber);
|
|
200
|
+
const priorKey = keyByIssue.get(issueKey);
|
|
201
|
+
if (priorKey !== undefined && priorKey !== key) {
|
|
202
|
+
byKey.delete(priorKey);
|
|
184
203
|
}
|
|
185
204
|
}
|
|
186
|
-
// C1 uniqueness: one row per projectRoot — drop prior issue map if root reused.
|
|
187
|
-
const prior =
|
|
205
|
+
// C1 uniqueness: one row per (book, projectRoot) — drop prior issue map if root reused.
|
|
206
|
+
const prior = byKey.get(key);
|
|
188
207
|
if (
|
|
189
208
|
prior !== undefined
|
|
190
209
|
&& prior.issueNumber !== undefined
|
|
191
210
|
&& prior.issueNumber !== row.issueNumber
|
|
192
211
|
) {
|
|
193
|
-
|
|
212
|
+
keyByIssue.delete(issueBookKey(prior.bookKey, prior.issueNumber));
|
|
194
213
|
}
|
|
195
|
-
|
|
214
|
+
byKey.set(key, row);
|
|
196
215
|
if (row.issueNumber !== undefined) {
|
|
197
|
-
|
|
216
|
+
keyByIssue.set(issueBookKey(row.bookKey, row.issueNumber), key);
|
|
198
217
|
}
|
|
199
218
|
};
|
|
200
219
|
|
|
@@ -206,7 +225,7 @@ export function upsertTaishiLibraryIndexRows(
|
|
|
206
225
|
for (const row of upserts) {
|
|
207
226
|
ingest(row);
|
|
208
227
|
}
|
|
209
|
-
return buildTaishiLibraryIndexPage([...
|
|
228
|
+
return buildTaishiLibraryIndexPage([...byKey.values()]);
|
|
210
229
|
}
|
|
211
230
|
|
|
212
231
|
/**
|
package/src/taishi-ledger.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Taishi ledger scan: S-family book/runs topology, issue scope
|
|
3
|
-
*
|
|
2
|
+
* Taishi ledger scan: S-family book/runs topology, book × ticket issue scope
|
|
3
|
+
* (#399), and loud unreadable exclusion for required sources.
|
|
4
4
|
* Reuses canonical session/artifact readers — does not parse session JSONL itself.
|
|
5
5
|
*
|
|
6
|
+
* Scope unit = ledger book (git common-dir key) × optional typed ticket filter.
|
|
7
|
+
* CLI issue query never path-filters by projectRoot (owner #399: that face deleted).
|
|
8
|
+
* Sweep/legacy library may still pass projectRoot as a path-narrow when bookKey
|
|
9
|
+
* is absent — recording-side field, not the public query mechanical key.
|
|
10
|
+
* Typed ticketNumber, when requested, decides alone (no silent projectRoot fallback).
|
|
11
|
+
*
|
|
6
12
|
* A2: classifyScopedRun retains typed per-run facts (frame span, tool intervals,
|
|
7
13
|
* terminal face) for metric-family modules — no longer discarded after checks.
|
|
8
14
|
*/
|
|
9
15
|
import { readdir, readFile } from "node:fs/promises";
|
|
10
16
|
import { join } from "node:path";
|
|
11
17
|
|
|
18
|
+
import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
|
|
12
19
|
import {
|
|
13
20
|
physicalPathIdentity,
|
|
14
21
|
resolveActivationLedgerHome,
|
|
@@ -79,8 +86,8 @@ function parseRunDirectoryName(
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
/**
|
|
82
|
-
* Invocation scope faces used for issue 圈定 (C4).
|
|
83
|
-
* projectRoot
|
|
89
|
+
* Invocation scope faces used for issue 圈定 (C4 / #399).
|
|
90
|
+
* projectRoot is retained for narrow path match and conflict facts;
|
|
84
91
|
* ticketNumber is the #176 typed face when present (integer ≥ 1).
|
|
85
92
|
* Single read of invocation.json — no second parse kernel.
|
|
86
93
|
*/
|
|
@@ -89,6 +96,25 @@ type InvocationScopeFields = {
|
|
|
89
96
|
readonly ticketNumber?: number;
|
|
90
97
|
};
|
|
91
98
|
|
|
99
|
+
/** Best-effort book key from a projectRoot git common-dir; undefined when not a git tree. */
|
|
100
|
+
function tryResolveBookKeyFromProjectRoot(projectRoot: string): string | undefined {
|
|
101
|
+
try {
|
|
102
|
+
return resolveBookKeyFromGit(projectRoot);
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function listLedgerBookNames(booksRoot: string): Promise<string[]> {
|
|
109
|
+
try {
|
|
110
|
+
const entries = await readdir(booksRoot, { withFileTypes: true });
|
|
111
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (isMissingPathError(error)) return [];
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
92
118
|
async function readInvocationScopeFields(
|
|
93
119
|
runDirectory: string,
|
|
94
120
|
): Promise<InvocationScopeFields | undefined> {
|
|
@@ -117,31 +143,36 @@ async function readInvocationScopeFields(
|
|
|
117
143
|
}
|
|
118
144
|
|
|
119
145
|
/**
|
|
120
|
-
*
|
|
121
|
-
* -
|
|
122
|
-
*
|
|
146
|
+
* Issue scope decision for one run (#399 / C4).
|
|
147
|
+
* - Scope ticket set → typed ticket alone decides (match in; else out).
|
|
148
|
+
* No projectRoot fallback — that silent path labeled full-project pages as ticket N.
|
|
149
|
+
* - Whole-book scope (CLI bare / bookKey without ticket) → every run in the book.
|
|
150
|
+
* - Path-narrow (sweep/legacy only): no ticket + scopeRootIdentity → path match.
|
|
123
151
|
*/
|
|
124
152
|
function decideIssueScope(input: {
|
|
125
|
-
readonly scopeProjectRootIdentity: string;
|
|
126
153
|
readonly scopeTicketNumber: number | undefined;
|
|
154
|
+
/** Whole-book membership when true (CLI bare / git-resolved book). */
|
|
155
|
+
readonly wholeBook: boolean;
|
|
156
|
+
readonly scopeRootIdentity: string | undefined;
|
|
127
157
|
readonly runProjectRootIdentity: string;
|
|
128
158
|
readonly runTicketNumber: number | undefined;
|
|
129
|
-
}): { readonly inScope: boolean
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
}): { readonly inScope: boolean } {
|
|
160
|
+
if (input.scopeTicketNumber !== undefined) {
|
|
161
|
+
// Strict (book, N): typed ticket alone; never fall back to path match.
|
|
162
|
+
return { inScope: input.runTicketNumber === input.scopeTicketNumber };
|
|
163
|
+
}
|
|
132
164
|
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
&& input.runTicketNumber !== undefined
|
|
136
|
-
) {
|
|
137
|
-
if (input.runTicketNumber === input.scopeTicketNumber) {
|
|
138
|
-
return { inScope: true, conflict: !projectRootMatch };
|
|
139
|
-
}
|
|
140
|
-
// Run bound to a different ticket — typed face wins over projectRoot match.
|
|
141
|
-
return { inScope: false, conflict: false };
|
|
165
|
+
if (input.wholeBook) {
|
|
166
|
+
return { inScope: true };
|
|
142
167
|
}
|
|
143
168
|
|
|
144
|
-
|
|
169
|
+
if (input.scopeRootIdentity !== undefined) {
|
|
170
|
+
return {
|
|
171
|
+
inScope: input.runProjectRootIdentity === input.scopeRootIdentity,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { inScope: true };
|
|
145
176
|
}
|
|
146
177
|
|
|
147
178
|
async function resolveSessionFile(
|
|
@@ -369,36 +400,59 @@ async function classifyScopedRun(input: {
|
|
|
369
400
|
|
|
370
401
|
/**
|
|
371
402
|
* Scan ledger home books/<book>/runs for runs in the issue scope.
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
403
|
+
* #399: scope = book × optional ticket.
|
|
404
|
+
* - bookKey set → that book only; whole-book when no ticket; ticket filters alone.
|
|
405
|
+
* - projectRoot without bookKey (sweep/legacy): git-resolved → whole that book;
|
|
406
|
+
* non-git → path-narrow across books (fixture isolation).
|
|
375
407
|
* Damaged required sources become unreadable exclusions.
|
|
376
408
|
* Readable runs retain typed facts for metric-family composition.
|
|
377
409
|
*/
|
|
378
410
|
export async function scanTaishiIssueRuns(input: {
|
|
379
|
-
|
|
380
|
-
|
|
411
|
+
/** Explicit book key — CLI issue query always supplies this. */
|
|
412
|
+
readonly bookKey?: string;
|
|
413
|
+
/** Caller typed ticket face — when set, only matching invocation.ticketNumber admits. */
|
|
381
414
|
readonly ticketNumber?: number;
|
|
415
|
+
/**
|
|
416
|
+
* Sweep/legacy path-narrow pointer. Not a CLI issue-query face (#399 deleted).
|
|
417
|
+
* When bookKey absent: git common-dir → whole book; else path filter.
|
|
418
|
+
*/
|
|
419
|
+
readonly projectRoot?: string;
|
|
382
420
|
}): Promise<TaishiScopedRunScan> {
|
|
383
421
|
// Package-owned machine home only (ADR 0048) — no invocation-varying override.
|
|
384
422
|
const ledgerHome = resolveActivationLedgerHome();
|
|
385
|
-
const scopeIdentity = physicalPathIdentity(input.projectRoot);
|
|
386
423
|
const scopeTicketNumber = input.ticketNumber;
|
|
387
424
|
const booksRoot = join(ledgerHome, "books");
|
|
388
425
|
|
|
426
|
+
let wholeBook = false;
|
|
427
|
+
let scopeRootIdentity: string | undefined;
|
|
389
428
|
let bookNames: string[];
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
bookNames =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
429
|
+
|
|
430
|
+
if (input.bookKey !== undefined && input.bookKey.trim() !== "") {
|
|
431
|
+
bookNames = [input.bookKey];
|
|
432
|
+
// CLI book scope: whole book unless ticket filters. Never path-narrow.
|
|
433
|
+
wholeBook = true;
|
|
434
|
+
} else if (input.projectRoot !== undefined) {
|
|
435
|
+
const resolved = tryResolveBookKeyFromProjectRoot(input.projectRoot);
|
|
436
|
+
if (resolved !== undefined) {
|
|
437
|
+
bookNames = [resolved];
|
|
438
|
+
wholeBook = true;
|
|
439
|
+
} else {
|
|
440
|
+
bookNames = await listLedgerBookNames(booksRoot);
|
|
441
|
+
scopeRootIdentity = physicalPathIdentity(input.projectRoot);
|
|
396
442
|
}
|
|
397
|
-
|
|
443
|
+
} else {
|
|
444
|
+
bookNames = await listLedgerBookNames(booksRoot);
|
|
445
|
+
wholeBook = true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (bookNames.length === 0) {
|
|
449
|
+
return { runs: [], unreadable: [], scopeConflicts: [] };
|
|
398
450
|
}
|
|
399
451
|
|
|
400
452
|
const runs: TaishiReadableRunFacts[] = [];
|
|
401
453
|
const unreadable: TaishiUnreadableRun[] = [];
|
|
454
|
+
// scopeConflicts retained on the scan face for page envelope compat; book×ticket
|
|
455
|
+
// scope no longer emits projectRoot dual-key conflicts on the CLI path.
|
|
402
456
|
const scopeConflicts: TaishiScopeConflict[] = [];
|
|
403
457
|
|
|
404
458
|
for (const book of bookNames) {
|
|
@@ -429,23 +483,14 @@ export async function scanTaishiIssueRuns(input: {
|
|
|
429
483
|
|
|
430
484
|
const runProjectRootIdentity = physicalPathIdentity(scopeFields.projectRoot);
|
|
431
485
|
const decision = decideIssueScope({
|
|
432
|
-
scopeProjectRootIdentity: scopeIdentity,
|
|
433
486
|
scopeTicketNumber,
|
|
487
|
+
wholeBook,
|
|
488
|
+
scopeRootIdentity,
|
|
434
489
|
runProjectRootIdentity,
|
|
435
490
|
runTicketNumber: scopeFields.ticketNumber,
|
|
436
491
|
});
|
|
437
492
|
if (!decision.inScope) continue;
|
|
438
493
|
|
|
439
|
-
if (decision.conflict) {
|
|
440
|
-
// ticketNumber is defined on both sides whenever conflict is true.
|
|
441
|
-
scopeConflicts.push({
|
|
442
|
-
runId: parsed.runId,
|
|
443
|
-
ticketNumber: scopeFields.ticketNumber as number,
|
|
444
|
-
projectRoot: runProjectRootIdentity,
|
|
445
|
-
fact: "typed-ticketNumber-over-projectRoot",
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
|
|
449
494
|
const classified = await classifyScopedRun({
|
|
450
495
|
book,
|
|
451
496
|
runId: parsed.runId,
|