@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/activation-ledger-git.js +12 -2
- package/dist/public-cli/main.js +194 -50
- package/package.json +1 -1
- package/src/activation-ledger-git.ts +33 -5
- package/src/public-cli/cli.ts +6 -7
- package/src/public-cli/invocation.ts +115 -11
- package/src/public-cli/option-definitions.ts +10 -9
- package/src/public-cli/taishi-run.ts +25 -1
- package/src/taishi-book-key.ts +66 -0
- package/src/taishi-cohort.ts +19 -7
- package/src/taishi-entry.ts +26 -21
- package/src/taishi-index.ts +53 -8
- package/src/taishi-ledger.ts +13 -3
package/src/public-cli/cli.ts
CHANGED
|
@@ -436,9 +436,10 @@ function appendUsageAndExamples(
|
|
|
436
436
|
|
|
437
437
|
function renderHelp(): string {
|
|
438
438
|
const doc = helpDocument();
|
|
439
|
-
|
|
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
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
{
|
|
441
|
-
|
|
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
|
-
|
|
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(
|
|
2297
|
+
throw new CliUsageError(
|
|
2298
|
+
`${flag} requires a comma-separated list of N or book:N`,
|
|
2299
|
+
);
|
|
2205
2300
|
}
|
|
2206
|
-
const parts = trimmed
|
|
2301
|
+
const parts = splitTaishiCohortIssueListParts(trimmed).map((part) =>
|
|
2302
|
+
part.trim(),
|
|
2303
|
+
);
|
|
2207
2304
|
if (parts.some((part) => part === "")) {
|
|
2208
|
-
throw new CliUsageError(
|
|
2305
|
+
throw new CliUsageError(
|
|
2306
|
+
`${flag} requires a comma-separated list of N or book:N`,
|
|
2307
|
+
);
|
|
2209
2308
|
}
|
|
2210
|
-
|
|
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
|
|
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:
|
|
2441
|
+
issues: parseTaishiCohortIssueTokenList(
|
|
2442
|
+
groupAIssuesRaw,
|
|
2443
|
+
"--group-a-issues",
|
|
2444
|
+
),
|
|
2344
2445
|
},
|
|
2345
2446
|
{
|
|
2346
2447
|
groupLabel: groupBLabel,
|
|
2347
|
-
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[
|
|
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[
|
|
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
|
|
654
|
-
zh: "cohort A
|
|
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[
|
|
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
|
|
686
|
-
zh: "cohort B
|
|
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[
|
|
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.
|
|
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:
|
|
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
|
+
}
|
package/src/taishi-cohort.ts
CHANGED
|
@@ -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
|
-
/**
|
|
46
|
-
readonly issues: readonly
|
|
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
|
|
163
|
-
const
|
|
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
|
-
|
|
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.
|
package/src/taishi-entry.ts
CHANGED
|
@@ -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
|
|
237
|
-
*
|
|
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
|
-
|
|
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.
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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
|
|
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
|
}
|
package/src/taishi-index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
/**
|