@akagilnc/pi-workflow-roles 0.1.1893 → 0.1.1941

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.
@@ -11,6 +11,21 @@ export const REVIEWER_AXIS_OUTPUT_ADAPTER = Object.freeze({
11
11
  version: 1,
12
12
  implementationSha256: sha256Hex("reviewer-axis-output:v1:single-axis-verbatim-report+standards-three-priorities"),
13
13
  });
14
+ /**
15
+ * Package-owned #1185 review verification cadence.
16
+ * Single true source consumed by two real actor carriers: parent Reviewer system-prompt injection
17
+ * and evidence-child system prompt. Not part of axis-adapter identity or axis leg prompts.
18
+ * Graded guidance only: focused tests allowed; full suite not forbidden but avoid frequent every-round reruns.
19
+ * Does not narrow ADR 0064 tools and adds no command ban, allowlist, or runtime block.
20
+ */
21
+ export const REVIEWER_VERIFICATION_BOUNDARY = [
22
+ "Verification-Boundary: you may run focused product tests during this review turn when independent verification needs them.",
23
+ "A full repository test suite is not forbidden, but do not re-run it every review round;",
24
+ "prefer once at family wrap-up unless this review specifically requires a broader run.",
25
+ "Slice and review work should not trigger frequent full-suite reruns.",
26
+ "Independently discover test facts (including existing coder/fixer receipts and any tests you run);",
27
+ "do not treat caller prose as the source of those facts.",
28
+ ].join(" ");
14
29
  /** Typed Standards conclusion keys owned by reviewer construction (presentation labels are not the contract). */
