@akagilnc/pi-workflow-roles 0.1.1941 → 0.1.2014

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.
Files changed (40) hide show
  1. package/dist/public-cli/main.js +2470 -88
  2. package/dist/reviewer-construction.js +33 -2
  3. package/dist/reviewer-dispatch.js +142 -9
  4. package/dist/reviewer-execution-ledger.js +5 -1
  5. package/dist/reviewer-pinned-git.js +139 -10
  6. package/extensions/role-runtime.ts +2 -1
  7. package/package.json +1 -1
  8. package/src/atomic-write.ts +26 -0
  9. package/src/collector-github.ts +94 -0
  10. package/src/ledger-session-read.ts +260 -0
  11. package/src/package-contracts/reviewer-output.ts +2 -0
  12. package/src/public-cli/cli.ts +24 -0
  13. package/src/public-cli/invocation.ts +352 -1
  14. package/src/public-cli/main.ts +21 -2
  15. package/src/public-cli/registry.ts +18 -1
  16. package/src/public-cli/reviewer-run.ts +13 -0
  17. package/src/public-cli/settlement.ts +13 -0
  18. package/src/public-cli/taishi-run.ts +235 -0
  19. package/src/reviewer-construction.ts +81 -3
  20. package/src/reviewer-dispatch.ts +189 -9
  21. package/src/reviewer-execution-ledger.ts +5 -1
  22. package/src/reviewer-pinned-git.ts +154 -11
  23. package/src/reviewer-role.ts +8 -1
  24. package/src/reviewer-settlement.ts +4 -0
  25. package/src/role-runtime.ts +21 -1
  26. package/src/run-terminal-artifacts.ts +231 -0
  27. package/src/taishi-cohort.ts +232 -0
  28. package/src/taishi-entry.ts +429 -0
  29. package/src/taishi-index.ts +269 -0
  30. package/src/taishi-ledger.ts +466 -0
  31. package/src/taishi-median.ts +15 -0
  32. package/src/taishi-metric-families/acceptance-success-rework.ts +346 -0
  33. package/src/taishi-metric-families/b2-frame-buckets-actions.ts +274 -0
  34. package/src/taishi-metric-families/leg-wall-clock.ts +90 -0
  35. package/src/taishi-metric-families/round-timeline.ts +201 -0
  36. package/src/taishi-metric-families.ts +36 -0
  37. package/src/taishi-metric-family.ts +41 -0
  38. package/src/taishi-model-groups.ts +198 -0
  39. package/src/taishi-page.ts +320 -0
  40. package/src/ticket-trajectory.ts +9 -62
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Canonical ledger session JSONL read primitives (shared owner).
3
+ * Consumers (ticket-trajectory, taishi, …) must import here — no second parse kernel.
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ export type LedgerSessionRow = Record<string, unknown>;
8
+
9
+ /**
10
+ * Loud JSONL failure that still retains rows parsed before the bad line.
11
+ * Callers that only need the throw keep catching Error; owners that must
12
+ * surface partial typed facts (e.g. first-frame timestamp) read prefixRows.
13
+ */
14
+ export class LedgerSessionJsonlError extends Error {
15
+ readonly path: string;
16
+ readonly line: number;
17
+ readonly prefixRows: readonly LedgerSessionRow[];
18
+
19
+ constructor(
20
+ message: string,
21
+ init: {
22
+ readonly path: string;
23
+ readonly line: number;
24
+ readonly prefixRows: readonly LedgerSessionRow[];
25
+ },
26
+ ) {
27
+ super(message);
28
+ this.name = "LedgerSessionJsonlError";
29
+ this.path = init.path;
30
+ this.line = init.line;
31
+ this.prefixRows = init.prefixRows;
32
+ }
33
+ }
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ /**
40
+ * Read session JSONL with honest live-tail semantics:
41
+ * a malformed line is tolerated only when it is an unfinished final
42
+ * fragment at EOF (no record terminator after it). Any malformed line
43
+ * completed by a line terminator must fail loudly with file and 1-based
44
+ * line context — even when no non-empty record follows — never silently
45
+ * under-count.
46
+ *
47
+ * Loud failures throw LedgerSessionJsonlError carrying prefixRows so the
48
+ * single parse kernel can still expose facts obtained before the bad line.
49
+ */
50
+ export async function readLedgerSessionJsonl(path: string): Promise<LedgerSessionRow[]> {
51
+ const text = await readFile(path, "utf8");
52
+ // split keeps a trailing empty segment iff text ends with "\n", so
53
+ // index < lines.length - 1 means this segment was terminated.
54
+ const lines = text.split("\n");
55
+ const rows: LedgerSessionRow[] = [];
56
+ for (let index = 0; index < lines.length; index += 1) {
57
+ const line = lines[index]!;
58
+ if (!line.trim()) continue;
59
+ let row: unknown;
60
+ try {
61
+ row = JSON.parse(line);
62
+ } catch (error) {
63
+ if (!(error instanceof SyntaxError)) throw error;
64
+ const completedByTerminator = index < lines.length - 1;
65
+ if (completedByTerminator) {
66
+ throw new LedgerSessionJsonlError(
67
+ `malformed JSONL record in ${path} at line ${index + 1}: ${error.message}`,
68
+ { path, line: index + 1, prefixRows: rows },
69
+ );
70
+ }
71
+ // unfinished fragment at EOF — keep prior complete rows
72
+ break;
73
+ }
74
+ // Syntactically complete line: must be a session object. Silent omission
75
+ // would under-count ledger evidence (failure honesty).
76
+ if (!isRecord(row)) {
77
+ const kind = row === null ? "null" : Array.isArray(row) ? "array" : typeof row;
78
+ throw new LedgerSessionJsonlError(
79
+ `complete non-object JSONL record in ${path} at line ${index + 1}: expected object, got ${kind}`,
80
+ { path, line: index + 1, prefixRows: rows },
81
+ );
82
+ }
83
+ rows.push(row);
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ /** First and last record timestamps in encounter order. */
89
+ export function extractSessionTimestampSpan(
90
+ rows: readonly LedgerSessionRow[],
91
+ ): { startedAt?: string; endedAt?: string } {
92
+ let startedAt: string | undefined;
93
+ let endedAt: string | undefined;
94
+ for (const row of rows) {
95
+ if (typeof row.timestamp !== "string" || !row.timestamp) continue;
96
+ if (startedAt === undefined) startedAt = row.timestamp;
97
+ endedAt = row.timestamp;
98
+ }
99
+ return {
100
+ ...(startedAt !== undefined ? { startedAt } : {}),
101
+ ...(endedAt !== undefined ? { endedAt } : {}),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Ordered-unique model ids from session frames (first-seen order).
107
+ * Sources (same faces ticket-trajectory already reads — single parse kernel):
108
+ * - `model_change.modelId`
109
+ * - assistant `message.model`
110
+ * Blank / non-string values are skipped. Does not invent a default model.
111
+ */
112
+ export function extractSessionModelSequence(
113
+ rows: readonly LedgerSessionRow[],
114
+ ): string[] {
115
+ const seen = new Set<string>();
116
+ const ordered: string[] = [];
117
+ const push = (raw: string): void => {
118
+ const model = raw.trim();
119
+ if (model === "" || seen.has(model)) return;
120
+ seen.add(model);
121
+ ordered.push(model);
122
+ };
123
+ for (const row of rows) {
124
+ if (row.type === "model_change" && typeof row.modelId === "string") {
125
+ push(row.modelId);
126
+ }
127
+ const message = isRecord(row.message) ? row.message : undefined;
128
+ if (message?.role === "assistant" && typeof message.model === "string") {
129
+ push(message.model);
130
+ }
131
+ }
132
+ return ordered;
133
+ }
134
+
135
+ /** First line of a bash `command` argument (sole owner of this summary). */
136
+ export function bashCommandFirstLine(command: string): string {
137
+ const match = /^[^\r\n]*/.exec(command);
138
+ return match?.[0] ?? "";
139
+ }
140
+
141
+ export type SessionToolInterval = {
142
+ readonly toolCallId: string;
143
+ readonly toolName: string;
144
+ readonly startedAt: string;
145
+ readonly endedAt?: string;
146
+ /**
147
+ * Bash-only first-line command summary from `arguments.command`.
148
+ * Omitted for non-bash tools and when the argument is absent/non-string.
149
+ * Full multi-line bodies are never retained on this typed fact face.
150
+ */
151
+ readonly command?: string;
152
+ };
153
+
154
+ /**
155
+ * Pair toolCall frames → toolResult frames by toolCallId.
156
+ * Throws when a tool-bearing frame is structurally unreadable for association
157
+ * (toolCall missing string id, toolResult missing string toolCallId).
158
+ * Unpaired open calls remain without endedAt — that is incomplete, not unreadable.
159
+ */
160
+ export function extractSessionToolIntervals(
161
+ rows: readonly LedgerSessionRow[],
162
+ ): SessionToolInterval[] {
163
+ type Open = {
164
+ toolCallId: string;
165
+ toolName: string;
166
+ startedAt: string;
167
+ endedAt?: string;
168
+ command?: string;
169
+ };
170
+ const order: Open[] = [];
171
+ const openById = new Map<string, Open>();
172
+
173
+ for (const row of rows) {
174
+ const rowTimestamp = typeof row.timestamp === "string" ? row.timestamp : undefined;
175
+ const message = isRecord(row.message) ? row.message : undefined;
176
+
177
+ if (message?.role === "assistant" && Array.isArray(message.content)) {
178
+ const callTimestamp =
179
+ typeof message.timestamp === "string" && message.timestamp
180
+ ? message.timestamp
181
+ : rowTimestamp;
182
+ for (const part of message.content) {
183
+ if (!isRecord(part) || part.type !== "toolCall") continue;
184
+ if (typeof part.id !== "string" || part.id.length === 0) {
185
+ throw new Error("toolCall frame missing string id");
186
+ }
187
+ if (typeof part.name !== "string" || part.name.length === 0) {
188
+ throw new Error(`toolCall ${part.id} missing string name`);
189
+ }
190
+ if (callTimestamp === undefined || callTimestamp.length === 0) {
191
+ throw new Error(`toolCall ${part.id} missing timestamp`);
192
+ }
193
+ if (openById.has(part.id)) {
194
+ throw new Error(`duplicate toolCall id ${part.id}`);
195
+ }
196
+ const args = isRecord(part.arguments) ? part.arguments : undefined;
197
+ // Ticket surface: only bash first-line summary is authorized here.
198
+ const command =
199
+ part.name === "bash" &&
200
+ args !== undefined &&
201
+ typeof args.command === "string"
202
+ ? bashCommandFirstLine(args.command)
203
+ : undefined;
204
+ const interval: Open = {
205
+ toolCallId: part.id,
206
+ toolName: part.name,
207
+ startedAt: callTimestamp,
208
+ ...(command !== undefined ? { command } : {}),
209
+ };
210
+ order.push(interval);
211
+ openById.set(part.id, interval);
212
+ }
213
+ }
214
+
215
+ if (message?.role === "toolResult") {
216
+ if (typeof message.toolCallId !== "string" || message.toolCallId.length === 0) {
217
+ throw new Error("toolResult frame missing string toolCallId");
218
+ }
219
+ const resultTimestamp =
220
+ typeof message.timestamp === "string" && message.timestamp
221
+ ? message.timestamp
222
+ : rowTimestamp;
223
+ if (resultTimestamp === undefined || resultTimestamp.length === 0) {
224
+ throw new Error(`toolResult ${message.toolCallId} missing timestamp`);
225
+ }
226
+ const open = openById.get(message.toolCallId);
227
+ if (open === undefined) {
228
+ // Result without a prior call is still associable as a closed interval
229
+ // once a name is known; keep structural readability without inventing a call.
230
+ const toolName =
231
+ typeof message.toolName === "string" && message.toolName.length > 0
232
+ ? message.toolName
233
+ : "unknown";
234
+ order.push({
235
+ toolCallId: message.toolCallId,
236
+ toolName,
237
+ startedAt: resultTimestamp,
238
+ endedAt: resultTimestamp,
239
+ });
240
+ continue;
241
+ }
242
+ if (open.endedAt !== undefined) {
243
+ throw new Error(`duplicate toolResult for toolCallId ${message.toolCallId}`);
244
+ }
245
+ open.endedAt = resultTimestamp;
246
+ }
247
+ }
248
+
249
+ return order.map((interval) => {
250
+ const base = {
251
+ toolCallId: interval.toolCallId,
252
+ toolName: interval.toolName,
253
+ startedAt: interval.startedAt,
254
+ ...(interval.command !== undefined ? { command: interval.command } : {}),
255
+ };
256
+ return interval.endedAt === undefined
257
+ ? base
258
+ : { ...base, endedAt: interval.endedAt };
259
+ });
260
+ }
@@ -35,6 +35,8 @@ export type RuntimeReviewerReceiptV2 = Readonly<{
35
35
  acceptedBatch?: RuntimeReviewerAcceptedBatch;
36
36
  /** Present on accepted batches: launched Spec child, or skipped after confirmed missing Spec. */
37
37
  specDisposition?: RuntimeReviewerSpecDisposition;
38
+ /** Self-fetch bytes + source annotation when Spec primary path produced material (#343). */
39
+ specFetchedMaterial?: ReviewerAcceptedEvidence["specFetchedMaterial"];
38
40
  reports: Readonly<Partial<Record<"standards" | "spec", VerbatimChildReport>>>;
39
41
  outcomes: Readonly<Partial<Record<"standards" | "spec", RuntimeReviewerOutcome>>>;
40
42
  identities: Readonly<{
@@ -32,6 +32,7 @@ import {
32
32
  parseJudgeArgv,
33
33
  parseMergerArgv,
34
34
  parseReviewerArgv,
35
+ parseTaishiArgv,
35
36
  } from "./invocation.ts";
36
37
  import { runPublicCoder, runPublicCoderResume } from "./coder-run.ts";
37
38
  import { runPublicCollector } from "./collector-run.ts";
@@ -40,6 +41,7 @@ import { runPublicFixer, runPublicFixerResume } from "./fixer-run.ts";
40
41
  import { runPublicJudge, runPublicResume } from "./judge-run.ts";
41
42
  import { runPublicMerger, runPublicMergerResume } from "./merger-run.ts";
42
43
  import { runPublicReviewer, runPublicReviewerResume } from "./reviewer-run.ts";
44
+ import { runPublicTaishi } from "./taishi-run.ts";
43
45
  import { peekRoleRunRole } from "./run-lifecycle.ts";
44
46
  import {
45
47
  INTERNAL_ROLE_ENTRYPOINT_RELATIVE,
@@ -73,6 +75,8 @@ export const PUBLIC_ROLE_ARGV = {
73
75
  doctor: { parse: parseDoctorArgv },
74
76
  merger: { parse: parseMergerArgv },
75
77
  reviewer: { parse: parseReviewerArgv },
78
+ /** Deterministic analysis seat (#336) — argv parse only; no LLM admission. */
79
+ taishi: { parse: parseTaishiArgv },
76
80
  } as const;
77
81
 
78
82
  type TakenPublicGlobalFlag =
@@ -314,6 +318,12 @@ function renderHelp(): string {
314
318
  lines.push(` ${cap.name} — ${phaseText}`);
315
319
  }
316
320
  }
321
+ lines.push("", "Deterministic commands:");
322
+ for (const cap of doc.capabilities) {
323
+ if (cap.kind === "deterministic") {
324
+ lines.push(` ${cap.name}`);
325
+ }
326
+ }
317
327
  lines.push(
318
328
  "",
319
329
  "Global options: --model provider/model --thinking level",
@@ -430,6 +440,8 @@ export async function runAkRole(
430
440
  }
431
441
  if (match.kind === "support") {
432
442
  io.stdout(`command\t${match.name}\tkind\tsupport\n`);
443
+ } else if (match.kind === "deterministic") {
444
+ io.stdout(`command\t${match.name}\tkind\tdeterministic\n`);
433
445
  } else {
434
446
  io.stdout(
435
447
  `command\t${match.name}\tkind\trole\tphases\t${match.phases
@@ -949,6 +961,18 @@ export async function runAkRole(
949
961
  };
950
962
  }
951
963
 
964
+ // Taishi public run path: deterministic analysis seat (#336 issue / #337 sweep).
965
+ // Not an LLM PUBLIC_CALLABLE_ROLE — registered only on PUBLIC_ROLE_ARGV (#176).
966
+ if (parsed.command === "taishi") {
967
+ const result = await runPublicTaishi(
968
+ parsed.args,
969
+ { home },
970
+ io,
971
+ PUBLIC_ROLE_ARGV.taishi.parse,
972
+ );
973
+ return { exitCode: result.exitCode };
974
+ }
975
+
952
976
  // #115: every PUBLIC_CALLABLE_ROLE has a completed handler above. Unknown
953
977
  // tokens (including misspelled role names) are structural rejects.
954
978
  throw new CliUsageError(`unknown command: ${parsed.command}`);
@@ -334,6 +334,50 @@ export type ParseMergerArgvResult = {
334
334
  project?: string;
335
335
  };
336
336
 
337
+ /**
338
+ * #336/#337/#338 taishi public argv — four faces on one registration seam.
339
+ * - issue (default): ticket N and/or project-root P
340
+ * - sweep (#337): optional positional `sweep` and/or --attach paths;
341
+ * sweep payload rides exactly one typed JSON attachment (not argv/stdin)
342
+ * - cohort: two labeled issue-number groups
343
+ * - model-groups: one or more project-root scope keys
344
+ */
345
+ export type ParseTaishiIssueArgv = {
346
+ readonly query: "issue";
347
+ /** Caller ticket / issue number face (#176 numbering space). */
348
+ readonly ticket?: number;
349
+ /** Direct projectRoot mechanical key (ADR 0068). */
350
+ readonly projectRoot?: string;
351
+ };
352
+
353
+ export type ParseTaishiSweepArgv = {
354
+ readonly query: "sweep";
355
+ /**
356
+ * Public CLI attachment paths (--attach). Sweep mode only (#337).
357
+ * Cardinality validated on the sweep run path (exactly one).
358
+ */
359
+ readonly attachmentPaths: readonly string[];
360
+ };
361
+
362
+ export type ParseTaishiCohortArgv = {
363
+ readonly query: "cohort";
364
+ readonly groups: readonly [
365
+ { readonly groupLabel: string; readonly issues: readonly number[] },
366
+ { readonly groupLabel: string; readonly issues: readonly number[] },
367
+ ];
368
+ };
369
+
370
+ export type ParseTaishiModelGroupsArgv = {
371
+ readonly query: "model-groups";
372
+ readonly projectRoots: readonly string[];
373
+ };
374
+
375
+ export type ParseTaishiArgvResult =
376
+ | ParseTaishiIssueArgv
377
+ | ParseTaishiSweepArgv
378
+ | ParseTaishiCohortArgv
379
+ | ParseTaishiModelGroupsArgv;
380
+
337
381
  /** Honest activation-class failure while deriving the active-merge envelope. */
338
382
  export class MergerEnvelopeDerivationError extends Error {
339
383
  readonly code = "merger-envelope-derivation" as const;
@@ -347,7 +391,13 @@ export class MergerEnvelopeDerivationError extends Error {
347
391
 
348
392
  /** Reject missing/blank path values so empty overrides cannot silently degrade. */
349
393
  function requireOptionPath(
350
- flag: "--project" | "--attach" | "--prerequisites" | "--request-manifest" | "--base",
394
+ flag:
395
+ | "--project"
396
+ | "--attach"
397
+ | "--prerequisites"
398
+ | "--request-manifest"
399
+ | "--base"
400
+ | "--project-root",
351
401
  value: string | undefined,
352
402
  ): string {
353
403
  if (value === undefined || value.trim() === "") {
@@ -2041,3 +2091,304 @@ export function buildMergerTransportPrompt(
2041
2091
  }
2042
2092
  return lines.join("\n");
2043
2093
  }
2094
+
2095
+ const TAISHI_TICKET_NUMBER_PATTERN = /^[1-9]\d*$/;
2096
+
2097
+ /**
2098
+ * Parse a positive ticket / issue number for public taishi admission.
2099
+ * Leading zeros and non-integers are structural rejects (same face as #176).
2100
+ * `flag` names the actual argv face in diagnostics (cohort group lists reuse this).
2101
+ */
2102
+ export function parseTaishiTicketNumber(
2103
+ raw: string,
2104
+ flag: string = "--ticket",
2105
+ ): number {
2106
+ const trimmed = raw.trim();
2107
+ if (!TAISHI_TICKET_NUMBER_PATTERN.test(trimmed)) {
2108
+ throw new CliUsageError(
2109
+ `taishi ${flag} must be a positive integer, got ${raw}`,
2110
+ );
2111
+ }
2112
+ const value = Number(trimmed);
2113
+ // Digit-only strings beyond MAX_SAFE_INTEGER round or become Infinity — reject.
2114
+ if (!Number.isSafeInteger(value) || value < 1) {
2115
+ throw new CliUsageError(
2116
+ `taishi ${flag} must be a positive integer, got ${raw}`,
2117
+ );
2118
+ }
2119
+ return value;
2120
+ }
2121
+
2122
+ function parseTaishiIssueNumberList(raw: string, flag: string): number[] {
2123
+ const trimmed = raw.trim();
2124
+ if (trimmed === "") {
2125
+ throw new CliUsageError(`${flag} requires a comma-separated positive integer list`);
2126
+ }
2127
+ const parts = trimmed.split(",").map((part) => part.trim());
2128
+ if (parts.some((part) => part === "")) {
2129
+ throw new CliUsageError(`${flag} requires a comma-separated positive integer list`);
2130
+ }
2131
+ // Same numeric rule as --ticket; diagnostic names the actual group flag.
2132
+ return parts.map((part) => parseTaishiTicketNumber(part, flag));
2133
+ }
2134
+
2135
+ function requireOptionValue(
2136
+ flag: string,
2137
+ value: string | undefined,
2138
+ what: string,
2139
+ ): string {
2140
+ if (value === undefined || value.trim() === "") {
2141
+ throw new CliUsageError(`${flag} requires ${what}`);
2142
+ }
2143
+ return value;
2144
+ }
2145
+
2146
+ /**
2147
+ * Parse taishi-specific argv after the `taishi` token (#336/#337/#338).
2148
+ * Issue: at least one of --ticket / --project-root.
2149
+ * Sweep: positional `sweep` and/or --attach; payload is the attachment body only.
2150
+ * Cohort / model-groups: explicit query flags with their own required faces.
2151
+ * Faces are mutually exclusive.
2152
+ */
2153
+ export function parseTaishiArgv(args: readonly string[]): ParseTaishiArgvResult {
2154
+ let query: "issue" | "cohort" | "model-groups" = "issue";
2155
+ let ticketRaw: string | undefined;
2156
+ const projectRoots: string[] = [];
2157
+ let groupALabel: string | undefined;
2158
+ let groupAIssuesRaw: string | undefined;
2159
+ let groupBLabel: string | undefined;
2160
+ let groupBIssuesRaw: string | undefined;
2161
+ let sweepToken = false;
2162
+ const attachmentPaths: string[] = [];
2163
+ const tokens = [...args];
2164
+
2165
+ while (tokens.length > 0) {
2166
+ const token = tokens.shift()!;
2167
+ if (token === "--") {
2168
+ if (tokens.length > 0) {
2169
+ throw new CliUsageError(`unexpected taishi argument: ${tokens[0]}`);
2170
+ }
2171
+ break;
2172
+ }
2173
+ if (token === "--cohort") {
2174
+ if (query !== "issue") {
2175
+ throw new CliUsageError("taishi accepts only one of --cohort / --model-groups");
2176
+ }
2177
+ query = "cohort";
2178
+ continue;
2179
+ }
2180
+ if (token === "--model-groups") {
2181
+ if (query !== "issue") {
2182
+ throw new CliUsageError("taishi accepts only one of --cohort / --model-groups");
2183
+ }
2184
+ query = "model-groups";
2185
+ continue;
2186
+ }
2187
+ if (token === "--ticket") {
2188
+ const value = tokens.shift();
2189
+ if (value === undefined || value.trim() === "") {
2190
+ throw new CliUsageError("taishi --ticket requires a positive integer");
2191
+ }
2192
+ ticketRaw = value;
2193
+ continue;
2194
+ }
2195
+ if (token.startsWith("--ticket=")) {
2196
+ ticketRaw = token.slice("--ticket=".length);
2197
+ if (ticketRaw.trim() === "") {
2198
+ throw new CliUsageError("taishi --ticket requires a positive integer");
2199
+ }
2200
+ continue;
2201
+ }
2202
+ if (token === "--project-root") {
2203
+ projectRoots.push(requireOptionPath("--project-root", tokens.shift()));
2204
+ continue;
2205
+ }
2206
+ if (token.startsWith("--project-root=")) {
2207
+ projectRoots.push(
2208
+ requireOptionPath("--project-root", token.slice("--project-root=".length)),
2209
+ );
2210
+ continue;
2211
+ }
2212
+ if (token === "--group-a-label") {
2213
+ groupALabel = requireOptionValue("--group-a-label", tokens.shift(), "a label");
2214
+ continue;
2215
+ }
2216
+ if (token.startsWith("--group-a-label=")) {
2217
+ groupALabel = requireOptionValue(
2218
+ "--group-a-label",
2219
+ token.slice("--group-a-label=".length),
2220
+ "a label",
2221
+ );
2222
+ continue;
2223
+ }
2224
+ if (token === "--group-a-issues") {
2225
+ groupAIssuesRaw = requireOptionValue(
2226
+ "--group-a-issues",
2227
+ tokens.shift(),
2228
+ "a comma-separated positive integer list",
2229
+ );
2230
+ continue;
2231
+ }
2232
+ if (token.startsWith("--group-a-issues=")) {
2233
+ groupAIssuesRaw = requireOptionValue(
2234
+ "--group-a-issues",
2235
+ token.slice("--group-a-issues=".length),
2236
+ "a comma-separated positive integer list",
2237
+ );
2238
+ continue;
2239
+ }
2240
+ if (token === "--group-b-label") {
2241
+ groupBLabel = requireOptionValue("--group-b-label", tokens.shift(), "a label");
2242
+ continue;
2243
+ }
2244
+ if (token.startsWith("--group-b-label=")) {
2245
+ groupBLabel = requireOptionValue(
2246
+ "--group-b-label",
2247
+ token.slice("--group-b-label=".length),
2248
+ "a label",
2249
+ );
2250
+ continue;
2251
+ }
2252
+ if (token === "--group-b-issues") {
2253
+ groupBIssuesRaw = requireOptionValue(
2254
+ "--group-b-issues",
2255
+ tokens.shift(),
2256
+ "a comma-separated positive integer list",
2257
+ );
2258
+ continue;
2259
+ }
2260
+ if (token.startsWith("--group-b-issues=")) {
2261
+ groupBIssuesRaw = requireOptionValue(
2262
+ "--group-b-issues",
2263
+ token.slice("--group-b-issues=".length),
2264
+ "a comma-separated positive integer list",
2265
+ );
2266
+ continue;
2267
+ }
2268
+ if (token === "--attach") {
2269
+ attachmentPaths.push(requireOptionPath("--attach", tokens.shift()));
2270
+ continue;
2271
+ }
2272
+ if (token.startsWith("--attach=")) {
2273
+ attachmentPaths.push(
2274
+ requireOptionPath("--attach", token.slice("--attach=".length)),
2275
+ );
2276
+ continue;
2277
+ }
2278
+ if (token.startsWith("-") && token !== "-") {
2279
+ throw new CliUsageError(`unknown taishi option: ${token}`);
2280
+ }
2281
+ // Optional sweep mode token (like coder plan/apply); only once, no other positionals.
2282
+ if (token === "sweep") {
2283
+ if (sweepToken) {
2284
+ throw new CliUsageError("unexpected taishi argument: sweep");
2285
+ }
2286
+ sweepToken = true;
2287
+ continue;
2288
+ }
2289
+ throw new CliUsageError(`unexpected taishi argument: ${token}`);
2290
+ }
2291
+
2292
+ const hasSweepFace = sweepToken || attachmentPaths.length > 0;
2293
+ const hasCohortFlags =
2294
+ groupALabel !== undefined
2295
+ || groupAIssuesRaw !== undefined
2296
+ || groupBLabel !== undefined
2297
+ || groupBIssuesRaw !== undefined;
2298
+
2299
+ if (query === "cohort") {
2300
+ if (
2301
+ groupALabel === undefined
2302
+ || groupAIssuesRaw === undefined
2303
+ || groupBLabel === undefined
2304
+ || groupBIssuesRaw === undefined
2305
+ ) {
2306
+ throw new CliUsageError(
2307
+ "usage: ak-role taishi --cohort --group-a-label <L> --group-a-issues <N[,N...]> --group-b-label <L> --group-b-issues <N[,N...]>",
2308
+ );
2309
+ }
2310
+ if (ticketRaw !== undefined || projectRoots.length > 0) {
2311
+ throw new CliUsageError(
2312
+ "taishi --cohort does not accept --ticket or --project-root",
2313
+ );
2314
+ }
2315
+ if (hasSweepFace) {
2316
+ throw new CliUsageError(
2317
+ "taishi --cohort does not accept sweep --attach",
2318
+ );
2319
+ }
2320
+ return {
2321
+ query: "cohort",
2322
+ groups: [
2323
+ {
2324
+ groupLabel: groupALabel,
2325
+ issues: parseTaishiIssueNumberList(groupAIssuesRaw, "--group-a-issues"),
2326
+ },
2327
+ {
2328
+ groupLabel: groupBLabel,
2329
+ issues: parseTaishiIssueNumberList(groupBIssuesRaw, "--group-b-issues"),
2330
+ },
2331
+ ],
2332
+ };
2333
+ }
2334
+
2335
+ if (query === "model-groups") {
2336
+ if (projectRoots.length === 0) {
2337
+ throw new CliUsageError(
2338
+ "usage: ak-role taishi --model-groups --project-root <P> [--project-root <P> ...]",
2339
+ );
2340
+ }
2341
+ if (ticketRaw !== undefined) {
2342
+ throw new CliUsageError("taishi --model-groups does not accept --ticket");
2343
+ }
2344
+ if (hasCohortFlags) {
2345
+ throw new CliUsageError("taishi --model-groups does not accept cohort group flags");
2346
+ }
2347
+ if (hasSweepFace) {
2348
+ throw new CliUsageError(
2349
+ "taishi --model-groups does not accept sweep --attach",
2350
+ );
2351
+ }
2352
+ return {
2353
+ query: "model-groups",
2354
+ projectRoots,
2355
+ };
2356
+ }
2357
+
2358
+ // default issue or sweep (#336/#337 faces)
2359
+ if (hasCohortFlags) {
2360
+ throw new CliUsageError("taishi issue query does not accept cohort group flags");
2361
+ }
2362
+
2363
+ if (hasSweepFace) {
2364
+ if (ticketRaw !== undefined || projectRoots.length > 0) {
2365
+ throw new CliUsageError(
2366
+ "taishi sweep --attach cannot combine with --ticket or --project-root",
2367
+ );
2368
+ }
2369
+ return {
2370
+ query: "sweep",
2371
+ attachmentPaths,
2372
+ };
2373
+ }
2374
+
2375
+ if (projectRoots.length > 1) {
2376
+ throw new CliUsageError(
2377
+ "taishi issue query accepts at most one --project-root (use --model-groups for many)",
2378
+ );
2379
+ }
2380
+ const projectRoot = projectRoots[0];
2381
+ if (ticketRaw === undefined && projectRoot === undefined) {
2382
+ throw new CliUsageError(
2383
+ "usage: ak-role taishi ((--ticket <N> | --project-root <P>) | [sweep] --attach <sweep.json> | --cohort ... | --model-groups ...)",
2384
+ );
2385
+ }
2386
+
2387
+ return {
2388
+ query: "issue",
2389
+ ...(ticketRaw === undefined
2390
+ ? {}
2391
+ : { ticket: parseTaishiTicketNumber(ticketRaw) }),
2392
+ ...(projectRoot === undefined ? {} : { projectRoot }),
2393
+ };
2394
+ }