@akagilnc/pi-workflow-roles 0.1.1941 → 0.1.2004

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.
@@ -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
+ }
@@ -1,11 +1,30 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { dirname, join } from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
 
4
5
  import { ensureHostPiRuntimeResolvable } from "./host-pi-runtime.ts";
5
6
 
6
7
  const here = dirname(fileURLToPath(import.meta.url));
7
- // dist/public-cli/main.js → package root is ../..
8
- const packageRoot = join(here, "..", "..");
8
+
9
+ /**
10
+ * Resolve install package root from the public bin location.
11
+ *
12
+ * Shipped layout is `<packageRoot>/dist/public-cli/main.js` → two levels up when
13
+ * that ancestor owns package.json. A relocated single-file bundle (no package
14
+ * tree beside the bin) must NOT keep climbing: `join("/tmp/<bin>","..","..")`
15
+ * is `"/"` on Linux CI, and host-pi linking then does
16
+ * `mkdir('/node_modules/@earendil-works')` → EACCES. Fall back to the bin
17
+ * directory so links stay on the ESM ancestor walk and remain writable.
18
+ */
19
+ function resolvePackageRoot(binDir: string): string {
20
+ const canonical = join(binDir, "..", "..");
21
+ if (existsSync(join(canonical, "package.json"))) {
22
+ return canonical;
23
+ }
24
+ return binDir;
25
+ }
26
+
27
+ const packageRoot = resolvePackageRoot(here);
9
28
 
10
29
  // The host-provided runtime must be resolvable before the CLI module graph loads it.
11
30
  ensureHostPiRuntimeResolvable(packageRoot);
@@ -97,6 +97,12 @@ export function publicStartupCandidates(
97
97
  return STARTUP_CANDIDATES[seat];
98
98
  }
99
99
 
100
+ /** Deterministic public commands — discoverable, never LLM-configurable seats. */
101
+ export const PUBLIC_DETERMINISTIC_COMMANDS = ["taishi"] as const;
102
+
103
+ export type PublicDeterministicCommand =
104
+ (typeof PUBLIC_DETERMINISTIC_COMMANDS)[number];
105
+
100
106
  export type HelpCapability =
101
107
  | {
102
108
  kind: "support";
@@ -107,11 +113,16 @@ export type HelpCapability =
107
113
  name: PublicCallableRole;
108
114
  phases: readonly (string | null)[];
109
115
  defaultPhase: string | null;
116
+ }
117
+ | {
118
+ kind: "deterministic";
119
+ name: PublicDeterministicCommand;
110
120
  };
111
121
 
112
122
  /**
113
123
  * Typed help surface. Presentation formats these facts; tests must not assert
114
124
  * exact help prose or layout (锚定宪法 / ADR 0016 / #105 AC).
125
+ * taishi is a deterministic analysis command on the public CLI — not an LLM seat.
115
126
  */
