@akagilnc/pi-workflow-roles 0.1.2033 → 0.1.2041
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 +88 -1
- package/README.zh-CN.md +88 -1
- package/dist/public-cli/main.js +1051 -443
- package/package.json +1 -1
- package/src/public-cli/cli.ts +95 -53
- package/src/public-cli/invocation.ts +295 -404
- package/src/public-cli/option-definitions.ts +1173 -0
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #342 — sole typed public CLI option-definition source.
|
|
3
|
+
*
|
|
4
|
+
* PUBLIC_ROLE_ARGV rows reference these definitions. Production parsers and
|
|
5
|
+
* `help <command>` consume this table; README flag inventory is generated from
|
|
6
|
+
* it. Do not maintain a parallel spelling set in parsers, help, or docs.
|
|
7
|
+
*
|
|
8
|
+
* Dashed-option take, positional selector match, role-phase resolution,
|
|
9
|
+
* `repeatable` enforcement, and unconditional `required` checks share one
|
|
10
|
+
* consumer (`createTypedOptionConsumer`). Parsers must not restate phase
|
|
11
|
+
* tokens or add parallel repeatability / requiredness branches.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CliUsageError } from "./cli-errors.ts";
|
|
15
|
+
|
|
16
|
+
/** Taishi query faces (#336/#337/#338). */
|
|
17
|
+
export type TaishiMode = "issue" | "sweep" | "cohort" | "model-groups";
|
|
18
|
+
|
|
19
|
+
/** Coder/Fixer public phase tokens. */
|
|
20
|
+
export type RolePhase = "plan" | "apply";
|
|
21
|
+
|
|
22
|
+
export type OptionOwner =
|
|
23
|
+
| "global"
|
|
24
|
+
| "judge"
|
|
25
|
+
| "coder"
|
|
26
|
+
| "fixer"
|
|
27
|
+
| "reviewer"
|
|
28
|
+
| "collector"
|
|
29
|
+
| "doctor"
|
|
30
|
+
| "merger"
|
|
31
|
+
| "taishi";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One public option (or positional mode/phase token) identity.
|
|
35
|
+
* Structured fields are the contract; EN/ZH strings are presentation only.
|
|
36
|
+
*/
|
|
37
|
+
export type PublicOptionDefinition = {
|
|
38
|
+
/** Stable id unique within owner (not a spelling). */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly owner: OptionOwner;
|
|
41
|
+
/** Canonical public spelling, e.g. `--attach` or positional `sweep`. */
|
|
42
|
+
readonly canonical: string;
|
|
43
|
+
/** Legal alternate spellings (e.g. `-h` for `--help`). */
|
|
44
|
+
readonly aliases: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* Value placeholder for help/README; `null` means flag takes no value
|
|
47
|
+
* (boolean switch or bare positional token).
|
|
48
|
+
*/
|
|
49
|
+
readonly valueMetavar: string | null;
|
|
50
|
+
/** Unconditionally required at admission when no mode/phase qualifier applies. */
|
|
51
|
+
readonly required: boolean;
|
|
52
|
+
readonly repeatable: boolean;
|
|
53
|
+
readonly defaultValue?: string;
|
|
54
|
+
/**
|
|
55
|
+
* `option` — dashed flag; `positional` — bare token (phase/mode selector).
|
|
56
|
+
*/
|
|
57
|
+
readonly form: "option" | "positional";
|
|
58
|
+
/** When set, only these coder/fixer phases admit the option. */
|
|
59
|
+
readonly phases?: readonly RolePhase[];
|
|
60
|
+
/** When set, only these taishi modes admit the option. */
|
|
61
|
+
readonly modes?: readonly TaishiMode[];
|
|
62
|
+
/** Modes in which this option is required (taishi conditional requiredness). */
|
|
63
|
+
readonly requiredInModes?: readonly TaishiMode[];
|
|
64
|
+
/**
|
|
65
|
+
* Other option ids on the same owner that cannot co-occur
|
|
66
|
+
* (e.g. cohort × model-groups).
|
|
67
|
+
*/
|
|
68
|
+
readonly exclusiveWith?: readonly string[];
|
|
69
|
+
/** Per-mode maximum occurrences (issue `--project-root` ≤ 1). */
|
|
70
|
+
readonly maxCountByMode?: Readonly<Partial<Record<TaishiMode, number>>>;
|
|
71
|
+
/**
|
|
72
|
+
* When this option (or positional) is present it activates this mode.
|
|
73
|
+
* Mode resolution consumes only this field — parsers must not restate selectors.
|
|
74
|
+
*/
|
|
75
|
+
readonly selectsMode?: TaishiMode;
|
|
76
|
+
readonly description: { readonly en: string; readonly zh: string };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cross-field at-least-one rules for taishi modes (cannot hang on one option row).
|
|
81
|
+
* Parser-consumed sole source together with per-option modes/required/exclusive/max (#342).
|
|
82
|
+
*/
|
|
83
|
+
export type TaishiRequireAnyOfRule = {
|
|
84
|
+
readonly mode: TaishiMode;
|
|
85
|
+
readonly optionIds: readonly string[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Issue face: ticket | project-root at least one. */
|
|
89
|
+
export const TAISHI_REQUIRE_ANY_OF = [
|
|
90
|
+
{ mode: "issue", optionIds: ["ticket", "project-root"] },
|
|
91
|
+
] as const satisfies readonly TaishiRequireAnyOfRule[];
|
|
92
|
+
|
|
93
|
+
/** Residual taishi mode when no `selectsMode` option is present. */
|
|
94
|
+
export const TAISHI_DEFAULT_MODE: TaishiMode = "issue";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve taishi mode from collected option ids via `selectsMode` on the table.
|
|
98
|
+
* Deterministic preference when multiple selectors co-occur; exclusiveWith then rejects.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveTaishiMode(
|
|
101
|
+
presentOptionIds: ReadonlySet<string>,
|
|
102
|
+
): TaishiMode {
|
|
103
|
+
const selected = new Set<TaishiMode>();
|
|
104
|
+
for (const def of optionsForOwner("taishi")) {
|
|
105
|
+
if (def.selectsMode === undefined) continue;
|
|
106
|
+
if (presentOptionIds.has(def.id)) selected.add(def.selectsMode);
|
|
107
|
+
}
|
|
108
|
+
if (selected.size === 0) return TAISHI_DEFAULT_MODE;
|
|
109
|
+
if (selected.has("cohort")) return "cohort";
|
|
110
|
+
if (selected.has("model-groups")) return "model-groups";
|
|
111
|
+
if (selected.has("sweep")) return "sweep";
|
|
112
|
+
if (selected.has("issue")) return "issue";
|
|
113
|
+
return TAISHI_DEFAULT_MODE;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type TaishiOptionCounts = ReadonlyMap<string, number>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Evaluate taishi cross-field / cross-mode structured contracts from the sole typed table.
|
|
120
|
+
* Covers: modes admission, requiredInModes, exclusiveWith, maxCountByMode, TAISHI_REQUIRE_ANY_OF.
|
|
121
|
+
*/
|
|
122
|
+
export function evaluateTaishiModeOptionContract(
|
|
123
|
+
mode: TaishiMode,
|
|
124
|
+
counts: TaishiOptionCounts,
|
|
125
|
+
): { ok: true } | { ok: false; message: string } {
|
|
126
|
+
const definitions = optionsForOwner("taishi");
|
|
127
|
+
const byId = new Map(definitions.map((def) => [def.id, def] as const));
|
|
128
|
+
|
|
129
|
+
for (const def of definitions) {
|
|
130
|
+
const count = counts.get(def.id) ?? 0;
|
|
131
|
+
if (count === 0 || def.exclusiveWith === undefined) continue;
|
|
132
|
+
for (const otherId of def.exclusiveWith) {
|
|
133
|
+
if ((counts.get(otherId) ?? 0) === 0) continue;
|
|
134
|
+
const other = byId.get(otherId);
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
message: `taishi accepts only one of ${def.canonical} / ${other?.canonical ?? otherId}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const def of definitions) {
|
|
143
|
+
const count = counts.get(def.id) ?? 0;
|
|
144
|
+
if (count === 0) continue;
|
|
145
|
+
if (def.modes !== undefined && !def.modes.includes(mode)) {
|
|
146
|
+
// Sweep face historically names the attach carrier on mix-face rejects.
|
|
147
|
+
if (mode === "sweep") {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
message: `taishi sweep --attach cannot combine with ${def.canonical}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
message: `taishi ${mode} does not accept ${def.canonical}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const max = def.maxCountByMode?.[mode];
|
|
159
|
+
if (max !== undefined && count > max) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
message:
|
|
163
|
+
max === 1
|
|
164
|
+
? `taishi ${mode} accepts at most one ${def.canonical}`
|
|
165
|
+
: `taishi ${mode} accepts at most ${max} ${def.canonical}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const missingRequired: PublicOptionDefinition[] = [];
|
|
171
|
+
for (const def of definitions) {
|
|
172
|
+
if (def.requiredInModes === undefined) continue;
|
|
173
|
+
if (!def.requiredInModes.includes(mode)) continue;
|
|
174
|
+
if ((counts.get(def.id) ?? 0) === 0) missingRequired.push(def);
|
|
175
|
+
}
|
|
176
|
+
if (missingRequired.length > 0) {
|
|
177
|
+
if (mode === "cohort") {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
message:
|
|
181
|
+
"usage: ak-role taishi --cohort --group-a-label <L> --group-a-issues <N[,N...]> --group-b-label <L> --group-b-issues <N[,N...]",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (mode === "model-groups") {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
message:
|
|
188
|
+
"usage: ak-role taishi --model-groups --project-root <P> [--project-root <P> ...]",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
message: `usage: ak-role taishi ${mode} requires ${missingRequired
|
|
194
|
+
.map((def) => def.canonical)
|
|
195
|
+
.join(" ")}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const rule of TAISHI_REQUIRE_ANY_OF) {
|
|
200
|
+
if (rule.mode !== mode) continue;
|
|
201
|
+
const hit = rule.optionIds.some((id) => (counts.get(id) ?? 0) > 0);
|
|
202
|
+
if (hit) continue;
|
|
203
|
+
if (mode === "issue") {
|
|
204
|
+
// Bare-usage surface names issue faces and the sweep attach carrier.
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
message:
|
|
208
|
+
"usage: ak-role taishi ((--ticket <N> | --project-root <P>) | [sweep] --attach <sweep.json> | --cohort ... | --model-groups ...)",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const flags = rule.optionIds
|
|
212
|
+
.map((id) => byId.get(id)?.canonical ?? id)
|
|
213
|
+
.join(" | ");
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
message: `usage: ak-role taishi ${mode} requires one of ${flags}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Rejected / internal spellings retained by parsers for explicit refusal.
|
|
225
|
+
* Must never appear in public help or README projections.
|
|
226
|
+
*/
|
|
227
|
+
export type RejectedSpelling = {
|
|
228
|
+
readonly owner: OptionOwner;
|
|
229
|
+
readonly spellings: readonly string[];
|
|
230
|
+
readonly reason: { readonly en: string; readonly zh: string };
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const REJECTED_PUBLIC_SPELLINGS = [
|
|
234
|
+
{
|
|
235
|
+
owner: "judge",
|
|
236
|
+
spellings: ["--burden", "--ak-judge-burden", "--judge-burden"],
|
|
237
|
+
reason: {
|
|
238
|
+
en: "Judge infers its own burden; no public burden selector.",
|
|
239
|
+
zh: "大理寺自行推断举证责任,不接受公开 burden 旗标。",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
owner: "merger",
|
|
244
|
+
spellings: ["--ak-merger-input"],
|
|
245
|
+
reason: {
|
|
246
|
+
en: "Merger input packet is assembled internally; not a public flag.",
|
|
247
|
+
zh: "校书郎 input packet 由内部装配,不是公开旗标。",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
] as const satisfies readonly RejectedSpelling[];
|
|
251
|
+
|
|
252
|
+
const GLOBAL_OPTIONS = [
|
|
253
|
+
{
|
|
254
|
+
id: "model",
|
|
255
|
+
owner: "global",
|
|
256
|
+
canonical: "--model",
|
|
257
|
+
aliases: [],
|
|
258
|
+
valueMetavar: "provider/model",
|
|
259
|
+
required: false,
|
|
260
|
+
repeatable: false,
|
|
261
|
+
form: "option",
|
|
262
|
+
description: {
|
|
263
|
+
en: "Override the effective seat model for this invocation (before or after the command).",
|
|
264
|
+
zh: "覆盖本调用有效席位模型(可置于子命令前或后)。",
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: "thinking",
|
|
269
|
+
owner: "global",
|
|
270
|
+
canonical: "--thinking",
|
|
271
|
+
aliases: [],
|
|
272
|
+
valueMetavar: "level",
|
|
273
|
+
required: false,
|
|
274
|
+
repeatable: false,
|
|
275
|
+
form: "option",
|
|
276
|
+
description: {
|
|
277
|
+
en: "Override thinking level: off|minimal|low|medium|high|xhigh|max.",
|
|
278
|
+
zh: "覆盖 thinking 档位:off|minimal|low|medium|high|xhigh|max。",
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "help",
|
|
283
|
+
owner: "global",
|
|
284
|
+
canonical: "--help",
|
|
285
|
+
aliases: ["-h"],
|
|
286
|
+
valueMetavar: null,
|
|
287
|
+
required: false,
|
|
288
|
+
repeatable: false,
|
|
289
|
+
form: "option",
|
|
290
|
+
description: {
|
|
291
|
+
en: "Show public CLI help and exit.",
|
|
292
|
+
zh: "显示公开 CLI 帮助并退出。",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Immutable shared semantics for the common ledger `--project` face.
|
|
299
|
+
* Role rows bind owner only — do not copy these fields per role.
|
|
300
|
+
* Role-specific project faces (merger merge-root; taishi `--project-root`)
|
|
301
|
+
* stay explicit below and must not use this binding.
|
|
302
|
+
*/
|
|
303
|
+
const SHARED_PROJECT_SEMANTICS = {
|
|
304
|
+
id: "project",
|
|
305
|
+
canonical: "--project",
|
|
306
|
+
aliases: [] as const,
|
|
307
|
+
valueMetavar: "path",
|
|
308
|
+
required: false,
|
|
309
|
+
repeatable: false,
|
|
310
|
+
form: "option" as const,
|
|
311
|
+
description: {
|
|
312
|
+
en: "Project root for ledger identity (defaults to process cwd).",
|
|
313
|
+
zh: "卷宗身份用的项目根(默认进程 cwd)。",
|
|
314
|
+
},
|
|
315
|
+
} as const satisfies Omit<PublicOptionDefinition, "owner">;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Immutable shared semantics for the common frozen-file `--attach` face.
|
|
319
|
+
* Role rows bind owner only. Reviewer has no attach face; taishi sweep attach
|
|
320
|
+
* keeps its own modes/selectsMode/description and must not use this binding.
|
|
321
|
+
*/
|
|
322
|
+
const SHARED_ATTACH_SEMANTICS = {
|
|
323
|
+
id: "attach",
|
|
324
|
+
canonical: "--attach",
|
|
325
|
+
aliases: [] as const,
|
|
326
|
+
valueMetavar: "path",
|
|
327
|
+
required: false,
|
|
328
|
+
repeatable: true,
|
|
329
|
+
form: "option" as const,
|
|
330
|
+
description: {
|
|
331
|
+
en: "Attach a regular file; frozen at admission (repeatable).",
|
|
332
|
+
zh: "附加普通文件;受理即冻结(可重复)。",
|
|
333
|
+
},
|
|
334
|
+
} as const satisfies Omit<PublicOptionDefinition, "owner">;
|
|
335
|
+
|
|
336
|
+
/** Minimal owner-binding: one immutable semantic row → one role table entry. */
|
|
337
|
+
function bindOwner(
|
|
338
|
+
owner: OptionOwner,
|
|
339
|
+
semantics: Omit<PublicOptionDefinition, "owner">,
|
|
340
|
+
): PublicOptionDefinition {
|
|
341
|
+
return { ...semantics, owner };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const JUDGE_OPTIONS = [
|
|
345
|
+
bindOwner("judge", SHARED_PROJECT_SEMANTICS),
|
|
346
|
+
bindOwner("judge", SHARED_ATTACH_SEMANTICS),
|
|
347
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
348
|
+
|
|
349
|
+
const CODER_OPTIONS = [
|
|
350
|
+
{
|
|
351
|
+
id: "phase",
|
|
352
|
+
owner: "coder",
|
|
353
|
+
canonical: "plan|apply",
|
|
354
|
+
aliases: ["plan", "apply"],
|
|
355
|
+
valueMetavar: null,
|
|
356
|
+
required: false,
|
|
357
|
+
repeatable: false,
|
|
358
|
+
defaultValue: "apply",
|
|
359
|
+
form: "positional",
|
|
360
|
+
phases: ["plan", "apply"],
|
|
361
|
+
description: {
|
|
362
|
+
en: "Optional phase token before the instruction; defaults to apply.",
|
|
363
|
+
zh: "指令前可选 phase 词元;默认 apply。",
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
bindOwner("coder", SHARED_PROJECT_SEMANTICS),
|
|
367
|
+
bindOwner("coder", SHARED_ATTACH_SEMANTICS),
|
|
368
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
369
|
+
|
|
370
|
+
const FIXER_OPTIONS = [
|
|
371
|
+
{
|
|
372
|
+
id: "phase",
|
|
373
|
+
owner: "fixer",
|
|
374
|
+
canonical: "plan|apply",
|
|
375
|
+
aliases: ["plan", "apply"],
|
|
376
|
+
valueMetavar: null,
|
|
377
|
+
required: false,
|
|
378
|
+
repeatable: false,
|
|
379
|
+
defaultValue: "apply",
|
|
380
|
+
form: "positional",
|
|
381
|
+
phases: ["plan", "apply"],
|
|
382
|
+
description: {
|
|
383
|
+
en: "Optional phase token before the instruction; defaults to apply.",
|
|
384
|
+
zh: "指令前可选 phase 词元;默认 apply。",
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
bindOwner("fixer", SHARED_PROJECT_SEMANTICS),
|
|
388
|
+
bindOwner("fixer", SHARED_ATTACH_SEMANTICS),
|
|
389
|
+
{
|
|
390
|
+
id: "prerequisites",
|
|
391
|
+
owner: "fixer",
|
|
392
|
+
canonical: "--prerequisites",
|
|
393
|
+
aliases: [],
|
|
394
|
+
valueMetavar: "path",
|
|
395
|
+
required: false,
|
|
396
|
+
repeatable: false,
|
|
397
|
+
form: "option",
|
|
398
|
+
description: {
|
|
399
|
+
en: "JSON array of {id, requirement} prerequisite objects.",
|
|
400
|
+
zh: "{id, requirement} 前置条件 JSON 数组路径。",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
404
|
+
|
|
405
|
+
const REVIEWER_OPTIONS = [
|
|
406
|
+
bindOwner("reviewer", SHARED_PROJECT_SEMANTICS),
|
|
407
|
+
// Reviewer deliberately has no --attach face (gathers its own evidence).
|
|
408
|
+
{
|
|
409
|
+
id: "base",
|
|
410
|
+
owner: "reviewer",
|
|
411
|
+
canonical: "--base",
|
|
412
|
+
aliases: [],
|
|
413
|
+
valueMetavar: "revision",
|
|
414
|
+
required: true,
|
|
415
|
+
repeatable: false,
|
|
416
|
+
form: "option",
|
|
417
|
+
description: {
|
|
418
|
+
en: "Required fixed-point revision for the pinned review target.",
|
|
419
|
+
zh: "必填;钉住审查目标的 fixed-point revision。",
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "authority-ref",
|
|
424
|
+
owner: "reviewer",
|
|
425
|
+
canonical: "--authority-ref",
|
|
426
|
+
aliases: [],
|
|
427
|
+
valueMetavar: "ref",
|
|
428
|
+
required: false,
|
|
429
|
+
repeatable: true,
|
|
430
|
+
form: "option",
|
|
431
|
+
description: {
|
|
432
|
+
en: "Durable authority reference/URL (repeatable; refs only, not inline prose).",
|
|
433
|
+
zh: "持久 authority 引用/URL(可重复;仅 ref,非内联散文)。",
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
437
|
+
|
|
438
|
+
const COLLECTOR_OPTIONS = [
|
|
439
|
+
bindOwner("collector", SHARED_PROJECT_SEMANTICS),
|
|
440
|
+
bindOwner("collector", SHARED_ATTACH_SEMANTICS),
|
|
441
|
+
{
|
|
442
|
+
id: "pr",
|
|
443
|
+
owner: "collector",
|
|
444
|
+
canonical: "--pr",
|
|
445
|
+
aliases: [],
|
|
446
|
+
valueMetavar: "number",
|
|
447
|
+
required: true,
|
|
448
|
+
repeatable: false,
|
|
449
|
+
form: "option",
|
|
450
|
+
description: {
|
|
451
|
+
en: "Required positive GitHub pull request number.",
|
|
452
|
+
zh: "必填;正整数 GitHub PR 号。",
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "repo",
|
|
457
|
+
owner: "collector",
|
|
458
|
+
canonical: "--repo",
|
|
459
|
+
aliases: [],
|
|
460
|
+
valueMetavar: "owner/repo",
|
|
461
|
+
required: false,
|
|
462
|
+
repeatable: false,
|
|
463
|
+
form: "option",
|
|
464
|
+
description: {
|
|
465
|
+
en: "GitHub owner/repo override (defaults from origin when github.com).",
|
|
466
|
+
zh: "GitHub owner/repo 覆盖(默认取 github.com origin)。",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: "request-manifest",
|
|
471
|
+
owner: "collector",
|
|
472
|
+
canonical: "--request-manifest",
|
|
473
|
+
aliases: [],
|
|
474
|
+
valueMetavar: "path",
|
|
475
|
+
required: false,
|
|
476
|
+
repeatable: false,
|
|
477
|
+
form: "option",
|
|
478
|
+
description: {
|
|
479
|
+
en: "Optional request manifest JSON path ({requests:[{id,body}]}).",
|
|
480
|
+
zh: "可选 request manifest JSON 路径({requests:[{id,body}]})。",
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
484
|
+
|
|
485
|
+
const DOCTOR_OPTIONS = [
|
|
486
|
+
bindOwner("doctor", SHARED_PROJECT_SEMANTICS),
|
|
487
|
+
bindOwner("doctor", SHARED_ATTACH_SEMANTICS),
|
|
488
|
+
{
|
|
489
|
+
id: "issue",
|
|
490
|
+
owner: "doctor",
|
|
491
|
+
canonical: "--issue",
|
|
492
|
+
aliases: [],
|
|
493
|
+
valueMetavar: "number",
|
|
494
|
+
required: true,
|
|
495
|
+
repeatable: false,
|
|
496
|
+
form: "option",
|
|
497
|
+
description: {
|
|
498
|
+
en: "Required positive issue number for the retained case.",
|
|
499
|
+
zh: "必填;留存病例的正整数 issue 号。",
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
id: "runs",
|
|
504
|
+
owner: "doctor",
|
|
505
|
+
canonical: "--runs",
|
|
506
|
+
aliases: [],
|
|
507
|
+
valueMetavar: "path",
|
|
508
|
+
required: false,
|
|
509
|
+
repeatable: false,
|
|
510
|
+
form: "option",
|
|
511
|
+
description: {
|
|
512
|
+
en: "Optional project-relative .ak-roles/books/<book>/issues/<n>/runs override matching --issue.",
|
|
513
|
+
zh: "可选项目相对 .ak-roles/books/<book>/issues/<n>/runs 覆盖,且须匹配 --issue。",
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
517
|
+
|
|
518
|
+
const MERGER_OPTIONS = [
|
|
519
|
+
// Merger project face differs: requires an in-progress ordinary merge root.
|
|
520
|
+
{
|
|
521
|
+
id: "project",
|
|
522
|
+
owner: "merger",
|
|
523
|
+
canonical: "--project",
|
|
524
|
+
aliases: [],
|
|
525
|
+
valueMetavar: "path",
|
|
526
|
+
required: false,
|
|
527
|
+
repeatable: false,
|
|
528
|
+
form: "option",
|
|
529
|
+
description: {
|
|
530
|
+
en: "Project root with one ordinary in-progress merge (defaults to cwd).",
|
|
531
|
+
zh: "已有进行中 ordinary merge 的项目根(默认 cwd)。",
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
bindOwner("merger", SHARED_ATTACH_SEMANTICS),
|
|
535
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
536
|
+
|
|
537
|
+
const TAISHI_OPTIONS = [
|
|
538
|
+
{
|
|
539
|
+
id: "sweep",
|
|
540
|
+
owner: "taishi",
|
|
541
|
+
canonical: "sweep",
|
|
542
|
+
aliases: [],
|
|
543
|
+
valueMetavar: null,
|
|
544
|
+
required: false,
|
|
545
|
+
repeatable: false,
|
|
546
|
+
form: "positional",
|
|
547
|
+
modes: ["sweep"],
|
|
548
|
+
selectsMode: "sweep",
|
|
549
|
+
description: {
|
|
550
|
+
en: "Optional sweep mode token (at most once; no other positionals).",
|
|
551
|
+
zh: "可选 sweep 模式词元(至多一次;不得夹带其他 positional)。",
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
id: "project-root",
|
|
556
|
+
owner: "taishi",
|
|
557
|
+
canonical: "--project-root",
|
|
558
|
+
aliases: [],
|
|
559
|
+
valueMetavar: "path",
|
|
560
|
+
required: false,
|
|
561
|
+
repeatable: true,
|
|
562
|
+
form: "option",
|
|
563
|
+
modes: ["issue", "model-groups"],
|
|
564
|
+
requiredInModes: ["model-groups"],
|
|
565
|
+
maxCountByMode: { issue: 1 },
|
|
566
|
+
description: {
|
|
567
|
+
en: "Project-root scope key. Issue: at most one (with --ticket at least one of the two). Model-groups: one or more required.",
|
|
568
|
+
zh: "projectRoot 范围键。issue:至多一个(与 --ticket 至少居其一)。model-groups:一个或多个且必填。",
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
id: "ticket",
|
|
573
|
+
owner: "taishi",
|
|
574
|
+
canonical: "--ticket",
|
|
575
|
+
aliases: [],
|
|
576
|
+
valueMetavar: "number",
|
|
577
|
+
required: false,
|
|
578
|
+
repeatable: false,
|
|
579
|
+
form: "option",
|
|
580
|
+
modes: ["issue"],
|
|
581
|
+
description: {
|
|
582
|
+
en: "Ticket/issue number for issue mode (with --project-root at least one of the two).",
|
|
583
|
+
zh: "issue 模式的票号(与 --project-root 至少居其一)。",
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
id: "attach",
|
|
588
|
+
owner: "taishi",
|
|
589
|
+
canonical: "--attach",
|
|
590
|
+
aliases: [],
|
|
591
|
+
valueMetavar: "path",
|
|
592
|
+
required: false,
|
|
593
|
+
repeatable: true,
|
|
594
|
+
form: "option",
|
|
595
|
+
modes: ["sweep"],
|
|
596
|
+
selectsMode: "sweep",
|
|
597
|
+
requiredInModes: ["sweep"],
|
|
598
|
+
maxCountByMode: { sweep: 1 },
|
|
599
|
+
description: {
|
|
600
|
+
en: "Sweep-mode attachment path; required exactly once in sweep; payload is the attachment body.",
|
|
601
|
+
zh: "sweep 模式附件路径;sweep 必填且恰一次;载荷为附件正文。",
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
id: "cohort",
|
|
606
|
+
owner: "taishi",
|
|
607
|
+
canonical: "--cohort",
|
|
608
|
+
aliases: [],
|
|
609
|
+
valueMetavar: null,
|
|
610
|
+
required: false,
|
|
611
|
+
repeatable: false,
|
|
612
|
+
form: "option",
|
|
613
|
+
modes: ["cohort"],
|
|
614
|
+
exclusiveWith: ["model-groups"],
|
|
615
|
+
selectsMode: "cohort",
|
|
616
|
+
description: {
|
|
617
|
+
en: "Select cohort mode (mutually exclusive with --model-groups).",
|
|
618
|
+
zh: "选择 cohort 模式(与 --model-groups 互斥)。",
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
id: "model-groups",
|
|
623
|
+
owner: "taishi",
|
|
624
|
+
canonical: "--model-groups",
|
|
625
|
+
aliases: [],
|
|
626
|
+
valueMetavar: null,
|
|
627
|
+
required: false,
|
|
628
|
+
repeatable: false,
|
|
629
|
+
form: "option",
|
|
630
|
+
modes: ["model-groups"],
|
|
631
|
+
exclusiveWith: ["cohort"],
|
|
632
|
+
selectsMode: "model-groups",
|
|
633
|
+
description: {
|
|
634
|
+
en: "Select model-groups mode (mutually exclusive with --cohort).",
|
|
635
|
+
zh: "选择 model-groups 模式(与 --cohort 互斥)。",
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
id: "group-a-label",
|
|
640
|
+
owner: "taishi",
|
|
641
|
+
canonical: "--group-a-label",
|
|
642
|
+
aliases: [],
|
|
643
|
+
valueMetavar: "label",
|
|
644
|
+
required: false,
|
|
645
|
+
repeatable: false,
|
|
646
|
+
form: "option",
|
|
647
|
+
modes: ["cohort"],
|
|
648
|
+
requiredInModes: ["cohort"],
|
|
649
|
+
description: {
|
|
650
|
+
en: "Cohort group A label (required in cohort mode).",
|
|
651
|
+
zh: "cohort A 组标签(cohort 模式必填)。",
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
id: "group-a-issues",
|
|
656
|
+
owner: "taishi",
|
|
657
|
+
canonical: "--group-a-issues",
|
|
658
|
+
aliases: [],
|
|
659
|
+
valueMetavar: "N[,N...]",
|
|
660
|
+
required: false,
|
|
661
|
+
repeatable: false,
|
|
662
|
+
form: "option",
|
|
663
|
+
modes: ["cohort"],
|
|
664
|
+
requiredInModes: ["cohort"],
|
|
665
|
+
description: {
|
|
666
|
+
en: "Cohort group A comma-separated positive issue numbers (required in cohort mode).",
|
|
667
|
+
zh: "cohort A 组逗号分隔正整数 issue 列表(cohort 模式必填)。",
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
id: "group-b-label",
|
|
672
|
+
owner: "taishi",
|
|
673
|
+
canonical: "--group-b-label",
|
|
674
|
+
aliases: [],
|
|
675
|
+
valueMetavar: "label",
|
|
676
|
+
required: false,
|
|
677
|
+
repeatable: false,
|
|
678
|
+
form: "option",
|
|
679
|
+
modes: ["cohort"],
|
|
680
|
+
requiredInModes: ["cohort"],
|
|
681
|
+
description: {
|
|
682
|
+
en: "Cohort group B label (required in cohort mode).",
|
|
683
|
+
zh: "cohort B 组标签(cohort 模式必填)。",
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
id: "group-b-issues",
|
|
688
|
+
owner: "taishi",
|
|
689
|
+
canonical: "--group-b-issues",
|
|
690
|
+
aliases: [],
|
|
691
|
+
valueMetavar: "N[,N...]",
|
|
692
|
+
required: false,
|
|
693
|
+
repeatable: false,
|
|
694
|
+
form: "option",
|
|
695
|
+
modes: ["cohort"],
|
|
696
|
+
requiredInModes: ["cohort"],
|
|
697
|
+
description: {
|
|
698
|
+
en: "Cohort group B comma-separated positive issue numbers (required in cohort mode).",
|
|
699
|
+
zh: "cohort B 组逗号分隔正整数 issue 列表(cohort 模式必填)。",
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
] as const satisfies readonly PublicOptionDefinition[];
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Sole production option tables keyed by PUBLIC_ROLE_ARGV / global owner.
|
|
706
|
+
* Rows are readonly definition lists — parsers look up spellings here.
|
|
707
|
+
*/
|
|
708
|
+
export const PUBLIC_OPTION_TABLE = {
|
|
709
|
+
global: GLOBAL_OPTIONS,
|
|
710
|
+
judge: JUDGE_OPTIONS,
|
|
711
|
+
coder: CODER_OPTIONS,
|
|
712
|
+
fixer: FIXER_OPTIONS,
|
|
713
|
+
reviewer: REVIEWER_OPTIONS,
|
|
714
|
+
collector: COLLECTOR_OPTIONS,
|
|
715
|
+
doctor: DOCTOR_OPTIONS,
|
|
716
|
+
merger: MERGER_OPTIONS,
|
|
717
|
+
taishi: TAISHI_OPTIONS,
|
|
718
|
+
} as const satisfies Record<OptionOwner, readonly PublicOptionDefinition[]>;
|
|
719
|
+
|
|
720
|
+
export type PublicRoleOptionOwner = Exclude<OptionOwner, "global">;
|
|
721
|
+
|
|
722
|
+
/** Role/deterministic owners that appear on PUBLIC_ROLE_ARGV. */
|
|
723
|
+
export const PUBLIC_ROLE_OPTION_OWNERS = [
|
|
724
|
+
"judge",
|
|
725
|
+
"coder",
|
|
726
|
+
"fixer",
|
|
727
|
+
"reviewer",
|
|
728
|
+
"collector",
|
|
729
|
+
"doctor",
|
|
730
|
+
"merger",
|
|
731
|
+
"taishi",
|
|
732
|
+
] as const satisfies readonly PublicRoleOptionOwner[];
|
|
733
|
+
|
|
734
|
+
export function optionsForOwner(
|
|
735
|
+
owner: OptionOwner,
|
|
736
|
+
): readonly PublicOptionDefinition[] {
|
|
737
|
+
return PUBLIC_OPTION_TABLE[owner];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function optionById(
|
|
741
|
+
owner: OptionOwner,
|
|
742
|
+
id: string,
|
|
743
|
+
): PublicOptionDefinition {
|
|
744
|
+
const found = PUBLIC_OPTION_TABLE[owner].find((entry) => entry.id === id);
|
|
745
|
+
if (found === undefined) {
|
|
746
|
+
throw new Error(`public option not defined: ${owner}/${id}`);
|
|
747
|
+
}
|
|
748
|
+
return found;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Every dashed spelling (canonical + aliases) for option-form entries. */
|
|
752
|
+
export function dashedSpellings(
|
|
753
|
+
def: PublicOptionDefinition,
|
|
754
|
+
): readonly string[] {
|
|
755
|
+
if (def.form !== "option") return [];
|
|
756
|
+
return [def.canonical, ...def.aliases];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Match `token` against dashed option definitions.
|
|
761
|
+
* Supports exact `--flag` / `-h` and inline `--flag=value`.
|
|
762
|
+
*/
|
|
763
|
+
export function matchDashedOption(
|
|
764
|
+
token: string,
|
|
765
|
+
definitions: readonly PublicOptionDefinition[],
|
|
766
|
+
):
|
|
767
|
+
| { def: PublicOptionDefinition; inlineValue?: string }
|
|
768
|
+
| undefined {
|
|
769
|
+
if (!token.startsWith("-") || token === "-") return undefined;
|
|
770
|
+
for (const def of definitions) {
|
|
771
|
+
if (def.form !== "option") continue;
|
|
772
|
+
for (const spelling of dashedSpellings(def)) {
|
|
773
|
+
if (token === spelling) {
|
|
774
|
+
return { def };
|
|
775
|
+
}
|
|
776
|
+
if (def.valueMetavar !== null && token.startsWith(`${spelling}=`)) {
|
|
777
|
+
return { def, inlineValue: token.slice(spelling.length + 1) };
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Consume one dashed option from the front of `tokens` (mutates).
|
|
786
|
+
* Returns undefined when tokens[0] is not a known definition spelling.
|
|
787
|
+
* Does not enforce `repeatable` — production parsers use `createTypedOptionConsumer`.
|
|
788
|
+
*/
|
|
789
|
+
export function takeDashedOption(
|
|
790
|
+
tokens: string[],
|
|
791
|
+
definitions: readonly PublicOptionDefinition[],
|
|
792
|
+
): { def: PublicOptionDefinition; value: string | undefined } | undefined {
|
|
793
|
+
const token = tokens[0];
|
|
794
|
+
if (token === undefined) return undefined;
|
|
795
|
+
const matched = matchDashedOption(token, definitions);
|
|
796
|
+
if (matched === undefined) return undefined;
|
|
797
|
+
tokens.shift();
|
|
798
|
+
if (matched.def.valueMetavar === null) {
|
|
799
|
+
return { def: matched.def, value: undefined };
|
|
800
|
+
}
|
|
801
|
+
if (matched.inlineValue !== undefined) {
|
|
802
|
+
return { def: matched.def, value: matched.inlineValue };
|
|
803
|
+
}
|
|
804
|
+
return { def: matched.def, value: tokens.shift() };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Match a bare token against form:"positional" definitions (canonical or alias).
|
|
809
|
+
* Does not record occurrence — use `createTypedOptionConsumer().takePositional`.
|
|
810
|
+
*/
|
|
811
|
+
export function matchPositionalOption(
|
|
812
|
+
token: string,
|
|
813
|
+
definitions: readonly PublicOptionDefinition[],
|
|
814
|
+
): PublicOptionDefinition | undefined {
|
|
815
|
+
for (const def of definitions) {
|
|
816
|
+
if (def.form !== "positional") continue;
|
|
817
|
+
if (def.canonical === token || def.aliases.includes(token)) {
|
|
818
|
+
return def;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export type TakenTypedOption = {
|
|
825
|
+
readonly def: PublicOptionDefinition;
|
|
826
|
+
readonly value: string | undefined;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Shared typed-table argv consumer (#342).
|
|
831
|
+
* Single path for dashed take, positional selector take, leading role-phase
|
|
832
|
+
* resolution, `repeatable:false` rejection, and unconditional `required:true`
|
|
833
|
+
* missing checks via CliUsageError.
|
|
834
|
+
*/
|
|
835
|
+
export type TypedOptionConsumer = {
|
|
836
|
+
/** Take one dashed option from `tokens` front; enforces repeatable. */
|
|
837
|
+
readonly takeDashed: (tokens: string[]) => TakenTypedOption | undefined;
|
|
838
|
+
/**
|
|
839
|
+
* If `token` is a known positional spelling, record it (repeatable-enforced)
|
|
840
|
+
* and return its definition; otherwise undefined.
|
|
841
|
+
*/
|
|
842
|
+
readonly takePositional: (token: string) => PublicOptionDefinition | undefined;
|
|
843
|
+
/**
|
|
844
|
+
* Consume a leading role phase token from `positional` using the owner's
|
|
845
|
+
* typed `phase` definition (aliases + defaultValue). Mutates `positional`
|
|
846
|
+
* when a phase token is taken. No hardcoded plan/apply branch at call sites.
|
|
847
|
+
*/
|
|
848
|
+
readonly consumeLeadingPhase: (positional: string[]) => RolePhase;
|
|
849
|
+
/** Occurrence count for an option id (0 when never seen). */
|
|
850
|
+
readonly count: (id: string) => number;
|
|
851
|
+
/**
|
|
852
|
+
* Reject when any definition with unconditional `required:true` has count 0.
|
|
853
|
+
* Table-driven sole missing-required gate — parsers must not restate it.
|
|
854
|
+
*/
|
|
855
|
+
readonly assertRequired: () => void;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Build the sole production consumer for one owner definition list.
|
|
860
|
+
* Every public argv parser shares this path — no parallel phase/repeatable/required logic.
|
|
861
|
+
*/
|
|
862
|
+
export function createTypedOptionConsumer(
|
|
863
|
+
definitions: readonly PublicOptionDefinition[],
|
|
864
|
+
): TypedOptionConsumer {
|
|
865
|
+
const counts = new Map<string, number>();
|
|
866
|
+
|
|
867
|
+
const note = (def: PublicOptionDefinition): void => {
|
|
868
|
+
const next = (counts.get(def.id) ?? 0) + 1;
|
|
869
|
+
counts.set(def.id, next);
|
|
870
|
+
if (next > 1 && !def.repeatable) {
|
|
871
|
+
throw new CliUsageError(`${def.canonical} cannot be repeated`);
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
takeDashed(tokens) {
|
|
877
|
+
const taken = takeDashedOption(tokens, definitions);
|
|
878
|
+
if (taken === undefined) return undefined;
|
|
879
|
+
note(taken.def);
|
|
880
|
+
return taken;
|
|
881
|
+
},
|
|
882
|
+
takePositional(token) {
|
|
883
|
+
const def = matchPositionalOption(token, definitions);
|
|
884
|
+
if (def === undefined) return undefined;
|
|
885
|
+
note(def);
|
|
886
|
+
return def;
|
|
887
|
+
},
|
|
888
|
+
consumeLeadingPhase(positional) {
|
|
889
|
+
const phaseDef = definitions.find(
|
|
890
|
+
(def) => def.id === "phase" && def.form === "positional",
|
|
891
|
+
);
|
|
892
|
+
const defaultPhase: RolePhase =
|
|
893
|
+
phaseDef?.defaultValue === "plan" || phaseDef?.defaultValue === "apply"
|
|
894
|
+
? phaseDef.defaultValue
|
|
895
|
+
: "apply";
|
|
896
|
+
if (phaseDef === undefined || positional.length === 0) {
|
|
897
|
+
return defaultPhase;
|
|
898
|
+
}
|
|
899
|
+
const token = positional[0]!;
|
|
900
|
+
// Aliases carry single-token spellings; canonical may be a joint label (plan|apply).
|
|
901
|
+
if (!phaseDef.aliases.includes(token) && phaseDef.canonical !== token) {
|
|
902
|
+
return defaultPhase;
|
|
903
|
+
}
|
|
904
|
+
positional.shift();
|
|
905
|
+
note(phaseDef);
|
|
906
|
+
if (token !== "plan" && token !== "apply") {
|
|
907
|
+
throw new CliUsageError(`invalid phase token: ${token}`);
|
|
908
|
+
}
|
|
909
|
+
return token;
|
|
910
|
+
},
|
|
911
|
+
count(id) {
|
|
912
|
+
return counts.get(id) ?? 0;
|
|
913
|
+
},
|
|
914
|
+
assertRequired() {
|
|
915
|
+
for (const def of definitions) {
|
|
916
|
+
if (!def.required) continue;
|
|
917
|
+
if ((counts.get(def.id) ?? 0) > 0) continue;
|
|
918
|
+
const suffix =
|
|
919
|
+
def.valueMetavar === null ? "" : ` <${def.valueMetavar}>`;
|
|
920
|
+
throw new CliUsageError(
|
|
921
|
+
`${def.owner} requires ${def.canonical}${suffix}`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Structured option projection used by help and acceptance tests. */
|
|
929
|
+
export type StructuredOptionProjection = {
|
|
930
|
+
readonly id: string;
|
|
931
|
+
readonly owner: OptionOwner;
|
|
932
|
+
readonly canonical: string;
|
|
933
|
+
readonly aliases: readonly string[];
|
|
934
|
+
readonly valueMetavar: string | null;
|
|
935
|
+
readonly required: boolean;
|
|
936
|
+
readonly repeatable: boolean;
|
|
937
|
+
readonly defaultValue?: string;
|
|
938
|
+
readonly form: "option" | "positional";
|
|
939
|
+
readonly phases?: readonly RolePhase[];
|
|
940
|
+
readonly modes?: readonly TaishiMode[];
|
|
941
|
+
readonly requiredInModes?: readonly TaishiMode[];
|
|
942
|
+
readonly exclusiveWith?: readonly string[];
|
|
943
|
+
readonly maxCountByMode?: Readonly<Partial<Record<TaishiMode, number>>>;
|
|
944
|
+
readonly selectsMode?: TaishiMode;
|
|
945
|
+
readonly description: { readonly en: string; readonly zh: string };
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
export function projectOwnerOptions(
|
|
949
|
+
owner: OptionOwner,
|
|
950
|
+
): readonly StructuredOptionProjection[] {
|
|
951
|
+
return optionsForOwner(owner).map((def) => ({
|
|
952
|
+
id: def.id,
|
|
953
|
+
owner: def.owner,
|
|
954
|
+
canonical: def.canonical,
|
|
955
|
+
aliases: def.aliases,
|
|
956
|
+
valueMetavar: def.valueMetavar,
|
|
957
|
+
required: def.required,
|
|
958
|
+
repeatable: def.repeatable,
|
|
959
|
+
...(def.defaultValue === undefined ? {} : { defaultValue: def.defaultValue }),
|
|
960
|
+
form: def.form,
|
|
961
|
+
...(def.phases === undefined ? {} : { phases: def.phases }),
|
|
962
|
+
...(def.modes === undefined ? {} : { modes: def.modes }),
|
|
963
|
+
...(def.requiredInModes === undefined
|
|
964
|
+
? {}
|
|
965
|
+
: { requiredInModes: def.requiredInModes }),
|
|
966
|
+
...(def.exclusiveWith === undefined
|
|
967
|
+
? {}
|
|
968
|
+
: { exclusiveWith: def.exclusiveWith }),
|
|
969
|
+
...(def.maxCountByMode === undefined
|
|
970
|
+
? {}
|
|
971
|
+
: { maxCountByMode: def.maxCountByMode }),
|
|
972
|
+
...(def.selectsMode === undefined ? {} : { selectsMode: def.selectsMode }),
|
|
973
|
+
description: def.description,
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** All rejected spelling tokens flattened (for leakage scans). */
|
|
978
|
+
export function allRejectedSpellingTokens(): readonly string[] {
|
|
979
|
+
const out: string[] = [];
|
|
980
|
+
for (const entry of REJECTED_PUBLIC_SPELLINGS) {
|
|
981
|
+
out.push(...entry.spellings);
|
|
982
|
+
}
|
|
983
|
+
return out;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Render one owner’s options as stable TSV lines for help.
|
|
988
|
+
* Layout is presentation; identity columns are the structured contract.
|
|
989
|
+
*/
|
|
990
|
+
export function renderOwnerOptionHelpLines(
|
|
991
|
+
owner: OptionOwner,
|
|
992
|
+
locale: "en" | "zh" = "en",
|
|
993
|
+
): string[] {
|
|
994
|
+
const lines: string[] = [];
|
|
995
|
+
for (const opt of projectOwnerOptions(owner)) {
|
|
996
|
+
const aliasText =
|
|
997
|
+
opt.aliases.length === 0 ? "-" : opt.aliases.join(",");
|
|
998
|
+
const metavar = opt.valueMetavar ?? "-";
|
|
999
|
+
const required = opt.required ? "required" : "optional";
|
|
1000
|
+
const repeatable = opt.repeatable ? "repeatable" : "single";
|
|
1001
|
+
const form = opt.form;
|
|
1002
|
+
const phases =
|
|
1003
|
+
opt.phases === undefined ? "-" : opt.phases.join("|");
|
|
1004
|
+
const modes = opt.modes === undefined ? "-" : opt.modes.join("|");
|
|
1005
|
+
const requiredInModes =
|
|
1006
|
+
opt.requiredInModes === undefined
|
|
1007
|
+
? "-"
|
|
1008
|
+
: opt.requiredInModes.join("|");
|
|
1009
|
+
const exclusiveWith =
|
|
1010
|
+
opt.exclusiveWith === undefined ? "-" : opt.exclusiveWith.join("|");
|
|
1011
|
+
const maxCount =
|
|
1012
|
+
opt.maxCountByMode === undefined
|
|
1013
|
+
? "-"
|
|
1014
|
+
: Object.entries(opt.maxCountByMode)
|
|
1015
|
+
.map(([mode, n]) => `${mode}:${n}`)
|
|
1016
|
+
.join(",");
|
|
1017
|
+
const defaultValue = opt.defaultValue ?? "-";
|
|
1018
|
+
const desc = locale === "zh" ? opt.description.zh : opt.description.en;
|
|
1019
|
+
lines.push(
|
|
1020
|
+
[
|
|
1021
|
+
"option",
|
|
1022
|
+
opt.id,
|
|
1023
|
+
opt.canonical,
|
|
1024
|
+
`aliases=${aliasText}`,
|
|
1025
|
+
`metavar=${metavar}`,
|
|
1026
|
+
required,
|
|
1027
|
+
repeatable,
|
|
1028
|
+
`form=${form}`,
|
|
1029
|
+
`phases=${phases}`,
|
|
1030
|
+
`modes=${modes}`,
|
|
1031
|
+
`requiredInModes=${requiredInModes}`,
|
|
1032
|
+
`exclusiveWith=${exclusiveWith}`,
|
|
1033
|
+
`maxCountByMode=${maxCount}`,
|
|
1034
|
+
`default=${defaultValue}`,
|
|
1035
|
+
desc,
|
|
1036
|
+
].join("\t"),
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
return lines;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const README_BEGIN = "<!-- BEGIN GENERATED: public-cli-options -->";
|
|
1043
|
+
const README_END = "<!-- END GENERATED: public-cli-options -->";
|
|
1044
|
+
|
|
1045
|
+
export const PUBLIC_CLI_OPTIONS_README_MARKERS = {
|
|
1046
|
+
begin: README_BEGIN,
|
|
1047
|
+
end: README_END,
|
|
1048
|
+
} as const;
|
|
1049
|
+
|
|
1050
|
+
/** Escape `|` so GFM table cells keep their column count (including inside code spans). */
|
|
1051
|
+
function escapeMarkdownTableCell(value: string): string {
|
|
1052
|
+
return value.replaceAll("|", "\\|");
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Markdown flag inventory for README generation (EN or ZH). */
|
|
1056
|
+
export function renderReadmeOptionsMarkdown(locale: "en" | "zh"): string {
|
|
1057
|
+
const lines: string[] = [];
|
|
1058
|
+
if (locale === "zh") {
|
|
1059
|
+
lines.push("## 公开 CLI 选项(生成)");
|
|
1060
|
+
lines.push("");
|
|
1061
|
+
lines.push(
|
|
1062
|
+
"本表由 `src/public-cli/option-definitions.ts` 生成;以 `ak-role help <command>` 为准。勿手改本区。",
|
|
1063
|
+
);
|
|
1064
|
+
} else {
|
|
1065
|
+
lines.push("## Public CLI options (generated)");
|
|
1066
|
+
lines.push("");
|
|
1067
|
+
lines.push(
|
|
1068
|
+
"Generated from `src/public-cli/option-definitions.ts`. Prefer `ak-role help <command>`. Do not hand-edit this section.",
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
lines.push("");
|
|
1072
|
+
|
|
1073
|
+
const owners: OptionOwner[] = ["global", ...PUBLIC_ROLE_OPTION_OWNERS];
|
|
1074
|
+
for (const owner of owners) {
|
|
1075
|
+
lines.push(locale === "zh" ? `### \`${owner}\`` : `### \`${owner}\``);
|
|
1076
|
+
lines.push("");
|
|
1077
|
+
lines.push(
|
|
1078
|
+
locale === "zh"
|
|
1079
|
+
? "| 拼写 | 别名 | 值 | 必填 | 可重复 | 形式 | 模式/阶段 | 说明 |"
|
|
1080
|
+
: "| Spelling | Aliases | Value | Required | Repeatable | Form | Modes/Phases | Description |",
|
|
1081
|
+
);
|
|
1082
|
+
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
|
|
1083
|
+
for (const opt of projectOwnerOptions(owner)) {
|
|
1084
|
+
const aliasesRaw =
|
|
1085
|
+
opt.aliases.length === 0 ? "—" : opt.aliases.join(", ");
|
|
1086
|
+
const aliases =
|
|
1087
|
+
aliasesRaw === "—"
|
|
1088
|
+
? aliasesRaw
|
|
1089
|
+
: aliasesRaw
|
|
1090
|
+
.split(", ")
|
|
1091
|
+
.map((a) => `\`${escapeMarkdownTableCell(a)}\``)
|
|
1092
|
+
.join(", ");
|
|
1093
|
+
const valueRaw = opt.valueMetavar ?? "—";
|
|
1094
|
+
const value =
|
|
1095
|
+
valueRaw === "—"
|
|
1096
|
+
? valueRaw
|
|
1097
|
+
: `\`${escapeMarkdownTableCell(valueRaw)}\``;
|
|
1098
|
+
const requiredRaw = opt.required
|
|
1099
|
+
? locale === "zh"
|
|
1100
|
+
? "是"
|
|
1101
|
+
: "yes"
|
|
1102
|
+
: opt.requiredInModes !== undefined
|
|
1103
|
+
? locale === "zh"
|
|
1104
|
+
? `条件:${opt.requiredInModes.join("|")}`
|
|
1105
|
+
: `when:${opt.requiredInModes.join("|")}`
|
|
1106
|
+
: locale === "zh"
|
|
1107
|
+
? "否"
|
|
1108
|
+
: "no";
|
|
1109
|
+
const required = escapeMarkdownTableCell(requiredRaw);
|
|
1110
|
+
const repeatable = escapeMarkdownTableCell(
|
|
1111
|
+
opt.repeatable
|
|
1112
|
+
? locale === "zh"
|
|
1113
|
+
? "是"
|
|
1114
|
+
: "yes"
|
|
1115
|
+
: locale === "zh"
|
|
1116
|
+
? "否"
|
|
1117
|
+
: "no",
|
|
1118
|
+
);
|
|
1119
|
+
const modePhase = escapeMarkdownTableCell(
|
|
1120
|
+
[
|
|
1121
|
+
opt.modes === undefined ? "" : `modes=${opt.modes.join("|")}`,
|
|
1122
|
+
opt.phases === undefined ? "" : `phases=${opt.phases.join("|")}`,
|
|
1123
|
+
opt.exclusiveWith === undefined
|
|
1124
|
+
? ""
|
|
1125
|
+
: `xor=${opt.exclusiveWith.join("|")}`,
|
|
1126
|
+
opt.maxCountByMode === undefined
|
|
1127
|
+
? ""
|
|
1128
|
+
: `max=${Object.entries(opt.maxCountByMode)
|
|
1129
|
+
.map(([m, n]) => `${m}:${n}`)
|
|
1130
|
+
.join(",")}`,
|
|
1131
|
+
opt.defaultValue === undefined ? "" : `default=${opt.defaultValue}`,
|
|
1132
|
+
]
|
|
1133
|
+
.filter((part) => part !== "")
|
|
1134
|
+
.join("; ") || "—",
|
|
1135
|
+
);
|
|
1136
|
+
const desc = escapeMarkdownTableCell(
|
|
1137
|
+
locale === "zh" ? opt.description.zh : opt.description.en,
|
|
1138
|
+
);
|
|
1139
|
+
const spelling = `\`${escapeMarkdownTableCell(opt.canonical)}\``;
|
|
1140
|
+
const form = escapeMarkdownTableCell(opt.form);
|
|
1141
|
+
lines.push(
|
|
1142
|
+
`| ${spelling} | ${aliases} | ${value} | ${required} | ${repeatable} | ${form} | ${modePhase} | ${desc} |`,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
lines.push("");
|
|
1146
|
+
}
|
|
1147
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Replace the generated region inside a README body, or append one.
|
|
1152
|
+
* Returns the full file text.
|
|
1153
|
+
*/
|
|
1154
|
+
export function applyReadmeOptionsSection(
|
|
1155
|
+
readmeText: string,
|
|
1156
|
+
locale: "en" | "zh",
|
|
1157
|
+
): string {
|
|
1158
|
+
const section = `${README_BEGIN}\n${renderReadmeOptionsMarkdown(locale)}${README_END}\n`;
|
|
1159
|
+
const beginIdx = readmeText.indexOf(README_BEGIN);
|
|
1160
|
+
const endIdx = readmeText.indexOf(README_END);
|
|
1161
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
1162
|
+
const afterEnd = endIdx + README_END.length;
|
|
1163
|
+
// Consume a single trailing newline after the end marker when present.
|
|
1164
|
+
const tailStart =
|
|
1165
|
+
readmeText[afterEnd] === "\n" ? afterEnd + 1 : afterEnd;
|
|
1166
|
+
return (
|
|
1167
|
+
readmeText.slice(0, beginIdx) + section + readmeText.slice(tailStart)
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
// No markers yet — append before EOF.
|
|
1171
|
+
const base = readmeText.endsWith("\n") ? readmeText : `${readmeText}\n`;
|
|
1172
|
+
return `${base}\n${section}`;
|
|
1173
|
+
}
|