@akagilnc/pi-workflow-roles 0.1.2193 → 0.1.2207

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.
@@ -11,7 +11,7 @@ const GIT_DISCOVERY_ENV_KEYS = [
11
11
  ] as const;
12
12
 
13
13
  function envWithoutGitDiscovery(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
14
- const env: NodeJS.ProcessEnv = { ...base };
14
+ const env: NodeJS.ProcessEnv = { ...base, LC_ALL: "C" };
15
15
  for (const key of GIT_DISCOVERY_ENV_KEYS) {
16
16
  delete env[key];
17
17
  }
@@ -19,17 +19,42 @@ function envWithoutGitDiscovery(base: NodeJS.ProcessEnv = process.env): NodeJS.P
19
19
  }
20
20
 
21
21
  /**
22
- * Typed book-key discovery failure: a git child ran and reported non-repository status.
23
- * Original git cause (nonzero exit) is retained. Spawn/OS failures never become this type.
22
+ * #413 r2 U5 — single failure-classification owner. A git child nonzero exit is
23
+ * only a *confirmed* non-repository verdict when git's own diagnostic says so
24
+ * ("fatal: not a git repository …", forced to the C locale so the wording is
25
+ * deterministic). Every other diagnostic — dubious ownership exit 128,
26
+ * permissions, anything unknown — leaves the question open: git found or
27
+ * refuses to adjudicate a repository-shaped situation without certifying
28
+ * "non repository". Classification by diagnostic identity, not by exit code
29
+ * alone, because both faces share exit 128.
30
+ */
31
+ const CONFIRMED_NON_REPOSITORY_STDERR = /^fatal:\s*not a git repository/i;
32
+
33
+ export function isConfirmedNonRepositoryStderr(stderr: string): boolean {
34
+ return CONFIRMED_NON_REPOSITORY_STDERR.test(stderr);
35
+ }
36
+
37
+ /**
38
+ * Typed book-key discovery failure: a git child ran and exited nonzero.
39
+ * Original git cause (nonzero exit) is retained. Spawn/OS failures never become
40
+ * this type. `confirmedNonRepository` records whether git itself certified
41
+ * "no repository here" (true) or merely failed for an unadjudicated reason
42
+ * such as dubious ownership (false) — consumers may only synthesize fallback
43
+ * identities from the confirmed face.
24
44
  */
25
45
  export class ActivationGitRepositoryRequiredError extends Error {
26
46
  readonly code = "AK_ACTIVATION_GIT_REPOSITORY_REQUIRED" as const;
27
- constructor(detail: string, options?: { cause?: unknown }) {
47
+ readonly confirmedNonRepository: boolean;
48
+ constructor(
49
+ detail: string,
50
+ options?: { cause?: unknown; confirmedNonRepository?: boolean },
51
+ ) {
28
52
  super(
29
53
  `Workflow role activation requires a git repository cwd (git rev-parse --git-common-dir failed): ${detail || "unknown git error"}`,
30
54
  options?.cause === undefined ? undefined : { cause: options.cause },
31
55
  );
32
56
  this.name = "ActivationGitRepositoryRequiredError";
57
+ this.confirmedNonRepository = options?.confirmedNonRepository ?? false;
33
58
  }
34
59
  }
35
60
 
@@ -79,7 +104,10 @@ export function resolveBookKeyFromGit(cwd: string): string {
79
104
  : typeof err.message === "string"
80
105
  ? err.message
81
106
  : "";
82
- throw new ActivationGitRepositoryRequiredError(detail || "unknown git error", { cause: error });
107
+ throw new ActivationGitRepositoryRequiredError(detail || "unknown git error", {
108
+ cause: error,
109
+ confirmedNonRepository: isConfirmedNonRepositoryStderr(detail),
110
+ });
83
111
  }
84
112
  if (commonDir.length === 0) {
85
113
  throw new Error("git rev-parse --git-common-dir returned an empty path");
@@ -136,8 +136,10 @@ export function isEngineDetourFailure(result: {
136
136
  }
137
137
 
138
138
  /**
139
- * Diagnostic string for shared settlement / Terminal Error Artifact.
140
- * Prefer engine stderr 原样; whitespace-only/empty stderr is absent → stable fallback.
139
+ * Diagnostic string for shared settlement / Terminal Error Artifact (#395).
140
+ * Prefer engine stderr 原样; whitespace-only/empty stderr with stdout body must
141
+ * carry the child's last result/error row verbatim (e.g. a 529 API Error row) —
142
+ * never swallow the cause behind an exit code. Fully-empty output → stable fallback.
141
143
  */
142
144
  export function engineDetourFailureDiagnostic(result: {
143
145
  stderr: string;
@@ -146,7 +148,15 @@ export function engineDetourFailureDiagnostic(result: {
146
148
  }): string {
147
149
  if (result.stderr.trim().length > 0) return result.stderr;
148
150
  if (result.stdout.trim() === "") return ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC;
149
- return `engine detour exited with code ${result.code}`;
151
+ const rows = result.stdout.split("\n");
152
+ let lastRow = "";
153
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
154
+ if (rows[index]!.trim() !== "") {
155
+ lastRow = rows[index]!;
156
+ break;
157
+ }
158
+ }
159
+ return `engine detour exited with code ${result.code}: ${lastRow}`;
150
160
  }
151
161
 
152
162
  /** Non-empty trimmed engine name from process.env, else undefined. */
@@ -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
  }