@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.
@@ -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 — single-issue scope via projectRoot mechanical key. */
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
- * C4: caller typed ticket face (#176). When set, issue 圈定 prefers matching
90
- * invocation.ticketNumber; runs without ticketNumber fall back to projectRoot.
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 ticketNumber?: number;
95
+ readonly bookKey?: string;
93
96
  /**
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.
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 conflictingProjectRoot?: string;
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 addressing remains projectRoot (ADR 0068); issueNumber is not the key.
108
- * When present, issue mode also maintains the unique issueNumber→projectRoot index row.
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 ticket-scope equality.
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 root page)
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: TaishiIssueModeInput,
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 pagePath = taishiIssuePagePath(ledgerHome, projectRoot);
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
- ...(input.issueNumber === undefined ? {} : { issueNumber: input.issueNumber }),
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
- ...(input.issueNumber === undefined ? {} : { issueNumber: input.issueNumber }),
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
- (ticketNumber === undefined
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
- 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
- }
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 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).
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 pagePath = taishiIssuePagePath(ledgerHome, projectRoot);
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
  });
@@ -5,8 +5,10 @@
5
5
  * 完全耗时 / 排除后改动行数 / 耗时每千行 / 末次活动时间戳.
6
6
  * Rows carry their own sort keys — readers choose order (Story 6/8).
7
7
  *
8
- * Cross-book one row per issue. C2 joins cohort groups by issueNumber →
9
- * projectRoot page reference (ADR 0068 mechanical key). Missing row =
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 cross-book library index.
81
- * projectRoot = page addressing key (ADR 0068).
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, also unique per issueNumber — re-issue
169
- * overwrites that number's row only (C2 issueNumber→projectRoot join).
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 byRoot = new Map<string, TaishiLibraryIndexRow>();
176
- const rootByIssue = new Map<number, string>();
192
+ const byKey = new Map<string, TaishiLibraryIndexRow>();
193
+ const keyByIssue = new Map<string, string>();
177
194
 
178
195
  const ingest = (row: TaishiLibraryIndexRow): void => {
179
- // C2 uniqueness: one row per issueNumber — drop prior root if number moved.
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 priorRoot = rootByIssue.get(row.issueNumber);
182
- if (priorRoot !== undefined && priorRoot !== row.projectRoot) {
183
- byRoot.delete(priorRoot);
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 = byRoot.get(row.projectRoot);
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
- rootByIssue.delete(prior.issueNumber);
212
+ keyByIssue.delete(issueBookKey(prior.bookKey, prior.issueNumber));
194
213
  }
195
- byRoot.set(row.projectRoot, row);
214
+ byKey.set(key, row);
196
215
  if (row.issueNumber !== undefined) {
197
- rootByIssue.set(row.issueNumber, row.projectRoot);
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([...byRoot.values()]);
228
+ return buildTaishiLibraryIndexPage([...byKey.values()]);
210
229
  }
211
230
 
212
231
  /**
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Taishi ledger scan: S-family book/runs topology, issue scope by invocation
3
- * projectRoot, and loud unreadable exclusion for required sources.
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 remains required to place a run on any scope path;
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
- * C4 issue scope decision for one run.
121
- * - Both sides carry ticketNumber → typed ticket decides; mismatch projectRoot = conflict.
122
- * - Otherwise → projectRoot mechanical-key fallback.
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; readonly conflict: boolean } {
130
- const projectRootMatch =
131
- input.runProjectRootIdentity === input.scopeProjectRootIdentity;
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
- input.scopeTicketNumber !== undefined
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
- return { inScope: projectRootMatch, conflict: false };
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
- * C4: typed ticketNumber (when present on both scope and run) decides membership;
373
- * otherwise projectRoot mechanical key is the fallback. Ticket-vs-projectRoot
374
- * conflicts still admit the run and surface on scopeConflicts.
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
- readonly projectRoot: string;
380
- /** Caller typed ticket face — when set, prefer #176 invocation ticketNumber. */
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
- try {
391
- const entries = await readdir(booksRoot, { withFileTypes: true });
392
- bookNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
393
- } catch (error) {
394
- if (isMissingPathError(error)) {
395
- return { runs: [], unreadable: [], scopeConflicts: [] };
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
- throw error;
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,