@akagilnc/pi-workflow-roles 0.1.1941 → 0.1.2014

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/public-cli/main.js +2470 -88
  2. package/dist/reviewer-construction.js +33 -2
  3. package/dist/reviewer-dispatch.js +142 -9
  4. package/dist/reviewer-execution-ledger.js +5 -1
  5. package/dist/reviewer-pinned-git.js +139 -10
  6. package/extensions/role-runtime.ts +2 -1
  7. package/package.json +1 -1
  8. package/src/atomic-write.ts +26 -0
  9. package/src/collector-github.ts +94 -0
  10. package/src/ledger-session-read.ts +260 -0
  11. package/src/package-contracts/reviewer-output.ts +2 -0
  12. package/src/public-cli/cli.ts +24 -0
  13. package/src/public-cli/invocation.ts +352 -1
  14. package/src/public-cli/main.ts +21 -2
  15. package/src/public-cli/registry.ts +18 -1
  16. package/src/public-cli/reviewer-run.ts +13 -0
  17. package/src/public-cli/settlement.ts +13 -0
  18. package/src/public-cli/taishi-run.ts +235 -0
  19. package/src/reviewer-construction.ts +81 -3
  20. package/src/reviewer-dispatch.ts +189 -9
  21. package/src/reviewer-execution-ledger.ts +5 -1
  22. package/src/reviewer-pinned-git.ts +154 -11
  23. package/src/reviewer-role.ts +8 -1
  24. package/src/reviewer-settlement.ts +4 -0
  25. package/src/role-runtime.ts +21 -1
  26. package/src/run-terminal-artifacts.ts +231 -0
  27. package/src/taishi-cohort.ts +232 -0
  28. package/src/taishi-entry.ts +429 -0
  29. package/src/taishi-index.ts +269 -0
  30. package/src/taishi-ledger.ts +466 -0
  31. package/src/taishi-median.ts +15 -0
  32. package/src/taishi-metric-families/acceptance-success-rework.ts +346 -0
  33. package/src/taishi-metric-families/b2-frame-buckets-actions.ts +274 -0
  34. package/src/taishi-metric-families/leg-wall-clock.ts +90 -0
  35. package/src/taishi-metric-families/round-timeline.ts +201 -0
  36. package/src/taishi-metric-families.ts +36 -0
  37. package/src/taishi-metric-family.ts +41 -0
  38. package/src/taishi-model-groups.ts +198 -0
  39. package/src/taishi-page.ts +320 -0
  40. package/src/ticket-trajectory.ts +9 -62
@@ -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 {
@@ -100,6 +100,15 @@ function buildModelArgs(model: SeatModelConfig | undefined): string[] {
100
100
  ];
101
101
  }
102
102
 
