@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/activation-ledger-git.js +12 -2
- package/dist/engine-detour.js +13 -3
- package/dist/public-cli/main.js +194 -50
- package/package.json +1 -1
- package/src/activation-ledger-git.ts +33 -5
- package/src/engine-detour.ts +13 -3
- 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
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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", {
|
|
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");
|
package/src/engine-detour.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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. */
|
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
|
}
|