@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.
@@ -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
  /**
@@ -401,7 +401,9 @@ async function classifyScopedRun(input: {
401
401
  /**
402
402
  * Scan ledger home books/<book>/runs for runs in the issue scope.
403
403
  * #399: scope = book × optional ticket.
404
- * - bookKey set → that book only; whole-book when no ticket; ticket filters alone.
404
+ * - bookKey set → that book only; whole-book when no ticket and no projectRoot;
405
+ * ticket filters alone; bookKey + projectRoot (cohort ensure, #412) narrows
406
+ * to that root inside the book.
405
407
  * - projectRoot without bookKey (sweep/legacy): git-resolved → whole that book;
406
408
  * non-git → path-narrow across books (fixture isolation).
407
409
  * Damaged required sources become unreadable exclusions.
@@ -429,8 +431,16 @@ export async function scanTaishiIssueRuns(input: {
429
431
 
430
432
  if (input.bookKey !== undefined && input.bookKey.trim() !== "") {
431
433
  bookNames = [input.bookKey];
432
- // CLI book scope: whole book unless ticket filters. Never path-narrow.
433
- wholeBook = true;
434
+ if (input.projectRoot !== undefined) {
435
+ // Cohort ensure conjunction (#412): the index join already selected the
436
+ // row — a cache-miss recompute must stay inside this root of this book,
437
+ // never inhale sibling roots' runs into one issue page.
438
+ wholeBook = false;
439
+ scopeRootIdentity = physicalPathIdentity(input.projectRoot);
440
+ } else {
441
+ // CLI book scope: whole book unless ticket filters. Never path-narrow.
442
+ wholeBook = true;
443
+ }
434
444
  } else if (input.projectRoot !== undefined) {
435
445
  const resolved = tryResolveBookKeyFromProjectRoot(input.projectRoot);
436
446
  if (resolved !== undefined) {