103
+ /** Encode admitted ticketNumber as CLI argv for activation/resume (no defaults). */
104
+ function buildReviewerTicketNumberArgs(
105
+ ticketNumber: number | undefined,
106
+ ): string[] {
107
+ return ticketNumber === undefined
108
+ ? []
109
+ : ["--ak-review-ticket-number", String(ticketNumber)];
110
+ }
111
+
103
112
  /**
104
113
  * Build Internal activation extra-args for an admitted Reviewer run.
105
114
  * Package code-review Skill is forced via --skill; ambient home skills stay off.
@@ -122,6 +131,7 @@ export function buildReviewerActivationExtraArgs(
122
131
  admitted.authorityRefs.length === 0
123
132
  ? []
124
133
  : ["--ak-review-authority-refs", JSON.stringify([...admitted.authorityRefs])];
134
+ const ticketNumberArgs = buildReviewerTicketNumberArgs(admitted.ticketNumber);
125
135
  return [
126
136
  "--no-skills",
127
137
  "--skill",
@@ -139,6 +149,7 @@ export function buildReviewerActivationExtraArgs(
139
149
  "--ak-review-base",
140
150
  admitted.baseRevision,
141
151
  ...authorityRefArgs,
152
+ ...ticketNumberArgs,
142
153
  "--mode",
143
154
  "json",
144
155
  ...buildModelArgs(options.model),
@@ -166,6 +177,7 @@ export function buildReviewerResumeActivationExtraArgs(
166
177
  admitted.authorityRefs.length === 0
167
178
  ? []
168
179
  : ["--ak-review-authority-refs", JSON.stringify([...admitted.authorityRefs])];
180
+ const ticketNumberArgs = buildReviewerTicketNumberArgs(admitted.ticketNumber);
169
181
  return [
170
182
  "--no-skills",
171
183
  "--skill",
@@ -183,6 +195,7 @@ export function buildReviewerResumeActivationExtraArgs(
183
195
  "--ak-review-base",
184
196
  admitted.baseRevision,
185
197
  ...authorityRefArgs,
198
+ ...ticketNumberArgs,
186
199
  "--mode",
187
200
  "json",
188
201
  ...buildModelArgs(options.model),
@@ -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" }
@@ -3176,6 +3185,10 @@ export async function publishReviewerArtifacts(
3176
3185
  ...(admitted.instructionEmpty
3177
3186
  ? {}
3178
3187
  : { callerProvenance: admitted.instruction }),
3188
+ // Self-fetch Spec bytes + source annotation when primary path produced material (#343).
3189
+ ...(options.reviewerReceipt?.specFetchedMaterial === undefined
3190
+ ? {}
3191
+ : { specFetchedMaterial: options.reviewerReceipt.specFetchedMaterial }),
3179
3192
  attachments: admitted.attachments.map((a) => ({
3180
3193
  provenancePath: a.provenancePath,
3181
3194
  frozenPath: a.frozenPath,
@@ -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
+ }
@@ -101,12 +101,49 @@ export function reviewerAxisMethodAdapter(axis: ReviewerAxis): string {
101
101
  }
102
102
 
103
103
  export type ConstructedReviewerLeg = Readonly<{ axis: "standards" | "spec"; prompt: ReviewerPromptText }>;
104
+
105
+ /** Ticket-number provenance for Spec self-fetch (#343). High-priority source wins. */
106
+ export type ReviewerTicketNumberSource =
107
+ | "typed-ticket-number"
108
+ | "branch-token"
109
+ | "commit-message";
110
+
111
+ export type ReviewerTicketNumberCandidate = Readonly<{
112
+ source: ReviewerTicketNumberSource;
113
+ ticketNumber: number;
114
+ }>;
115
+
116
+ /** One docs/adr path referenced by the fetched issue body. */
117
+ export type ReviewerFetchedAdr =
118
+ | Readonly<{ path: string; status: "present"; body: string }>
119
+ | Readonly<{ path: string; status: "missing" }>;
120
+
121
+ /**
122
+ * Actual Spec bytes pulled on the self-fetch primary path (fetch-then-store).
123
+ * Carried into Spec-child material and retained on the accepted dispatch for audit.
124
+ */
125
+ export type ReviewerSpecFetchedMaterial = Readonly<{
126
+ issueRef: string;
127
+ owner: string;
128
+ repo: string;
129
+ ticketNumber: number;
130
+ adopted: ReviewerTicketNumberCandidate;
131
+ abandoned: readonly ReviewerTicketNumberCandidate[];
132
+ issueBody: string;
133
+ adrs: readonly ReviewerFetchedAdr[];
134
+ }>;
135
+
104
136
  /**
105
137
  * Unique discovery product for Skill step 2: durable refs Spec child can read, or confirmed missing.
138
+ * Optional `fetched` is present only when the self-fetch primary path produced issue bytes.
106
139
  * Construction builds Standards/Spec solely from this product — no secondary launch decision.
107
140
  */
108
141
  export type ReviewerSpecAuthorityDiscovery =
109
- | Readonly<{ status: "available"; refs: readonly string[] }>
142
+ | Readonly<{
143
+ status: "available";
144
+ refs: readonly string[];
145
+ fetched?: ReviewerSpecFetchedMaterial;
146
+ }>
110
147
  | Readonly<{ status: "missing" }>;
111
148
  /** Spec-child cardinality decision recorded on the accepted dispatch. */
112
149
  export type ReviewerSpecDisposition = "launched" | "skipped-missing";