116
127
  export function listHelpCapabilities(): readonly HelpCapability[] {
117
128
  const support: HelpCapability[] = PUBLIC_CLI_SUPPORT_COMMANDS.map((name) => ({
@@ -133,7 +144,13 @@ export function listHelpCapabilities(): readonly HelpCapability[] {
133
144
  defaultPhase,
134
145
  };
135
146
  });
136
- return [...support, ...roles];
147
+ const deterministic: HelpCapability[] = PUBLIC_DETERMINISTIC_COMMANDS.map(
148
+ (name) => ({
149
+ kind: "deterministic" as const,
150
+ name,
151
+ }),
152
+ );
153
+ return [...support, ...roles, ...deterministic];
137
154
  }
138
155
 
139
156
  export function isPublicCallableRole(value: string): value is PublicCallableRole {
@@ -242,6 +242,15 @@ export function presentStructuralRejection(
242
242
  io.stderr(formatCliDiagnostic(error.message));
243
243
  }
244
244
 
245
+ /** ControlledFailure face without admitted-run Terminal (stdout body + stderr line). */
246
+ export function presentControlledFailure(
247
+ failure: ControlledFailure,
248
+ io: { stdout: (text: string) => void; stderr: (text: string) => void },
249
+ ): void {
250
+ io.stdout(`${JSON.stringify(failure, null, 2)}\n`);
251
+ io.stderr(formatFailureStderrDiagnostic(failure));
252
+ }
253
+
245
254
  /** Session readiness after an admitted activation attempt. */
246
255
  export type SessionReadiness =
247
256
  | { readonly state: "missing" }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Public taishi adapter (#336/#337/#338): argv → typed query → runTaishi family.
3
+ * Deterministic analysis seat — no Pi runner, no admission lease.
4
+ * Reuses existing CLI failure envelope (CliUsageError + structural reject +
5
+ * ControlledFailure).
6
+ * Index read reuses readTaishiLibraryIndexPage / findTaishiLibraryIndexRow.
7
+ * #337 sweep: exactly one typed JSON attachment → TaishiSweepModeInput → #329 kernel.
8
+ * #338: three query faces; sync compute-if-missing; whole-compute failure →
9
+ * ControlledFailure terminal (code/projectRoot/issueNumber/real cause).
10
+ * "Unobtrusive" binds #337 merge auto-trigger only, not this user-initiated query.
11
+ */
12
+ import { readFile } from "node:fs/promises";
13
+ import { isAbsolute, resolve } from "node:path";
14
+ import { Value } from "typebox/value";
15
+
16
+ import {
17
+ errnoCode,
18
+ physicalPathIdentity,
19
+ resolveActivationLedgerHome,
20
+ } from "../activation-ledger-topology.ts";
21
+ import { exactUtf8 } from "../exact-utf8.ts";
22
+ import {
23
+ findTaishiLibraryIndexRow,
24
+ readTaishiLibraryIndexPage,
25
+ } from "../taishi-index.ts";
26
+ import {
27
+ readOrComputeTaishiIssuePage,
28
+ runTaishi,
29
+ taishiSweepModeInputSchema,
30
+ TaishiIssueComputeError,
31
+ type TaishiIssueModeInput,
32
+ type TaishiSweepModeInput,
33
+ } from "../taishi-entry.ts";
34
+ import { CliUsageError } from "./cli-errors.ts";
35
+ import type { CliIo } from "./cli-io.ts";
36
+ import type {
37
+ ParseTaishiArgvResult,
38
+ ParseTaishiIssueArgv,
39
+ } from "./invocation.ts";
40
+ import { presentControlledFailure, presentStructuralRejection } from "./settlement.ts";
41
+
42
+ export type TaishiRunEnv = {
43
+ readonly home: string;
44
+ };
45
+
46
+ /**
47
+ * Build the sole library issue-mode input from public argv faces.
48
+ * - ticket N → issueNumber = ticketNumber = N; projectRoot from index (or project-root fallback).
49
+ * - project-root P → direct mechanical key.
50
+ * - both + index hit → index projectRoot wins; when direct root differs, retain it as
51
+ * conflictingProjectRoot so runTaishi records the C4 dual-param conflict fact on the page.
52
+ * - both + index miss → project-root fallback.
53
+ * Bare both-missing is owned by parseTaishiArgv — no second reject here.
54
+ */
55
+ export async function buildTaishiIssueModeInputFromPublicArgv(
56
+ parsed: ParseTaishiIssueArgv,
57
+ ledgerHome: string,
58
+ ): Promise<TaishiIssueModeInput> {
59
+ const ticket = parsed.ticket;
60
+ const directRoot = parsed.projectRoot;
61
+
62
+ if (ticket === undefined) {
63
+ return {
64
+ mode: "issue",
65
+ projectRoot: directRoot!,
66
+ };
67
+ }
68
+
69
+ // ticket N = issueNumber (no conversion); also the C4 typed ticket face.
70
+ const index = await readTaishiLibraryIndexPage(ledgerHome);
71
+ const row = findTaishiLibraryIndexRow(index, ticket);
72
+
73
+ let projectRoot: string;
74
+ if (row !== undefined) {
75
+ // Ticket-resolved index projectRoot wins over any concurrent --project-root.
76
+ projectRoot = row.projectRoot;
77
+ } else if (directRoot !== undefined) {
78
+ // Index miss with project-root fallback (ticket faces still set for C4).
79
+ projectRoot = directRoot;
80
+ } else {
81
+ throw new CliUsageError(
82
+ `taishi library index has no row for ticket ${ticket}`,
83
+ );
84
+ }
85
+
86
+ // Dual-param conflict: index root won, but caller also supplied a distinct --project-root.
87
+ // Carry the losing root so the metrics page records the call-face conflict fact.
88
+ const dualParamConflict =
89
+ row !== undefined
90
+ && directRoot !== undefined
91
+ && physicalPathIdentity(directRoot) !== physicalPathIdentity(projectRoot);
92
+
93
+ return {
94
+ mode: "issue",
95
+ projectRoot,
96
+ ticketNumber: ticket,
97
+ issueNumber: ticket,
98
+ ...(dualParamConflict ? { conflictingProjectRoot: directRoot } : {}),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Attachment JSON → library TaishiSweepModeInput via the sole schema (#337).
104
+ * No parallel hand shape; rejects missing/extra/wrong-type fields only.
105
+ */
106
+ export function parseTaishiSweepModeInputFromJsonValue(
107
+ value: unknown,
108
+ ): TaishiSweepModeInput {
109
+ if (!Value.Check(taishiSweepModeInputSchema, value)) {
110
+ throw new CliUsageError(
111
+ "taishi sweep attachment must match TaishiSweepModeInput",
112
+ );
113
+ }
114
+ return value;
115
+ }
116
+
117
+ /**
118
+ * Load sweep typed input from exactly one public CLI attachment path.
119
+ * Rejects: wrong cardinality, unreadable path, non-UTF-8, JSON fail, field contract.
120
+ * Zero ledger writes — read-only path resolve + parse.
121
+ */
122
+ export async function buildTaishiSweepModeInputFromAttachmentPaths(
123
+ attachmentPaths: readonly string[],
124
+ ): Promise<TaishiSweepModeInput> {
125
+ if (attachmentPaths.length !== 1) {
126
+ throw new CliUsageError(
127
+ "taishi sweep requires exactly one --attach typed JSON attachment",
128
+ );
129
+ }
130
+
131
+ const sourcePath = attachmentPaths[0]!;
132
+ const absolute = isAbsolute(sourcePath) ? sourcePath : resolve(sourcePath);
133
+
134
+ let bytes: Buffer;
135
+ try {
136
+ bytes = await readFile(absolute);
137
+ } catch (error) {
138
+ throw new CliUsageError(
139
+ `taishi sweep attachment is not a readable regular file: ${sourcePath}`,
140
+ { cause: error },
141
+ );
142
+ }
143
+
144
+ let text: string;
145
+ try {
146
+ text = exactUtf8(bytes, "taishi sweep attachment");
147
+ } catch (error) {
148
+ const detail = error instanceof Error ? error.message : String(error);
149
+ throw new CliUsageError(detail, { cause: error });
150
+ }
151
+
152
+ let parsed: unknown;
153
+ try {
154
+ parsed = JSON.parse(text);
155
+ } catch (error) {
156
+ throw new CliUsageError(
157
+ "taishi sweep attachment is not valid JSON",
158
+ { cause: error },
159
+ );
160
+ }
161
+
162
+ return parseTaishiSweepModeInputFromJsonValue(parsed);
163
+ }
164
+
165
+ /**
166
+ * Public taishi run path — parse → resolve → query → typed receipt on stdout.
167
+ * Issue (#336/#338 compute-if-missing), sweep (#337), cohort/model-groups (#338).
168
+ */
169
+ export async function runPublicTaishi(
170
+ argv: readonly string[],
171
+ _env: TaishiRunEnv,
172
+ io: CliIo,
173
+ parseTaishiArgv: (args: readonly string[]) => ParseTaishiArgvResult,
174
+ ): Promise<{ exitCode: number }> {
175
+ try {
176
+ const parsed = parseTaishiArgv(argv);
177
+ // Machine home is package-owned (ADR 0048) — same primitive runTaishi uses.
178
+ const ledgerHome = resolveActivationLedgerHome();
179
+
180
+ if (parsed.query === "sweep") {
181
+ const input = await buildTaishiSweepModeInputFromAttachmentPaths(
182
+ parsed.attachmentPaths,
183
+ );
184
+ const result = await runTaishi(input);
185
+ io.stdout(`${JSON.stringify(result, null, 2)}\n`);
186
+ return { exitCode: 0 };
187
+ }
188
+
189
+ if (parsed.query === "cohort") {
190
+ const result = await runTaishi({
191
+ mode: "cohort",
192
+ groups: parsed.groups,
193
+ });
194
+ io.stdout(`${JSON.stringify(result, null, 2)}\n`);
195
+ return { exitCode: 0 };
196
+ }
197
+
198
+ if (parsed.query === "model-groups") {
199
+ const result = await runTaishi({
200
+ mode: "model-groups",
201
+ projectRoots: parsed.projectRoots,
202
+ });
203
+ io.stdout(`${JSON.stringify(result, null, 2)}\n`);
204
+ return { exitCode: 0 };
205
+ }
206
+
207
+ // issue query — compute-if-missing (#338); sole kernel on miss.
208
+ const input = await buildTaishiIssueModeInputFromPublicArgv(parsed, ledgerHome);
209
+ const result = await readOrComputeTaishiIssuePage(input);
210
+ io.stdout(`${JSON.stringify(result, null, 2)}\n`);
211
+ return { exitCode: 0 };
212
+ } catch (error) {
213
+ if (error instanceof CliUsageError) {
214
+ presentStructuralRejection(error, io);
215
+ return { exitCode: 2 };
216
+ }
217
+ if (error instanceof TaishiIssueComputeError) {
218
+ // Existing ControlledFailure: details carry code/projectRoot/issueNumber;
219
+ // identity.code carries distinguishable real cause (errno). No parallel schema.
220
+ const code = errnoCode(error.cause);
221
+ presentControlledFailure({
222
+ cause: "output",
223
+ diagnostic: error.message,
224
+ ...(code === undefined ? {} : { identity: { code } }),
225
+ details: {
226
+ code: error.code,
227
+ projectRoot: error.projectRoot,
228
+ ...(error.issueNumber === undefined ? {} : { issueNumber: error.issueNumber }),
229
+ },
230
+ }, io);
231
+ return { exitCode: 1 };
232
+ }
233
+ throw error;
234
+ }
235
+ }