15
30
  export const REVIEWER_STANDARDS_CONCLUSION_KEYS = Object.freeze([
16
31
  "constitutionality",
@@ -63,8 +78,22 @@ export function reviewerAxisMethodAdapter(axis) {
63
78
  "The returned report is the complete output envelope and its UTF-8 bytes are preserved verbatim; no heading parser, sanitizer, section splitter, rewrite, aggregation, or replacement leg follows.",
64
79
  ].join("\n");
65
80
  }
66
- /** Deterministic compiler: fixed target/range plus packaged Skill in, dispatch text out. */
81
+ /**
82
+ * Spec-only evidence-child material carrier for durable authority references.
83
+ * Exact values preserved; no prose extraction and no Standards/parent injection.
84
+ */
85
+ export function reviewerAuthorityRefsMaterial(authorityRefs) {
86
+ return [
87
+ "Authority-Refs:",
88
+ JSON.stringify(Object.freeze([...authorityRefs])),
89
+ "These are durable authority references only. Read them as Spec grounding materials; do not invent Spec prose from caller instruction.",
90
+ ].join("\n");
91
+ }
92
+ /** Deterministic compiler: fixed target/range plus discovery product in, dispatch text out. */
67
93
  export function constructReviewerDispatch(input) {
94
+ const launchSpec = input.specAuthority.status === "available";
95
+ const authorityRefs = Object.freeze(input.specAuthority.status === "available" ? [...input.specAuthority.refs] : []);
96
+ const specDisposition = launchSpec ? "launched" : "skipped-missing";
68
97
  const common = [
69
98
  `Target: ${input.range.target}`,
70
99
  `Base: ${input.range.base}`,
@@ -76,11 +105,20 @@ export function constructReviewerDispatch(input) {
76
105
  "Fixed-Range:",
77
106
  JSON.stringify(input.range, null, 2),
78
107
  ].join("\n");
79
- const axes = [{ axis: "standards" }, { axis: "spec" }];
80
- const legs = axes.map((x) => Object.freeze({
81
- axis: x.axis,
82
- prompt: `${common}\n${reviewerAxisMethodAdapter(x.axis)}\n`,
83
- }));
108
+ const axes = launchSpec
109
+ ? [{ axis: "standards" }, { axis: "spec" }]
110
+ : [{ axis: "standards" }];
111
+ const legs = axes.map((x) => {
112
+ const parts = [common, reviewerAxisMethodAdapter(x.axis)];
113
+ // Spec evidence-child only — never Standards or a parent replacement Spec leg.
114
+ if (x.axis === "spec" && authorityRefs.length > 0) {
115
+ parts.push(reviewerAuthorityRefsMaterial(authorityRefs));
116
+ }
117
+ return Object.freeze({
118
+ axis: x.axis,
119
+ prompt: `${parts.join("\n")}\n`,
120
+ });
121
+ });
84
122
  return Object.freeze({
85
123
  identity: input.identity,
86
124
  recipe: "reviewer-common-bundle-v1",
@@ -90,6 +128,8 @@ export function constructReviewerDispatch(input) {
90
128
  }),
91
129
  targetSnapshot: input.target,
92
130
  range: input.range,
131
+ authorityRefs,
132
+ specDisposition,
93
133
  legs: Object.freeze(legs),
94
134
  });
95
135
  }
@@ -3,10 +3,67 @@ import { immutableReviewerPin } from "./reviewer-pinned-git.js";
3
3
  export { createReviewerPinnedGitReader, immutableReviewerPin } from "./reviewer-pinned-git.js";
4
4
  import { isReviewerPromptText, sameReviewerPromptText } from "./reviewer-prompt-identity.js";
5
5
  import { sha256Hex } from "./sha256.js";
6
- import { constructReviewerDispatch } from "./reviewer-construction.js";
6
+ import { constructReviewerDispatch, } from "./reviewer-construction.js";
7
+ export {} from "./reviewer-construction.js";
7
8
  import { ReviewerCorrectablePreflightError } from "./reviewer-preflight-error.js";
8
9
  export { sha256Hex } from "./sha256.js";
9
10
  export { isReviewerPromptText as isReviewerPromptIdentity, sameReviewerPromptText as sameReviewerPromptIdentity } from "./reviewer-prompt-identity.js";
11
+ const GENERIC_FEATURE_TOKENS = new Set(["", "head", "main", "master", "trunk", "develop", "development"]);
12
+ /** Conventional branch shells that must not hide the feature token (feat/login → login). */
13
+ const BRANCH_SHELL_PREFIX = /^(?:feat|feature|fix|bugfix|hotfix|chore|docs|refactor)-/;
14
+ function normalizeFeatureToken(value) {
15
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16
+ }
17
+ /** Expand one branch/ref name into matchable tokens, stripping conventional shells. */
18
+ function expandFeatureTokens(raw) {
19
+ const normalized = normalizeFeatureToken(raw);
20
+ if (normalized.length === 0)
21
+ return Object.freeze([]);
22
+ const tokens = new Set([normalized]);
23
+ const stripped = normalized.replace(BRANCH_SHELL_PREFIX, "");
24
+ if (stripped.length > 0 && stripped !== normalized)
25
+ tokens.add(stripped);
26
+ return Object.freeze([...tokens]);
27
+ }
28
+ /**
29
+ * Unique production owner of code-review Skill step 2 Spec discovery.
30
+ * Directly yields durable refs Spec child can read, or confirmed missing.
31
+ * - Supplied authorityRefs ⇒ available with those refs as material.
32
+ * - Matching pinned-target docs/specs/.scratch paths ⇒ available with those paths as material.
33
+ * - Commit message bare #N without durable source ⇒ missing (not available).
34
+ * Only confirmed absence yields missing; other Git/I-O failures keep true cause for preflight.
35
+ * Construction builds Standards/Spec solely from this product.
36
+ */
37
+ export async function discoverReviewerSpecAuthority(input) {
38
+ if (input.authorityRefs.length > 0) {
39
+ return Object.freeze({
40
+ status: "available",
41
+ refs: Object.freeze([...input.authorityRefs]),
42
+ });
43
+ }
44
+ const featureTokens = await input.reader.featureTokens();
45
+ const tokens = [
46
+ ...new Set(featureTokens
47
+ .flatMap((raw) => expandFeatureTokens(raw))
48
+ .filter((token) => token.length >= 3 && !GENERIC_FEATURE_TOKENS.has(token))),
49
+ ];
50
+ if (tokens.length === 0) {
51
+ return Object.freeze({ status: "missing" });
52
+ }
53
+ // Pinned target tree only — Spec child cannot read live-worktree or gitignored paths.
54
+ const candidates = await input.reader.listSpecCandidatePaths();
55
+ const matched = candidates.filter((relativePath) => {
56
+ const normalizedPath = normalizeFeatureToken(relativePath);
57
+ return tokens.some((token) => normalizedPath.includes(token));
58
+ });
59
+ if (matched.length === 0) {
60
+ return Object.freeze({ status: "missing" });
61
+ }
62
+ return Object.freeze({
63
+ status: "available",
64
+ refs: Object.freeze(matched),
65
+ });
66
+ }
10
67
  export const REVIEWER_PREFLIGHT_VIOLATIONS = ["base-invalid", "range-invalid", "prompt-identity-invalid", "target-drift"];
11
68
  export class ReviewerPreflightError extends Error {
12
69
  code;
@@ -49,12 +106,18 @@ export function createReviewerDispatcher(d) {
49
106
  try {
50
107
  const base = await d.reader.resolve(baseRevision);
51
108
  const range = await d.reader.range(base);
109
+ const authorityRefs = Object.freeze([...(d.authorityRefs ?? [])]);
110
+ const specAuthority = await discoverReviewerSpecAuthority({
111
+ authorityRefs,
112
+ reader: d.reader,
113
+ });
52
114
  dispatch = constructReviewerDispatch({
53
115
  identity,
54
116
  canonicalSkill: d.canonicalSkill,
55
117
  target,
56
118
  range,
57
119
  ...(d.reviewScopeKeys === undefined ? {} : { reviewScopeKeys: d.reviewScopeKeys }),
120
+ specAuthority,
58
121
  });
59
122
  if (!sameReviewerPinnedTarget(await d.reader.snapshot(), target)) {
60
123
  throw new ReviewerPreflightError("target-drift", "pinned target snapshot changed before child execution");
@@ -5,7 +5,8 @@ export function projectAcceptedDispatch(dispatch) {
5
5
  return {
6
6
  source: "reviewer-dispatch", type: "accepted", identity: dispatch.identity,
7
7
  recipe: dispatch.recipe, input: dispatch.input, target: dispatch.targetSnapshot,
8
- range: dispatch.range, legs: dispatch.legs,
8
+ range: dispatch.range, authorityRefs: dispatch.authorityRefs,
9
+ specDisposition: dispatch.specDisposition, legs: dispatch.legs,
9
10
  };
10
11
  }
11
12
  export function projectReviewerDispatchOutcome(ledger, dispatch, result) {
@@ -142,5 +142,38 @@ export async function createReviewerPinnedGitReader(root = process.cwd()) {
142
142
  invalid("range-invalid", "review range must contain a non-empty diff between base and pinned target");
143
143
  return Object.freeze({ base: mergeBase, target: targetHead, diffCommand, diffSha256: sha256Hex(Uint8Array.from(diff)), commits: Object.freeze(commitsText ? commitsText.split("\n") : []) });
144
144
  },
145
+ async featureTokens() {
146
+ // Pinned ref snapshot is the target-tree fact — no live branch/symbolic-ref walk,
147
+ // no catch-to-empty. Detached/remote-only tips surface via refs/remotes/* entries.
148
+ const names = new Set();
149
+ for (const [refName, entry] of Object.entries(pin.refs)) {
150
+ if (entry.peeledCommitId !== targetHead)
151
+ continue;
152
+ const short = refName.startsWith("refs/heads/")
153
+ ? refName.slice("refs/heads/".length)
154
+ : refName.startsWith("refs/tags/")
155
+ ? refName.slice("refs/tags/".length)
156
+ : refName.startsWith("refs/remotes/")
157
+ ? refName.slice("refs/remotes/".length).replace(/^[^/]+\//, "")
158
+ : refName;
159
+ if (short.trim() !== "")
160
+ names.add(short.trim());
161
+ }
162
+ return Object.freeze([...names]);
163
+ },
164
+ async listSpecCandidatePaths() {
165
+ const roots = ["docs", "specs", ".scratch"];
166
+ // git ls-tree exits 0 with empty stdout when none of the roots exist at targetHead.
167
+ // Other Git/I-O failures keep their true cause for the dispatch preflight path.
168
+ const text = await gitText(repositoryRoot, [
169
+ "ls-tree",
170
+ "-r",
171
+ "--name-only",
172
+ targetHead,
173
+ "--",
174
+ ...roots,
175
+ ]);
176
+ return Object.freeze(text === "" ? [] : text.split("\n").filter((line) => line.length > 0));
177
+ },
145
178
  });
146
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.1893",
3
+ "version": "0.1.1941",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -30,15 +30,26 @@ import {
30
30
  type AuditorParentAttemptBinding,
31
31
  } from "./compliance-transport.ts";
32
32
  import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
33
+ import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
34
+ import { REVIEWER_VERIFICATION_BOUNDARY } from "./reviewer-construction.ts";
33
35
  import type { ReviewerPromptText } from "./reviewer-prompt-identity.ts";
34
36
  import { createStreamIdleGuard, isStreamIdleTimeoutError } from "./stream-idle-guard.ts";
35
- import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
36
37
  import {
37
38
  hasUpstreamErrorTestimony,
38
39
  isNonSuccessHttpStatus,
39
40
  projectConfirmedRemotePayload,
40
41
  } from "./upstream-error-testimony.ts";
41
42
 
43
+ /** Package-owned system prompt for Reviewer Standards/Spec evidence children (private carrier). */
44
+ function buildEvidenceChildSystemPrompt(): string {
45
+ return [
46
+ "Work only in the supplied workspace.",
47
+ "Use the available evidence tools to investigate. Do not commit, push, or mutate remotes.",
48
+ REVIEWER_VERIFICATION_BOUNDARY,
49
+ "Return one substantive non-blank report.",
50
+ ].join("\n");
51
+ }
52
+
42
53
  // ── shared constants / types ──────────────────────────────────────────────
43
54
 
44
55
  export const AUDITOR_TURN_LIMIT = 32;
@@ -561,11 +572,7 @@ export async function executeEvidenceChild(
561
572
  model: inherited.model,
562
573
  thinkingLevel: context.thinkingLevel ?? "off",
563
574
  modelRuntime: inherited.runtime,
564
- systemPrompt: [
565
- "Work only in the supplied workspace.",
566
- "Use the available evidence tools to investigate. Do not commit, push, or mutate remotes.",
567
- "Return one substantive non-blank report.",
568
- ].join("\n"),
575
+ systemPrompt: buildEvidenceChildSystemPrompt(),
569
576
  sessionManager: createRecordSession({
570
577
  cwd: workspace,
571
578
  kind: "evidence-children",
@@ -28,14 +28,19 @@ import {
28
28
  } from "./human-format.ts";
29
29
  import {
30
30
  DEFAULT_REFRESH_BOUNDARY_SECONDS,
31
+ buildTicketTrajectoryBookIndex,
31
32
  loadTicketTrajectoryRuns,
33
+ loadUnboundTrajectoryRuns,
32
34
  renderTicketTrajectoryStationHtml,
35
+ type TicketTrajectoryBookIndex,
33
36
  type TicketTrajectoryRun,
34
37
  type TrajectoryClock,
35
38
  type TrajectoryScheduler,
39
+ type UnboundTrajectoryRun,
36
40
  } from "./ticket-trajectory.ts";
37
41
  import type { BoardSnapshot, SnapshotTicket, TicketIssueState } from "./ticket-snapshot.ts";
38
42
 
43
+
39
44
  /** Unaccepted latest-run mtime bands (page-visible thresholds). */
40
45
  export const UNACCEPTED_FLYING_MS = 2 * 60 * 1000;
41
46
  export const UNACCEPTED_WATCH_MS = 15 * 60 * 1000;
@@ -403,6 +408,40 @@ function latestKnownStation(runs: readonly TicketTrajectoryRun[]): string | unde
403
408
  return latest.station;
404
409
  }
405
410
 
411
+ /**
412
+ * Unbound activation card for the existing unknown-set presentation seam.
413
+ * Not a ticket: no issue number, never placed in a yamen column.
414
+ */
415
+ function renderUnboundRunCard(input: {
416
+ bookKey: string;
417
+ unbound: UnboundTrajectoryRun;
418
+ }): string {
419
+ const { run, role, observedAt } = input.unbound;
420
+ const correlationId = input.unbound.correlationId;
421
+ const stationLabel =
422
+ run.station === "unknown" ? "未知站" : (YAMEN_LABELS[run.station] ?? run.station);
423
+ const corrAttr = correlationId === undefined ? "" : ` data-correlation="${attr(correlationId)}"`;
424
+ const corrMeta =
425
+ correlationId === undefined ? "" : ` · corr ${escapeHtml(correlationId)}`;
426
+ return [
427
+ `<article class="ticket unbound-run"`,
428
+ ` data-unbound-run="true"`,
429
+ ` data-book="${attr(input.bookKey)}"`,
430
+ ` data-run-id="${attr(run.runId)}"`,
431
+ corrAttr,
432
+ ` data-placement="unknown"`,
433
+ `>`,
434
+ `<header class="ticket-header">`,
435
+ `<span class="ticket-title">未绑定 · ${escapeHtml(run.runId)}</span>`,
436
+ `<span class="yamen-tag yamen-unknown" data-yamen="unknown">${escapeHtml(stationLabel)}</span>`,
437
+ `</header>`,
438
+ `<p class="unbound-meta" data-unbound-meta="true">`,
439
+ `role ${escapeHtml(role)}${corrMeta} · ${escapeHtml(observedAt)}`,
440
+ `</p>`,
441
+ `</article>`,
442
+ ].join("");
443
+ }
444
+
406
445
  function renderTicketArticle(input: {
407
446
  bookKey: string;
408
447
  prepared: PreparedTicket;
@@ -681,8 +720,9 @@ async function prepareTicket(
681
720
  ledgerDir: string,
682
721
  ticket: SnapshotTicket,
683
722
  now: Date,
723
+ bookIndex?: TicketTrajectoryBookIndex,
684
724
  ): Promise<PreparedTicket> {
685
- const runs = await loadTicketTrajectoryRuns(ledgerDir, ticket.issueNumber);
725
+ const runs = await loadTicketTrajectoryRuns(ledgerDir, ticket.issueNumber, bookIndex);
686
726
  const pending = ticket.state !== "closed" && runs.length === 0;
687
727
  const currentState = decideTicketCurrentState({
688
728
  ticketState: ticket.state,
@@ -862,9 +902,11 @@ async function renderLaneHtml(
862
902
  now: Date,
863
903
  options?: { hidden?: boolean },
864
904
  ): Promise<RenderedLane> {
905
+ // One book-level flat-run index shared by every ticket in this lane.
906
+ const bookIndex = await buildTicketTrajectoryBookIndex(ledgerDir);
865
907
  const prepared = new Map<number, PreparedTicket>();
866
908
  for (const ticket of tickets) {
867
- prepared.set(ticket.issueNumber, await prepareTicket(ledgerDir, ticket, now));
909
+ prepared.set(ticket.issueNumber, await prepareTicket(ledgerDir, ticket, now, bookIndex));
868
910
  }
869
911
 
870
912
  const childrenByParent = buildDirectChildrenByParent(prepared);
@@ -980,6 +1022,20 @@ async function renderLaneHtml(
980
1022
  list.push(entry);
981
1023
  byPlacement.set(entry.placement, list);
982
1024
  }
1025
+
1026
+ // Unbound flat runs: isolated from every ticket, honest on the unknown seam.
1027
+ const unboundRuns = await loadUnboundTrajectoryRuns(ledgerDir, bookIndex);
1028
+ for (const unbound of unboundRuns) {
1029
+ unknownEntries.push({
1030
+ placement: "unknown",
1031
+ band: 0,
1032
+ activityMs: unbound.run.mtimeMs,
1033
+ // Sort after real tickets; no issue identity (unbound is not a ticket).
1034
+ issueNumber: Number.MAX_SAFE_INTEGER,
1035
+ html: renderUnboundRunCard({ bookKey, unbound }),
1036
+ });
1037
+ }
1038
+
983
1039
  unknownEntries.sort(entryCompare);
984
1040
  unknownTickets.sort((a, b) => a.issueNumber - b.issueNumber);
985
1041
 
@@ -1469,7 +1525,8 @@ export async function renderFactoryBoardHtml(
1469
1525
  generatedAt,
1470
1526
  );
1471
1527
  }
1472
- const lane = await renderLaneHtml(
1528
+ let lane: RenderedLane;
1529
+ lane = await renderLaneHtml(
1473
1530
  book.bookKey,
1474
1531
  resolve(book.ledgerDir),
1475
1532
  bookSnap.tickets,
@@ -26,11 +26,15 @@ export type RuntimeReviewerAcceptedBatch = Readonly<{
26
26
  identity: string;
27
27
  legs: readonly Readonly<{ axis: "standards" | "spec"; prompt: ReviewerReceiptPrompt }>[];
28
28
  }>;
29
+ /** Honest Spec-child disposition on the receipt face. */
30
+ export type RuntimeReviewerSpecDisposition = "launched" | "skipped-missing";
29
31
  export type RuntimeReviewerReceiptV2 = Readonly<{
30
32
  version: 2;
31
33
  status: "completed" | "refused";
32
34
  diagnostic?: string;
33
35
  acceptedBatch?: RuntimeReviewerAcceptedBatch;
36
+ /** Present on accepted batches: launched Spec child, or skipped after confirmed missing Spec. */
37
+ specDisposition?: RuntimeReviewerSpecDisposition;
34
38
  reports: Readonly<Partial<Record<"standards" | "spec", VerbatimChildReport>>>;
35
39
  outcomes: Readonly<Partial<Record<"standards" | "spec", RuntimeReviewerOutcome>>>;
36
40
  identities: Readonly<{
@@ -100,6 +104,21 @@ export function validateRuntimeReviewerReceipt(output: unknown): RuntimeReviewer
100
104
  throw new Error("Successful Reviewer outcome lacks report");
101
105
  if (status === "failed" && report !== undefined) throw new Error("Failed Reviewer outcome cannot bind a report");
102
106
  }
107
+
108
+ // One read inside the existing accepted-leg consistency cycle — not a second validator.
109
+ // launched ⇔ Standards+Spec; skipped-missing ⇔ Standards only.
110
+ const specDisposition = read(output, "specDisposition");
111
+ if (specDisposition === "launched") {
112
+ if (expectedAxes.length !== 2 || expectedAxes[0] !== "standards" || expectedAxes[1] !== "spec") {
113
+ throw new Error("Reviewer specDisposition launched requires Standards+Spec accepted legs");
114
+ }
115
+ } else if (specDisposition === "skipped-missing") {
116
+ if (expectedAxes.length !== 1 || expectedAxes[0] !== "standards") {
117
+ throw new Error("Reviewer specDisposition skipped-missing requires Standards-only accepted legs");
118
+ }
119
+ } else if (specDisposition !== undefined) {
120
+ throw new Error("Invalid Reviewer specDisposition");
121
+ }
103
122
  }
104
123
  return output as RuntimeReviewerReceiptV2;
105
124
  }
@@ -61,6 +61,69 @@ export {
61
61
  export { CliUsageError } from "./cli-errors.ts";
62
62
  export type { CliIo } from "./cli-io.ts";
63
63
 
64
+ /**
65
+ * Sole production map: public role command → argv parser.
66
+ * cli handlers consume this table; no parallel set.
67
+ */
68
+ export const PUBLIC_ROLE_ARGV = {
69
+ judge: { parse: parseJudgeArgv },
70
+ coder: { parse: parseCoderArgv },
71
+ fixer: { parse: parseFixerArgv },
72
+ collector: { parse: parseCollectorArgv },
73
+ doctor: { parse: parseDoctorArgv },
74
+ merger: { parse: parseMergerArgv },
75
+ reviewer: { parse: parseReviewerArgv },
76
+ } as const;
77
+
78
+ type TakenPublicGlobalFlag =
79
+ | { flag: "help"; consume: 1 }
80
+ | { flag: "model"; consume: 1 | 2; value: string | undefined }
81
+ | { flag: "thinking"; consume: 1 | 2; raw: string | undefined };
82
+
83
+ /**
84
+ * If `argv[index]` is a public global flag, describe its span and payload.
85
+ * Sole global-flag grammar for parseArgv.
86
+ */
87
+ function takePublicGlobalFlag(
88
+ argv: readonly string[],
89
+ index: number,
90
+ ): TakenPublicGlobalFlag | undefined {
91
+ const token = argv[index];
92
+ if (token === undefined) return undefined;
93
+ if (token === "--help" || token === "-h") {
94
+ return { flag: "help", consume: 1 };
95
+ }
96
+ if (token === "--model") {
97
+ const value = argv[index + 1];
98
+ if (value === undefined) {
99
+ return { flag: "model", consume: 1, value: undefined };
100
+ }
101
+ return { flag: "model", consume: 2, value };
102
+ }
103
+ if (token.startsWith("--model=")) {
104
+ return {
105
+ flag: "model",
106
+ consume: 1,
107
+ value: token.slice("--model=".length),
108
+ };
109
+ }
110
+ if (token === "--thinking") {
111
+ const raw = argv[index + 1];
112
+ if (raw === undefined) {
113
+ return { flag: "thinking", consume: 1, raw: undefined };
114
+ }
115
+ return { flag: "thinking", consume: 2, raw };
116
+ }
117
+ if (token.startsWith("--thinking=")) {
118
+ return {
119
+ flag: "thinking",
120
+ consume: 1,
121
+ raw: token.slice("--thinking=".length),
122
+ };
123
+ }
124
+ return undefined;
125
+ }
126
+
64
127
  export type CliEnv = {
65
128
  home?: string;
66
129
  agentDir?: string;
@@ -167,44 +230,38 @@ function parseArgv(argv: readonly string[]): ParsedGlobal {
167
230
 
168
231
  // Global flags may appear before or after the subcommand
169
232
  // (`ak-role --model x roles` and `ak-role roles --model x`).
233
+ // Grammar authority: takePublicGlobalFlag above.
170
234
  while (args.length > 0) {
171
- const token = args.shift()!;
172
- if (token === "--") {
235
+ if (args[0] === "--") {
236
+ args.shift();
173
237
  positional.push(...args);
174
238
  break;
175
239
  }
176
- if (token === "--help" || token === "-h") {
177
- help = true;
178
- continue;
179
- }
180
- if (token === "--model") {
181
- const value = args.shift();
182
- if (value === undefined) throw new CliUsageError("--model requires a value");
183
- model = value;
184
- continue;
185
- }
186
- if (token.startsWith("--model=")) {
187
- model = token.slice("--model=".length);
188
- continue;
189
- }
190
- if (token === "--thinking") {
191
- const value = args.shift();
192
- if (value === undefined) throw new CliUsageError("--thinking requires a value");
193
- thinking = parseThinking(value);
194
- continue;
195
- }
196
- if (token.startsWith("--thinking=")) {
197
- thinking = parseThinking(token.slice("--thinking=".length));
198
- continue;
199
- }
200
- if (token.startsWith("-") && token !== "-") {
201
- // Subcommands may own additional flags later; only reject unknowns at the
202
- // top level when they appear before any positional command is collected
203
- // and the command is one that does not accept free flags (enforced below).
204
- positional.push(token);
240
+ const taken = takePublicGlobalFlag(args, 0);
241
+ if (taken !== undefined) {
242
+ if (taken.flag === "help") {
243
+ help = true;
244
+ args.splice(0, taken.consume);
245
+ continue;
246
+ }
247
+ if (taken.flag === "model") {
248
+ if (taken.value === undefined) {
249
+ throw new CliUsageError("--model requires a value");
250
+ }
251
+ model = taken.value;
252
+ args.splice(0, taken.consume);
253
+ continue;
254
+ }
255
+ if (taken.raw === undefined) {
256
+ throw new CliUsageError("--thinking requires a value");
257
+ }
258
+ thinking = parseThinking(taken.raw);
259
+ args.splice(0, taken.consume);
205
260
  continue;
206
261
  }
207
- positional.push(token);
262
+ // Subcommands may own additional flags later; unknown dashed tokens stay
263
+ // positional here (same as pre-unification parseArgv).
264
+ positional.push(args.shift()!);
208
265
  }
209
266
 
210
267
  const [command, ...rest] = positional;
@@ -626,7 +683,7 @@ export async function runAkRole(
626
683
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
627
684
  },
628
685
  io,
629
- parseJudgeArgv,
686
+ PUBLIC_ROLE_ARGV.judge.parse,
630
687
  );
631
688
  return {
632
689
  exitCode: result.exitCode,
@@ -669,7 +726,7 @@ export async function runAkRole(
669
726
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
670
727
  },
671
728
  io,
672
- parseCoderArgv,
729
+ PUBLIC_ROLE_ARGV.coder.parse,
673
730
  );
674
731
  return {
675
732
  exitCode: result.exitCode,
@@ -712,7 +769,7 @@ export async function runAkRole(
712
769
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
713
770
  },
714
771
  io,
715
- parseFixerArgv,
772
+ PUBLIC_ROLE_ARGV.fixer.parse,
716
773
  );
717
774
  return {
718
775
  exitCode: result.exitCode,
@@ -755,7 +812,7 @@ export async function runAkRole(
755
812
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
756
813
  },
757
814
  io,
758
- parseCollectorArgv,
815
+ PUBLIC_ROLE_ARGV.collector.parse,
759
816
  );
760
817
  return {
761
818
  exitCode: result.exitCode,
@@ -798,7 +855,7 @@ export async function runAkRole(
798
855
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
799
856
  },
800
857
  io,
801
- parseReviewerArgv,
858
+ PUBLIC_ROLE_ARGV.reviewer.parse,
802
859
  );
803
860
  return {
804
861
  exitCode: result.exitCode,
@@ -841,7 +898,7 @@ export async function runAkRole(
841
898
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
842
899
  },
843
900
  io,
844
- parseDoctorArgv,
901
+ PUBLIC_ROLE_ARGV.doctor.parse,
845
902
  );
846
903
  return {
847
904
  exitCode: result.exitCode,
@@ -884,7 +941,7 @@ export async function runAkRole(
884
941
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
885
942
  },
886
943
  io,
887
- parseMergerArgv,
944
+ PUBLIC_ROLE_ARGV.merger.parse,
888
945
  );
889
946
  return {
890
947
  exitCode: result.exitCode,
@@ -313,8 +313,9 @@ async function dispatchAdmittedCoder(input: {
313
313
  PI_CODING_AGENT_DIR: env.agentDir,
314
314
  AK_ROLE_RUN_DIR: admitted.runDirectory,
315
315
  };
316
- if (env.correlationId !== undefined && env.correlationId.trim() !== "") {
317
- childEnv.AK_CORRELATION_ID = env.correlationId;
316
+ const correlationId = admitted.correlationId ?? env.correlationId;
317
+ if (correlationId !== undefined && correlationId.trim() !== "") {
318
+ childEnv.AK_CORRELATION_ID = correlationId;
318
319
  }
319
320
 
320
321
  let result: ExplicitInternalPiResult;
@@ -487,7 +488,10 @@ export async function runPublicCoder(
487
488
 
488
489
  return await dispatchAdmittedCoder({
489
490
  admitted,
490
- env,
491
+ env: {
492
+ ...env,
493
+ ...(admitted.correlationId === undefined ? {} : { correlationId: admitted.correlationId }),
494
+ },
491
495
  io,
492
496
  extraArgs,
493
497
  lease,
@@ -581,7 +585,10 @@ export async function runPublicCoderResume(
581
585
 
582
586
  return await dispatchAdmittedCoder({
583
587
  admitted,
584
- env,
588
+ env: {
589
+ ...env,
590
+ ...(admitted.correlationId === undefined ? {} : { correlationId: admitted.correlationId }),
591
+ },
585
592
  io,
586
593
  extraArgs,
587
594
  lease,