@akagilnc/pi-workflow-roles 0.1.2193 → 0.1.2203

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.
@@ -436,9 +436,10 @@ function appendUsageAndExamples(
436
436
 
437
437
  function renderHelp(): string {
438
438
  const doc = helpDocument();
439
- const top = projectCommandHelp("top");
439
+ // "top" is a required PUBLIC_COMMAND_HELP topic — no fallback prose (#412/397-F3).
440
+ const top = projectCommandHelp("top")!;
440
441
  const lines: string[] = [
441
- `ak-role — ${top?.summary ?? "public role CLI"}`,
442
+ `ak-role — ${top.summary}`,
442
443
  ];
443
444
  appendUsageAndExamples(lines, "top");
444
445
  lines.push("", PUBLIC_NAVIGATOR_HELP_NOTE);
@@ -493,11 +494,9 @@ function renderCommandHelp(command: string): string | undefined {
493
494
  lines.push(`ak-role ${match.name}`);
494
495
  }
495
496
  appendUsageAndExamples(lines, command);
496
- if (command in PUBLIC_ROLE_ARGV || command === "global") {
497
- const owner =
498
- command === "global"
499
- ? "global"
500
- : (command as keyof typeof PUBLIC_ROLE_ARGV);
497
+ // listHelpCapabilities never yields "global"; only role owners carry OPTIONS (#412/397-F3).
498
+ if (command in PUBLIC_ROLE_ARGV) {
499
+ const owner = command as keyof typeof PUBLIC_ROLE_ARGV;
501
500
  lines.push("", "OPTIONS");
502
501
  lines.push(...renderHumanOwnerOptionLines(owner));
503
502
  }
@@ -436,9 +436,16 @@ export type ParseTaishiSweepArgv = {
436
436
 
437
437
  export type ParseTaishiCohortArgv = {
438
438
  readonly query: "cohort";
439
+ /** Tokens before cwd-book stamping; bare N resolves at run (#412). */
439
440
  readonly groups: readonly [
440
- { readonly groupLabel: string; readonly issues: readonly number[] },
441
- { readonly groupLabel: string; readonly issues: readonly number[] },
441
+ {
442
+ readonly groupLabel: string;
443
+ readonly issues: readonly TaishiCohortIssueToken[];
444
+ },
445
+ {
446
+ readonly groupLabel: string;
447
+ readonly issues: readonly TaishiCohortIssueToken[];
448
+ },
442
449
  ];
443
450
  };
444
451
 
@@ -2198,17 +2205,108 @@ export function parseTaishiTicketNumber(
2198
2205
  return value;
2199
2206
  }
2200
2207
 
2201
- function parseTaishiIssueNumberList(raw: string, flag: string): number[] {
2208
+ /**
2209
+ * One cohort issue token before cwd-book stamping.
2210
+ * - bare N → join cwd book at run time (#412 / #399 ticket口径)
2211
+ * - book:N → explicit cross-book join (last ":" + positive integer RHS)
2212
+ */
2213
+ export type TaishiCohortIssueToken =
2214
+ | { readonly kind: "bare"; readonly issueNumber: number }
2215
+ | {
2216
+ readonly kind: "book-qualified";
2217
+ readonly bookKey: string;
2218
+ readonly issueNumber: number;
2219
+ };
2220
+
2221
+ /**
2222
+ * Parse one cohort issue token: bare positive integer or `book:N`.
2223
+ * Book keys may contain ":" (e.g. synthetic `root:<path>`) — split on the last
2224
+ * colon only when the RHS is a positive integer token.
2225
+ */
2226
+ export function parseTaishiCohortIssueToken(
2227
+ raw: string,
2228
+ flag: string,
2229
+ ): TaishiCohortIssueToken {
2230
+ const trimmed = raw.trim();
2231
+ if (trimmed === "") {
2232
+ throw new CliUsageError(
2233
+ `${flag} requires a comma-separated list of N or book:N`,
2234
+ );
2235
+ }
2236
+ const sep = trimmed.lastIndexOf(":");
2237
+ if (sep > 0) {
2238
+ const rhs = trimmed.slice(sep + 1);
2239
+ if (TAISHI_TICKET_NUMBER_PATTERN.test(rhs)) {
2240
+ const bookKey = trimmed.slice(0, sep);
2241
+ if (bookKey.trim() === "") {
2242
+ throw new CliUsageError(
2243
+ `${flag} book:N requires a non-empty book key, got ${raw}`,
2244
+ );
2245
+ }
2246
+ return {
2247
+ kind: "book-qualified",
2248
+ bookKey,
2249
+ issueNumber: parseTaishiTicketNumber(rhs, flag),
2250
+ };
2251
+ }
2252
+ }
2253
+ return {
2254
+ kind: "bare",
2255
+ issueNumber: parseTaishiTicketNumber(trimmed, flag),
2256
+ };
2257
+ }
2258
+
2259
+ /**
2260
+ * Sole cohort list grammar (#412): split on unescaped commas. `\,` is a literal
2261
+ * comma and `\\` a literal backslash — both round-trip, so any directory-name
2262
+ * book key (ADR 0048) is expressible. Any other `\x` stays literally `\x`, so
2263
+ * pre-existing unescaped input never changes meaning. Colons remain owned by
2264
+ * the token's lastIndexOf(':') rule.
2265
+ */
2266
+ function splitTaishiCohortIssueListParts(raw: string): string[] {
2267
+ const parts: string[] = [];
2268
+ let current = "";
2269
+ let escaped = false;
2270
+ for (const ch of raw) {
2271
+ if (escaped) {
2272
+ current += ch === "," || ch === "\\" ? ch : `\\${ch}`;
2273
+ escaped = false;
2274
+ continue;
2275
+ }
2276
+ if (ch === "\\") {
2277
+ escaped = true;
2278
+ continue;
2279
+ }
2280
+ if (ch === ",") {
2281
+ parts.push(current);
2282
+ current = "";
2283
+ continue;
2284
+ }
2285
+ current += ch;
2286
+ }
2287
+ parts.push(escaped ? `${current}\\` : current);
2288
+ return parts;
2289
+ }
2290
+
2291
+ function parseTaishiCohortIssueTokenList(
2292
+ raw: string,
2293
+ flag: string,
2294
+ ): TaishiCohortIssueToken[] {
2202
2295
  const trimmed = raw.trim();
2203
2296
  if (trimmed === "") {
2204
- throw new CliUsageError(`${flag} requires a comma-separated positive integer list`);
2297
+ throw new CliUsageError(
2298
+ `${flag} requires a comma-separated list of N or book:N`,
2299
+ );
2205
2300
  }
2206
- const parts = trimmed.split(",").map((part) => part.trim());
2301
+ const parts = splitTaishiCohortIssueListParts(trimmed).map((part) =>
2302
+ part.trim(),
2303
+ );
2207
2304
  if (parts.some((part) => part === "")) {
2208
- throw new CliUsageError(`${flag} requires a comma-separated positive integer list`);
2305
+ throw new CliUsageError(
2306
+ `${flag} requires a comma-separated list of N or book:N`,
2307
+ );
2209
2308
  }
2210
- // Same numeric rule as --ticket; diagnostic names the actual group flag.
2211
- return parts.map((part) => parseTaishiTicketNumber(part, flag));
2309
+ return parts.map((part) => parseTaishiCohortIssueToken(part, flag));
2212
2310
  }
2213
2311
 
2214
2312
  function requireOptionValue(
@@ -2285,7 +2383,7 @@ export function parseTaishiArgv(args: readonly string[]): ParseTaishiArgvResult
2285
2383
  requireOptionValue(
2286
2384
  taken.def.canonical,
2287
2385
  taken.value,
2288
- "a comma-separated positive integer list",
2386
+ "a comma-separated list of N or book:N",
2289
2387
  ),
2290
2388
  );
2291
2389
  continue;
@@ -2340,11 +2438,17 @@ export function parseTaishiArgv(args: readonly string[]): ParseTaishiArgvResult
2340
2438
  groups: [
2341
2439
  {
2342
2440
  groupLabel: groupALabel,
2343
- issues: parseTaishiIssueNumberList(groupAIssuesRaw, "--group-a-issues"),
2441
+ issues: parseTaishiCohortIssueTokenList(
2442
+ groupAIssuesRaw,
2443
+ "--group-a-issues",
2444
+ ),
2344
2445
  },
2345
2446
  {
2346
2447
  groupLabel: groupBLabel,
2347
- issues: parseTaishiIssueNumberList(groupBIssuesRaw, "--group-b-issues"),
2448
+ issues: parseTaishiCohortIssueTokenList(
2449
+ groupBIssuesRaw,
2450
+ "--group-b-issues",
2451
+ ),
2348
2452
  },
2349
2453
  ],
2350
2454
  };
@@ -177,7 +177,7 @@ export function evaluateTaishiModeOptionContract(
177
177
  return {
178
178
  ok: false,
179
179
  message:
180
- "usage: ak-role taishi --cohort --group-a-label <L> --group-a-issues <N[,N...]> --group-b-label <L> --group-b-issues <N[,N...]",
180
+ "usage: ak-role taishi --cohort --group-a-label <L> --group-a-issues <N|book:N[,...]> --group-b-label <L> --group-b-issues <N|book:N[,...]>",
181
181
  };
182
182
  }
183
183
  return {
@@ -643,15 +643,15 @@ const TAISHI_OPTIONS = [
643
643
  owner: "taishi",
644
644
  canonical: "--group-a-issues",
645
645
  aliases: [],
646
- valueMetavar: "N[,N...]",
646
+ valueMetavar: "N|book:N[,...]",
647
647
  required: false,
648
648
  repeatable: false,
649
649
  form: "option",
650
650
  modes: ["cohort"],
651
651
  requiredInModes: ["cohort"],
652
652
  description: {
653
- en: "Cohort group A comma-separated positive issue numbers (required in cohort mode).",
654
- zh: "cohort A 组逗号分隔正整数 issue 列表(cohort 模式必填)。",
653
+ en: "Cohort group A issues: bare N joins cwd book; book:N selects another book; escape a literal comma/backslash in a book key as \\, / \\\\ (required in cohort mode).",
654
+ zh: "cohort A 组 issue:裸 N 归属 cwd 簿;book:N 显式跨簿;簿键中的逗号/反斜杠用 \\, / \\\\ 转义(cohort 模式必填)。",
655
655
  },
656
656
  },
657
657
  {
@@ -675,15 +675,15 @@ const TAISHI_OPTIONS = [
675
675
  owner: "taishi",
676
676
  canonical: "--group-b-issues",
677
677
  aliases: [],
678
- valueMetavar: "N[,N...]",
678
+ valueMetavar: "N|book:N[,...]",
679
679
  required: false,
680
680
  repeatable: false,
681
681
  form: "option",
682
682
  modes: ["cohort"],
683
683
  requiredInModes: ["cohort"],
684
684
  description: {
685
- en: "Cohort group B comma-separated positive issue numbers (required in cohort mode).",
686
- zh: "cohort B 组逗号分隔正整数 issue 列表(cohort 模式必填)。",
685
+ en: "Cohort group B issues: bare N joins cwd book; book:N selects another book; escape a literal comma/backslash in a book key as \\, / \\\\ (required in cohort mode).",
686
+ zh: "cohort B 组 issue:裸 N 归属 cwd 簿;book:N 显式跨簿;簿键中的逗号/反斜杠用 \\, / \\\\ 转义(cohort 模式必填)。",
687
687
  },
688
688
  },
689
689
  ] as const satisfies readonly PublicOptionDefinition[];
@@ -1074,7 +1074,7 @@ const ROLE_COMMAND_HELP = {
1074
1074
  usage: [
1075
1075
  "ak-role taishi [--ticket <N>]",
1076
1076
  "ak-role taishi [sweep] --attach <path>",
1077
- "ak-role taishi --cohort --group-a-label <L> --group-a-issues <N[,N...]> --group-b-label <L> --group-b-issues <N[,N...]>",
1077
+ "ak-role taishi --cohort --group-a-label <L> --group-a-issues <N|book:N[,...]> --group-b-label <L> --group-b-issues <N|book:N[,...]>",
1078
1078
  ],
1079
1079
  examples: [
1080
1080
  "ak-role taishi",
@@ -1163,9 +1163,10 @@ export function renderHumanOwnerOptionLines(
1163
1163
  }
1164
1164
  if (opt.aliases.length > 0) {
1165
1165
  // Prefer single-token aliases in the spelling hint (plan/apply, -h).
1166
+ // #412/397-F2: inside aliases.length > 0 the empty branch is dead — join directly.
1166
1167
  const aliasHint = opt.aliases.join(", ");
1167
1168
  if (opt.form === "positional") {
1168
- spelling = opt.aliases.length > 0 ? opt.aliases.join("|") : spelling;
1169
+ spelling = opt.aliases.join("|");
1169
1170
  } else {
1170
1171
  spelling = `${spelling} (${aliasHint})`;
1171
1172
  }
@@ -184,9 +184,33 @@ export async function runPublicTaishi(
184
184
  }
185
185
 
186
186
  if (parsed.query === "cohort") {
187
+ // #412: bare N → cwd book (same口径 as #399 --ticket); book:N stays explicit.
188
+ // No cross-book silent scan — callers pass book:N for another repo's issues.
189
+ // Cwd book resolution is lazy and resolved at most once: an all-book:N
190
+ // cohort never touches cwd Git, so a non-git cwd cannot reject a purely
191
+ // explicit cross-book query.
192
+ let defaultBookKey: string | undefined;
193
+ const resolveIssue = (
194
+ token: (typeof parsed.groups)[0]["issues"][number],
195
+ ) => {
196
+ if (token.kind === "book-qualified") {
197
+ return { bookKey: token.bookKey, issueNumber: token.issueNumber };
198
+ }
199
+ defaultBookKey ??= resolveTaishiIssueBookKeyFromCwd();
200
+ return { bookKey: defaultBookKey, issueNumber: token.issueNumber };
201
+ };
187
202
  const result = await runTaishi({
188
203
  mode: "cohort",
189
- groups: parsed.groups,
204
+ groups: [
205
+ {
206
+ groupLabel: parsed.groups[0].groupLabel,
207
+ issues: parsed.groups[0].issues.map(resolveIssue),
208
+ },
209
+ {
210
+ groupLabel: parsed.groups[1].groupLabel,
211
+ issues: parsed.groups[1].issues.map(resolveIssue),
212
+ },
213
+ ],
190
214
  });
191
215
  io.stdout(`${JSON.stringify(result, null, 2)}\n`);
192
216
  return { exitCode: 0 };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Single true source for the Taishi projectRoot→bookKey rule (#399 / ADR 0048).
3
+ * Git-resolvable → git common-dir host directory name; otherwise the stable
4
+ * synthetic `root:<projectRoot identity>` so read/write page paths agree
5
+ * without a prior scan. Issue/sweep derivation and legacy library-index
6
+ * healing must both call this rule — never a second copy of the fallback.
7
+ *
8
+ * Failure honesty: a real existing directory that is a *confirmed* non-repository
9
+ * (git's own "not a git repository" verdict, plus the structurally-identical
10
+ * absent root / non-directory root / ENOTDIR mid-path faces — no repository can
11
+ * ever live there) keeps the r4-adjudicated legal `root:<identity>` fallback.
12
+ * Unconfirmed git failures — dubious ownership exit 128 and every other
13
+ * diagnostic that does not certify "non repository" — propagate loudly with
14
+ * their real cause (#413 r2 U5): washing them into a synthetic key would be
15
+ * silent identity drift. Git infrastructure failures (missing binary → ENOENT)
16
+ * stay loud too. The confirmed/unconfirmed classification is implemented once,
17
+ * in the shared resolver owner.
18
+ */
19
+ import { statSync } from "node:fs";
20
+ import { isAbsolute } from "node:path";
21
+ import {
22
+ ActivationGitRepositoryRequiredError,
23
+ resolveBookKeyFromGit,
24
+ } from "./activation-ledger-git.ts";
25
+ import { errnoCode, physicalPathIdentity } from "./activation-ledger-topology.ts";
26
+
27
+ export function resolveTaishiBookKey(projectRoot: string): string {
28
+ const identity = physicalPathIdentity(projectRoot);
29
+ let stats;
30
+ try {
31
+ stats = statSync(identity);
32
+ } catch (error) {
33
+ // Absent root AND plain file mid-path both mean "this path can never be a
34
+ // directory, hence never a Git repository" — same synthetic fallback.
35
+ const code = errnoCode(error);
36
+ if (code === "ENOENT" || code === "ENOTDIR") return `root:${identity}`;
37
+ throw error;
38
+ }
39
+ if (!stats.isDirectory()) return `root:${identity}`;
40
+ // #413 r2 U5 boundary at the Taishi seam: only git's own confirmed
41
+ // "not a git repository" verdict may fall back to `root:<identity>`;
42
+ // unconfirmed nonzero exits (dubious ownership etc.) stay loud.
43
+ try {
44
+ return resolveBookKeyFromGit(identity);
45
+ } catch (error) {
46
+ if (
47
+ error instanceof ActivationGitRepositoryRequiredError
48
+ && error.confirmedNonRepository
49
+ ) {
50
+ return `root:${identity}`;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * #413 r2 U3: synthetic keys are exactly `root:` + an absolute path identity
58
+ * (the physicalPathIdentity face). A real Git book whose basename is literally
59
+ * `root:foo` is NOT synthetic — its remainder is not an absolute path. The
60
+ * check is bidirectional: real books are never misclassified by the prefix,
61
+ * and existing synthetic keys keep their path-scope meaning.
62
+ */
63
+ export function isSyntheticTaishiBookKey(bookKey: string): boolean {
64
+ return bookKey.startsWith("root:")
65
+ && isAbsolute(bookKey.slice("root:".length));
66
+ }
@@ -40,10 +40,16 @@ export type TaishiCohortOptionalMetric =
40
40
  | { readonly status: "present"; readonly value: number }
41
41
  | { readonly status: "absent" };
42
42
 
43
+ /** Fully resolved cohort issue identity — book is always explicit at the library face. */
44
+ export type TaishiCohortIssueRef = {
45
+ readonly bookKey: string;
46
+ readonly issueNumber: number;
47
+ };
48
+
43
49
  export type TaishiCohortGroupInput = {
44
50
  readonly groupLabel: string;
45
- /** Issue numbers (caller typed); join key into the library index. */
46
- readonly issues: readonly number[];
51
+ /** (bookKey, issueNumber) pairs; join key into the library index (#412). */
52
+ readonly issues: readonly TaishiCohortIssueRef[];
47
53
  };
48
54
 
49
55
  export type TaishiCohortModeInput = {
@@ -63,6 +69,8 @@ export type TaishiCohortIssueEntry =
63
69
  | {
64
70
  readonly issueNumber: number;
65
71
  readonly status: "absent";
72
+ /** Requested book scope — cross-book same-number vacancies stay self-describing (#413 r2 U4). */
73
+ readonly bookKey: string;
66
74
  };
67
75
 
68
76
  /** Per-role contrast stats within one cohort group. */
@@ -159,16 +167,20 @@ async function aggregateGroup(
159
167
  let hasReworkSample = false;
160
168
  const legWalls: number[] = [];
161
169
 
162
- for (const issueNumber of input.issues) {
163
- const row = findTaishiLibraryIndexRow(index, issueNumber);
170
+ for (const ref of input.issues) {
171
+ const { issueNumber, bookKey } = ref;
172
+ const row = findTaishiLibraryIndexRow(index, issueNumber, bookKey);
164
173
  if (row === undefined) {
165
- // Only "index has no such row" is typed vacancy.
166
- issueEntries.push({ issueNumber, status: "absent" });
174
+ // Only "index has no such row in this book" is typed vacancy.
175
+ // The requested bookKey rides along so book-a:12 and book-b:12 absences
176
+ // are distinguishable (#413 r2 U4).
177
+ issueEntries.push({ issueNumber, status: "absent", bookKey });
167
178
  continue;
168
179
  }
169
180
 
170
181
  // Index hit: ensure page via sole compute-if-missing kernel (read or compute).
171
182
  // Ensure failure stays loud with issue identity — never washed to absent.
183
+ // row.bookKey is normalized at index read — present projection always carries it (F3).
172
184
  const page = (await ensureIssuePage({
173
185
  projectRoot: row.projectRoot,
174
186
  issueNumber,
@@ -216,7 +228,7 @@ async function aggregateGroup(
216
228
  }
217
229
 
218
230
  /**
219
- * Run cohort contrast: join index by issueNumber, ensure pages (#338), fold,
231
+ * Run cohort contrast: join index by (bookKey, issueNumber), ensure pages (#338), fold,
220
232
  * emit two side-by-side group results. Page writes happen only through the
221
233
  * injected ensurer (sole issue kernel + existing writer) — cohort itself is
222
234
  * not a second compute kernel or projection.
@@ -14,6 +14,7 @@ import { readFile } from "node:fs/promises";
14
14
  import { Type, type Static } from "typebox";
15
15
 
16
16
  import { physicalPathIdentity, resolveActivationLedgerHome } from "./activation-ledger-topology.ts";
17
+ import { isSyntheticTaishiBookKey, resolveTaishiBookKey } from "./taishi-book-key.ts";
17
18
  import {
18
19
  runTaishiCohortMode,
19
20
  type TaishiCohortModeInput,
@@ -33,7 +34,6 @@ import {
33
34
  buildTaishiModelGroupsPage,
34
35
  type TaishiModelGroupsPage,
35
36
  } from "./taishi-model-groups.ts";
36
- import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
37
37
  import {
38
38
  assertTaishiChangedLinesInput,
39
39
  buildTaishiIssueMetricsPage,
@@ -222,19 +222,11 @@ function cachedPageMatchesRequestedScope(
222
222
  return page.issueNumber === requestedTicket;
223
223
  }
224
224
 
225
- function tryResolveBookKey(projectRoot: string): string | undefined {
226
- try {
227
- return resolveBookKeyFromGit(projectRoot);
228
- } catch {
229
- return undefined;
230
- }
231
- }
232
-
233
225
  /**
234
226
  * Resolve page/scan book identity for issue mode (#399).
235
227
  * 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.
228
+ * Sweep/legacy without bookKey falls back to the single shared
229
+ * projectRoot→bookKey rule (git common-dir, else `root:<identity>`).
238
230
  */
239
231
  function resolveIssueBookKey(input: {
240
232
  readonly bookKey?: string;
@@ -243,13 +235,17 @@ function resolveIssueBookKey(input: {
243
235
  if (input.bookKey !== undefined && input.bookKey.trim() !== "") {
244
236
  return input.bookKey;
245
237
  }
246
- const fromGit = tryResolveBookKey(input.projectRoot);
247
- if (fromGit !== undefined) return fromGit;
248
- return `root:${physicalPathIdentity(input.projectRoot)}`;
238
+ return resolveTaishiBookKey(input.projectRoot);
249
239
  }
250
240
 
251
241
  export async function readOrComputeTaishiIssuePage(
252
242
  input: TaishiIssueModeInput,
243
+ /**
244
+ * Cohort ensure only (#412): narrow the cache-miss recompute scan to this
245
+ * root inside the already-selected book — a miss must never widen to a
246
+ * whole-book scan for one index row. Not a public CLI face.
247
+ */
248
+ options?: { readonly scanProjectRoot?: string },
253
249
  ): Promise<TaishiIssueModeResult> {
254
250
  const ledgerHome = resolveActivationLedgerHome();
255
251
  const projectRoot = physicalPathIdentity(input.projectRoot);
@@ -284,7 +280,7 @@ export async function readOrComputeTaishiIssuePage(
284
280
  }
285
281
 
286
282
  try {
287
- return await runTaishiIssueMode(input);
283
+ return await runTaishiIssueMode(input, undefined, options?.scanProjectRoot);
288
284
  } catch (error) {
289
285
  if (error instanceof TaishiIssueComputeError) throw error;
290
286
  throw new TaishiIssueComputeError({
@@ -300,6 +296,8 @@ async function runTaishiIssueMode(
300
296
  input: TaishiIssueModeInput | TaishiMergedPullRequest,
301
297
  /** Caller-supplied scan facts — skip a second ledger walk when already scanned. */
302
298
  precomputedScan?: TaishiScopedRunScan,
299
+ /** Cohort ensure conjunction (#412): scan this root inside the selected book. */
300
+ scanProjectRoot?: string,
303
301
  ): Promise<TaishiIssueModeResult> {
304
302
  // Programmatic issue/sweep entry boundary — same finite non-negative rule as attach schema.
305
303
  assertTaishiChangedLinesInput(input.changedLines);
@@ -317,6 +315,7 @@ async function runTaishiIssueMode(
317
315
  (inputBookKey !== undefined
318
316
  ? await scanTaishiIssueRuns({
319
317
  bookKey: inputBookKey,
318
+ ...(scanProjectRoot === undefined ? {} : { projectRoot: scanProjectRoot }),
320
319
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
321
320
  })
322
321
  : ticketNumber === undefined
@@ -467,20 +466,26 @@ export async function runTaishi(input: TaishiInput): Promise<TaishiResult> {
467
466
  if (input.mode === "cohort") {
468
467
  const ledgerHome = resolveActivationLedgerHome();
469
468
  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).
469
+ // Real ledger book keys drive book scope. Only `root:` + an absolute path
470
+ // is a synthetic sweep/legacy address key; a real book basename may be
471
+ // literally `root:foo` and must keep its book scope (U3, non-ambiguous
472
+ // bidirectional check — never a bare prefix test).
474
473
  const realBookKey =
475
- bookKey !== undefined && !bookKey.startsWith("root:")
474
+ bookKey !== undefined && !isSyntheticTaishiBookKey(bookKey)
476
475
  ? bookKey
477
476
  : undefined;
477
+ // T4 revised (#413 r2 U2 owner decision, per #399 book×ticket identity):
478
+ // cohort issueNumber IS the ticketNumber. A cache-miss recompute filters
479
+ // by bookKey ∧ projectRoot ∧ invocation.ticketNumber; legacy runs without
480
+ // a typed ticket are excluded from the recompute — never merged into the
481
+ // issue page by path alone.
478
482
  const ensured = await readOrComputeTaishiIssuePage({
479
483
  mode: "issue",
480
484
  projectRoot,
481
485
  issueNumber,
486
+ ticketNumber: issueNumber,
482
487
  ...(realBookKey === undefined ? {} : { bookKey: realBookKey }),
483
- });
488
+ }, { scanProjectRoot: projectRoot });
484
489
  return ensured.page;
485
490
  });
486
491
  }
@@ -8,8 +8,11 @@
8
8
  * #399 D9: retained solely for cohort (and sweep producers that feed it).
9
9
  * Ticket CLI query path must not read this index (no bootstrap prerequisite).
10
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 =
11
+ * C2 joins cohort groups by (bookKey, issueNumber) → page reference. Missing row =
12
12
  * typed vacancy entry — never silent skip, never live recompute.
13
+ * #412: bare cohort issue numbers resolve inside one book; no cross-book silent find.
14
+ * Legacy rows lacking bookKey heal via the single shared projectRoot→bookKey rule
15
+ * (git common-dir when resolvable, else `root:<identity>`) on read/ingest (F1/F3).
13
16
  *
14
17
  * Multi-process issue/sweep writers coordinate the whole read→upsert→write
15
18
  * on one exclusive lock next to the index (atomic rename still prevents torn
@@ -22,7 +25,9 @@ import { writeFileAtomically } from "./atomic-write.ts";
22
25
  import {
23
26
  assertLedgerFileInsideHome,
24
27
  ensureRealDirectoryTree,
28
+ physicalPathIdentity,
25
29
  } from "./activation-ledger-topology.ts";
30
+ import { resolveTaishiBookKey } from "./taishi-book-key.ts";
26
31
  import type {
27
32
  TaishiIssueMetricsPage,
28
33
  TaishiOptionalMetricNumber,
@@ -128,10 +133,27 @@ export function rowFromIssueMetricsPage(
128
133
  };
129
134
  }
130
135
 
136
+ /**
137
+ * #412 F1/F3: pre-#399 library-index rows omit bookKey. Heal with the single
138
+ * shared projectRoot→bookKey rule (#399 / ADR 0048) — the same rule the
139
+ * issue/sweep path uses, never a second resolver.
140
+ */
141
+ export function normalizeTaishiLibraryIndexRow(
142
+ row: TaishiLibraryIndexRow,
143
+ ): TaishiLibraryIndexRow {
144
+ const rawBook = (row as { readonly bookKey?: unknown }).bookKey;
145
+ if (typeof rawBook === "string" && rawBook !== "") {
146
+ if (rawBook === row.bookKey) return row;
147
+ return { ...row, bookKey: rawBook };
148
+ }
149
+ const projectRoot = physicalPathIdentity(row.projectRoot);
150
+ return { ...row, projectRoot, bookKey: resolveTaishiBookKey(projectRoot) };
151
+ }
152
+
131
153
  function sortRows(
132
154
  rows: readonly TaishiLibraryIndexRow[],
133
155
  ): TaishiLibraryIndexRow[] {
134
- // Stable book → projectRoot sort (C1 listing). Cohort join is by issueNumber find,
156
+ // Stable book → projectRoot sort (C1 listing). Cohort join is by (book, issue) find,
135
157
  // not row order — issueNumber secondary keeps C2 rows deterministic too.
136
158
  return [...rows].sort((a, b) => {
137
159
  const byBook = a.bookKey.localeCompare(b.bookKey);
@@ -153,20 +175,24 @@ export function buildTaishiLibraryIndexPage(
153
175
  ): TaishiLibraryIndexPage {
154
176
  return {
155
177
  kind: "taishi-library-index",
156
- rows: sortRows(rows),
178
+ rows: sortRows(rows.map(normalizeTaishiLibraryIndexRow)),
157
179
  };
158
180
  }
159
181
 
160
182
  /**
161
- * Look up the first index row for an issue number.
183
+ * Look up the index row for (bookKey, issueNumber).
184
+ * #412: no cross-book silent scan — caller supplies the book (cwd book or book:N).
162
185
  * Absence is a lawful cohort vacancy signal — not an error.
163
186
  */
164
187
  export function findTaishiLibraryIndexRow(
165
188
  index: TaishiLibraryIndexPage | undefined,
166
189
  issueNumber: number,
190
+ bookKey: string,
167
191
  ): TaishiLibraryIndexRow | undefined {
168
192
  if (index === undefined) return undefined;
169
- return index.rows.find((row) => row.issueNumber === issueNumber);
193
+ return index.rows.find(
194
+ (row) => row.issueNumber === issueNumber && row.bookKey === bookKey,
195
+ );
170
196
  }
171
197
 
172
198
  /** Row map key: book + root keeps cross-book same-ticket rows distinct (D5). */
@@ -193,6 +219,7 @@ export function upsertTaishiLibraryIndexRows(
193
219
  const keyByIssue = new Map<string, string>();
194
220
 
195
221
  const ingest = (row: TaishiLibraryIndexRow): void => {
222
+ row = normalizeTaishiLibraryIndexRow(row);
196
223
  const key = indexRowKey(row);
197
224
  // C2 uniqueness within book: one row per issueNumber — drop prior if number moved.
198
225
  if (row.issueNumber !== undefined) {
@@ -230,8 +257,10 @@ export function upsertTaishiLibraryIndexRows(
230
257
 
231
258
  /**
232
259
  * Read existing library index, or undefined when absent.
233
- * Single typed producer writes this file — JSON.parse failure is loud;
234
- * no bespoke shape validator on the self-read path.
260
+ * Single typed producer writes this file — JSON.parse failure is loud; a
261
+ * syntactically valid but malformed shape (null / non-object / rows not an
262
+ * array) is rejected at this sole read boundary with the file path and real
263
+ * shape, so no type assertion lets garbage reach consumers (#413 r2 U1).
235
264
  */
236
265
  export async function readTaishiLibraryIndexPage(
237
266
  ledgerHome: string,
@@ -250,7 +279,23 @@ export async function readTaishiLibraryIndexPage(
250
279
  }
251
280
  throw error;
252
281
  }
253
- return JSON.parse(raw) as TaishiLibraryIndexPage;
282
+ const parsed: unknown = JSON.parse(raw);
283
+ if (
284
+ parsed === null
285
+ || typeof parsed !== "object"
286
+ || !Array.isArray((parsed as { readonly rows?: unknown }).rows)
287
+ ) {
288
+ const shape = parsed === null
289
+ ? "null"
290
+ : typeof parsed !== "object"
291
+ ? typeof parsed
292
+ : `object with non-array rows (${typeof (parsed as { rows?: unknown }).rows})`;
293
+ throw new Error(
294
+ `taishi library-index at ${path} is malformed (${shape}; expected an index page with a rows array) — rejected at the read boundary`,
295
+ );
296
+ }
297
+ // Heal legacy rows at the read boundary so every consumer sees defined bookKey.
298
+ return buildTaishiLibraryIndexPage((parsed as TaishiLibraryIndexPage).rows);
254
299
  }
255
300
 
256
301
  /**