@@ -123,6 +160,8 @@ export type ConstructedReviewerDispatch = Readonly<{
123
160
  authorityRefs: readonly string[];
124
161
  /** Honest Spec-child disposition: launched, or skipped after confirmed missing Spec. */
125
162
  specDisposition: ReviewerSpecDisposition;
163
+ /** Self-fetch bytes + source annotation when primary path produced material. */
164
+ specFetchedMaterial?: ReviewerSpecFetchedMaterial;
126
165
  legs: readonly ConstructedReviewerLeg[];
127
166
  }>;
128
167
 
@@ -138,6 +177,35 @@ export function reviewerAuthorityRefsMaterial(authorityRefs: readonly string[]):
138
177
  ].join("\n");
139
178
  }
140
179
 
180
+ /**
181
+ * Spec-only material carrier for self-fetched issue bytes + source annotation (#343).
182
+ * Actual issue body and referenced ADR bytes are embedded for audit (fetch-then-store).
183
+ * Single JSON payload keeps external issue/ADR bytes inside structured field values so they
184
+ * cannot forge package framing markers on the same text layer (no plain-text section protocol).
185
+ */
186
+ export function reviewerFetchedSpecMaterial(fetched: ReviewerSpecFetchedMaterial): string {
187
+ return [
188
+ "Authority-Fetched-Spec:",
189
+ JSON.stringify(
190
+ Object.freeze({
191
+ source: fetched.adopted.source,
192
+ ticketNumber: fetched.ticketNumber,
193
+ issueRef: fetched.issueRef,
194
+ abandoned: Object.freeze([...fetched.abandoned]),
195
+ issueBody: fetched.issueBody,
196
+ adrs: Object.freeze(
197
+ fetched.adrs.map((adr) =>
198
+ adr.status === "present"
199
+ ? Object.freeze({ path: adr.path, status: adr.status, body: adr.body })
200
+ : Object.freeze({ path: adr.path, status: adr.status }),
201
+ ),
202
+ ),
203
+ }),
204
+ ),
205
+ "These are self-fetched Spec grounding materials. Do not invent Spec prose from caller instruction.",
206
+ ].join("\n");
207
+ }
208
+
141
209
  /** Deterministic compiler: fixed target/range plus discovery product in, dispatch text out. */
142
210
  export function constructReviewerDispatch(input: {
143
211
  identity: string;
@@ -152,6 +220,10 @@ export function constructReviewerDispatch(input: {
152
220
  const authorityRefs = Object.freeze(
153
221
  input.specAuthority.status === "available" ? [...input.specAuthority.refs] : [],
154
222
  );
223
+ const specFetchedMaterial =
224
+ input.specAuthority.status === "available" && input.specAuthority.fetched !== undefined
225
+ ? input.specAuthority.fetched
226
+ : undefined;
155
227
  const specDisposition: ReviewerSpecDisposition = launchSpec ? "launched" : "skipped-missing";
156
228
  const common = [
157
229
  `Target: ${input.range.target}`,
@@ -170,8 +242,13 @@ export function constructReviewerDispatch(input: {
170
242
  const legs = axes.map((x) => {
171
243
  const parts = [common, reviewerAxisMethodAdapter(x.axis)];
172
244
  // Spec evidence-child only — never Standards or a parent replacement Spec leg.
173
- if (x.axis === "spec" && authorityRefs.length > 0) {
174
- parts.push(reviewerAuthorityRefsMaterial(authorityRefs));
245
+ if (x.axis === "spec") {
246
+ if (specFetchedMaterial !== undefined) {
247
+ parts.push(reviewerFetchedSpecMaterial(specFetchedMaterial));
248
+ }
249
+ if (authorityRefs.length > 0) {
250
+ parts.push(reviewerAuthorityRefsMaterial(authorityRefs));
251
+ }
175
252
  }
176
253
  return Object.freeze({
177
254
  axis: x.axis,
@@ -189,6 +266,7 @@ export function constructReviewerDispatch(input: {
189
266
  range: input.range,
190
267
  authorityRefs,
191
268
  specDisposition,
269
+ ...(specFetchedMaterial === undefined ? {} : { specFetchedMaterial }),
192
270
  legs: Object.freeze(legs),
193
271
  });
194
272
  }