@akagilnc/pi-workflow-roles 0.1.1918 → 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.
@@ -134,6 +134,11 @@ export type AdmittedReviewerInvocation = AdmittedRoleInvocationBase & {
134
134
  readonly role: "reviewer";
135
135
  /** Required fixed base revision for the pinned review target (ADR 0037). */
136
136
  readonly baseRevision: string;
137
+ /**
138
+ * Optional durable authority references/URLs frozen at admission.
139
+ * Spec evidence-child material only — never Standards, never invocation prose promotion.
140
+ */
141
+ readonly authorityRefs: readonly string[];
137
142
  };
138
143
 
139
144
  /** Mechanical envelope derived from the active ordinary two-parent merge. */
@@ -318,6 +323,8 @@ export type ParseReviewerArgvResult = {
318
323
  attachmentPaths: string[];
319
324
  /** Required fixed base revision for the pinned review target. */
320
325
  baseRevision: string;
326
+ /** Repeatable durable authority references/URLs (exact order preserved). */
327
+ authorityRefs: string[];
321
328
  project?: string;
322
329
  };
323
330
 
@@ -353,6 +360,25 @@ function requireOptionPath(
353
360
  return value;
354
361
  }
355
362
 
363
+ /**
364
+ * Public --authority-ref admission grammar (refs-only).
365
+ * Unique owner for fresh argv and durable resume restore — no string-only parallel.
366
+ * Accepts durable reference tokens as-is; rejects blank and inline Spec prose
367
+ * (whitespace-bearing sentences). Does not fetch, normalize, or judge content.
368
+ */
369
+ export function requireAuthorityRef(value: string | undefined): string {
370
+ if (value === undefined || value.trim() === "") {
371
+ throw new CliUsageError("--authority-ref requires a nonempty durable reference");
372
+ }
373
+ // Spec prose sentences contain whitespace; durable public refs are single tokens.
374
+ if (/\s/.test(value)) {
375
+ throw new CliUsageError(
376
+ "--authority-ref requires a durable reference, not inline Spec prose",
377
+ );
378
+ }
379
+ return value;
380
+ }
381
+
356
382
  /**
357
383
  * Parse Judge-specific argv after the `judge` token.
358
384
  * Rejects any public burden selector/hint and unknown flags.
@@ -1581,13 +1607,15 @@ export function buildDoctorTransportPrompt(
1581
1607
 
1582
1608
  /**
1583
1609
  * Parse Reviewer-specific argv after the `reviewer` token.
1584
- * Public flags: --project and required --base (the non-interactive CLI cannot answer the canonical fixed-point question).
1610
+ * Public flags: --project, required --base, optional repeatable --authority-ref.
1585
1611
  * Reviewer gathers its own evidence; users submit neither attachments nor capability packets.
1612
+ * Caller instruction remains scope/procedure provenance — not Spec authority.
1586
1613
  */
1587
1614
  export function parseReviewerArgv(
1588
1615
  args: readonly string[],
1589
1616
  ): ParseReviewerArgvResult {
1590
1617
  const attachmentPaths: string[] = [];
1618
+ const authorityRefs: string[] = [];
1591
1619
  let project: string | undefined;
1592
1620
  let baseRevision: string | undefined;
1593
1621
  const positional: string[] = [];
@@ -1615,6 +1643,14 @@ export function parseReviewerArgv(
1615
1643
  baseRevision = requireOptionPath("--base", token.slice("--base=".length));
1616
1644
  continue;
1617
1645
  }
1646
+ if (token === "--authority-ref") {
1647
+ authorityRefs.push(requireAuthorityRef(tokens.shift()));
1648
+ continue;
1649
+ }
1650
+ if (token.startsWith("--authority-ref=")) {
1651
+ authorityRefs.push(requireAuthorityRef(token.slice("--authority-ref=".length)));
1652
+ continue;
1653
+ }
1618
1654
  if (token.startsWith("-") && token !== "-") {
1619
1655
  throw new CliUsageError(`unknown reviewer option: ${token}`);
1620
1656
  }
@@ -1628,6 +1664,7 @@ export function parseReviewerArgv(
1628
1664
  instruction: positional.join(" "),
1629
1665
  attachmentPaths,
1630
1666
  baseRevision,
1667
+ authorityRefs,
1631
1668
  ...(project === undefined ? {} : { project }),
1632
1669
  };
1633
1670
  }
@@ -1639,6 +1676,8 @@ export type AdmitReviewerInvocationOptions = {
1639
1676
  instruction: string;
1640
1677
  attachmentPaths: readonly string[];
1641
1678
  baseRevision: string;
1679
+ /** Optional durable authority references/URLs; frozen unchanged at admission. */
1680
+ authorityRefs?: readonly string[];
1642
1681
  project?: string;
1643
1682
  createRunId?: () => string;
1644
1683
  };
@@ -1646,6 +1685,7 @@ export type AdmitReviewerInvocationOptions = {
1646
1685
  /**
1647
1686
  * Admit a Reviewer Role run on the fixed base only.
1648
1687
  * Caller instruction is optional provenance; Reviewer acquires issue/authority independently.
1688
+ * Optional authorityRefs are frozen as durable references only — not Spec prose.
1649
1689
  */
1650
1690
  export async function admitReviewerInvocation(
1651
1691
  options: AdmitReviewerInvocationOptions,
@@ -1656,6 +1696,9 @@ export async function admitReviewerInvocation(
1656
1696
  if (options.baseRevision.trim() === "") {
1657
1697
  throw new CliUsageError("--base requires a nonempty revision");
1658
1698
  }
1699
+ const authorityRefs = Object.freeze(
1700
+ (options.authorityRefs ?? []).map((ref) => requireAuthorityRef(ref)),
1701
+ );
1659
1702
 
1660
1703
  const projectRoot = resolve(options.project ?? options.cwd);
1661
1704
  const runId = (options.createRunId ?? uuidv7)();
@@ -1686,6 +1729,7 @@ export async function admitReviewerInvocation(
1686
1729
  instruction,
1687
1730
  instructionEmpty,
1688
1731
  baseRevision: options.baseRevision,
1732
+ authorityRefs: [...authorityRefs],
1689
1733
  attachments: attachments.map((a) => ({
1690
1734
  provenancePath: a.provenancePath,
1691
1735
  frozenPath: a.frozenPath,
@@ -1715,6 +1759,7 @@ export async function admitReviewerInvocation(
1715
1759
  sessionFile,
1716
1760
  admittedRequestPath,
1717
1761
  baseRevision: options.baseRevision,
1762
+ authorityRefs,
1718
1763
  ...ticketFields,
1719
1764
  };
1720
1765
  }
@@ -118,6 +118,10 @@ export function buildReviewerActivationExtraArgs(
118
118
  options.packageRoot,
119
119
  "code-review",
120
120
  );
121
+ const authorityRefArgs =
122
+ admitted.authorityRefs.length === 0
123
+ ? []
124
+ : ["--ak-review-authority-refs", JSON.stringify([...admitted.authorityRefs])];
121
125
  return [
122
126
  "--no-skills",
123
127
  "--skill",
@@ -134,6 +138,7 @@ export function buildReviewerActivationExtraArgs(
134
138
  "reviewer",
135
139
  "--ak-review-base",
136
140
  admitted.baseRevision,
141
+ ...authorityRefArgs,
137
142
  "--mode",
138
143
  "json",
139
144
  ...buildModelArgs(options.model),
@@ -157,6 +162,10 @@ export function buildReviewerResumeActivationExtraArgs(
157
162
  options.packageRoot,
158
163
  "code-review",
159
164
  );
165
+ const authorityRefArgs =
166
+ admitted.authorityRefs.length === 0
167
+ ? []
168
+ : ["--ak-review-authority-refs", JSON.stringify([...admitted.authorityRefs])];
160
169
  return [
161
170
  "--no-skills",
162
171
  "--skill",
@@ -173,6 +182,7 @@ export function buildReviewerResumeActivationExtraArgs(
173
182
  "reviewer",
174
183
  "--ak-review-base",
175
184
  admitted.baseRevision,
185
+ ...authorityRefArgs,
176
186
  "--mode",
177
187
  "json",
178
188
  ...buildModelArgs(options.model),
@@ -420,6 +430,7 @@ export async function runPublicReviewer(
420
430
  instruction: string;
421
431
  attachmentPaths: string[];
422
432
  baseRevision: string;
433
+ authorityRefs: string[];
423
434
  project?: string;
424
435
  },
425
436
  ): Promise<{
@@ -436,6 +447,7 @@ export async function runPublicReviewer(
436
447
  instruction: parsed.instruction,
437
448
  attachmentPaths: parsed.attachmentPaths,
438
449
  baseRevision: parsed.baseRevision,
450
+ authorityRefs: parsed.authorityRefs,
439
451
  ...(parsed.project === undefined ? {} : { project: parsed.project }),
440
452
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
441
453
  });
@@ -24,6 +24,7 @@ export {
24
24
  import type { FixerPhase } from "../package-contracts/fixer-output.ts";
25
25
  import type { FixerPrerequisite } from "../package-contracts/fixer-packet.ts";
26
26
  import {
27
+ requireAuthorityRef,
27
28
  type AdmittedCoderInvocation,
28
29
  type AdmittedFixerInvocation,
29
30
  type AdmittedJudgeInvocation,
@@ -389,6 +390,7 @@ type LoadedAdmittedRequestFields = {
389
390
  readonly prerequisitesPath?: string;
390
391
  readonly prerequisites?: readonly FixerPrerequisite[];
391
392
  readonly baseRevision?: string;
393
+ readonly authorityRefs?: readonly string[];
392
394
  readonly mergerInputPath?: string;
393
395
  readonly derived?: DerivedMergerEnvelope;
394
396
  readonly correlationId?: string;
@@ -463,6 +465,7 @@ async function loadResumableRunRecord(
463
465
  let prerequisitesPath: string | undefined;
464
466
  let prerequisites: readonly FixerPrerequisite[] | undefined;
465
467
  let baseRevision: string | undefined;
468
+ let authorityRefs: readonly string[] | undefined;
466
469
  let mergerInputPath: string | undefined;
467
470
  let derived: DerivedMergerEnvelope | undefined;
468
471
  let correlationId: string | undefined;
@@ -506,6 +509,19 @@ async function loadResumableRunRecord(
506
509
  ) {
507
510
  baseRevision = record.baseRevision;
508
511
  }
512
+ if (Array.isArray(record.authorityRefs)) {
513
+ // Reuse unique --authority-ref grammar; blank/inline prose must not resume as authority.
514
+ authorityRefs = Object.freeze(
515
+ record.authorityRefs.map((ref) => {
516
+ if (typeof ref !== "string") {
517
+ throw new CliUsageError(
518
+ "role run admitted authority refs must be durable reference strings",
519
+ );
520
+ }
521
+ return requireAuthorityRef(ref);
522
+ }),
523
+ );
524
+ }
509
525
  if (
510
526
  typeof record.mergerInputPath === "string" &&
511
527
  record.mergerInputPath.trim() !== ""
@@ -540,9 +556,12 @@ async function loadResumableRunRecord(
540
556
  correlationId = fromAdmitted.correlationId;
541
557
  ticketNumber = fromAdmitted.ticketNumber;
542
558
  }
543
- } catch {
559
+ } catch (error) {
560
+ // Preserve unique --authority-ref grammar failures; do not collapse to unreadable.
561
+ if (error instanceof CliUsageError) throw error;
544
562
  throw new CliUsageError(
545
563
  `role run admitted request is unreadable: ${runId}`,
564
+ { cause: error },
546
565
  );
547
566
  }
548
567
  if (correlationId === undefined || ticketNumber === undefined) {
@@ -589,6 +608,7 @@ async function loadResumableRunRecord(
589
608
  ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
590
609
  ...(prerequisites === undefined ? {} : { prerequisites }),
591
610
  ...(baseRevision === undefined ? {} : { baseRevision }),
611
+ ...(authorityRefs === undefined ? {} : { authorityRefs }),
592
612
  ...(mergerInputPath === undefined ? {} : { mergerInputPath }),
593
613
  ...(derived === undefined ? {} : { derived }),
594
614
  ...(correlationId === undefined ? {} : { correlationId }),
@@ -806,6 +826,7 @@ export async function loadResumableReviewerRun(
806
826
  sessionFile: loaded.run.sessionFile,
807
827
  admittedRequestPath: loaded.run.admittedRequestPath,
808
828
  baseRevision,
829
+ authorityRefs: Object.freeze([...(loaded.admittedFields.authorityRefs ?? [])]),
809
830
  ...restoredTicketFields(loaded.admittedFields),
810
831
  };
811
832
  return {
@@ -1255,6 +1255,7 @@ function reviewerDecisiveFacts(
1255
1255
  const axes = reviewerAxes(outcomes.readable ? outcomes.value : undefined);
1256
1256
  const reportAxes = reviewerAxes(reports.readable ? reports.value : undefined);
1257
1257
  const acceptedBatch = safelyRead(candidate, "acceptedBatch");
1258
+ const specDisposition = safelyRead(candidate, "specDisposition");
1258
1259
  const facts: Record<string, unknown> = {
1259
1260
  axes,
1260
1261
  reportAxes,
@@ -1262,6 +1263,12 @@ function reviewerDecisiveFacts(
1262
1263
  ...auditNoReceiptDecisiveFact(candidate),
1263
1264
  };
1264
1265
  if (status.readable && typeof status.value === "string") facts.reviewerStatus = status.value;
1266
+ if (
1267
+ specDisposition.readable &&
1268
+ (specDisposition.value === "launched" || specDisposition.value === "skipped-missing")
1269
+ ) {
1270
+ facts.specDisposition = specDisposition.value;
1271
+ }
1265
1272
  const diagnostic = safelyRead(candidate, "diagnostic");
1266
1273
  if (status.readable && status.value === "refused" && diagnostic.readable) {
1267
1274
  facts.diagnosticPresent = typeof diagnostic.value === "string" && diagnostic.value.trim().length > 0;
@@ -3165,6 +3172,7 @@ export async function publishReviewerArtifacts(
3165
3172
  sessionFile: admitted.sessionFile,
3166
3173
  admittedRequestPath: admitted.admittedRequestPath,
3167
3174
  baseRevision: admitted.baseRevision,
3175
+ authorityRefs: [...admitted.authorityRefs],
3168
3176
  ...(admitted.instructionEmpty
3169
3177
  ? {}
3170
3178
  : { callerProvenance: admitted.instruction }),
@@ -18,6 +18,22 @@ export const REVIEWER_AXIS_OUTPUT_ADAPTER = Object.freeze({
18
18
  implementationSha256: sha256Hex("reviewer-axis-output:v1:single-axis-verbatim-report+standards-three-priorities"),
19
19
  });
20
20
 
21
+ /**
22
+ * Package-owned #1185 review verification cadence.
23
+ * Single true source consumed by two real actor carriers: parent Reviewer system-prompt injection
24
+ * and evidence-child system prompt. Not part of axis-adapter identity or axis leg prompts.
25
+ * Graded guidance only: focused tests allowed; full suite not forbidden but avoid frequent every-round reruns.
26
+ * Does not narrow ADR 0064 tools and adds no command ban, allowlist, or runtime block.
27
+ */
28
+ export const REVIEWER_VERIFICATION_BOUNDARY = [
29
+ "Verification-Boundary: you may run focused product tests during this review turn when independent verification needs them.",
30
+ "A full repository test suite is not forbidden, but do not re-run it every review round;",
31
+ "prefer once at family wrap-up unless this review specifically requires a broader run.",
32
+ "Slice and review work should not trigger frequent full-suite reruns.",
33
+ "Independently discover test facts (including existing coder/fixer receipts and any tests you run);",
34
+ "do not treat caller prose as the source of those facts.",
35
+ ].join(" ");
36
+
21
37
  /** Typed Standards conclusion keys owned by reviewer construction (presentation labels are not the contract). */
22
38
  export const REVIEWER_STANDARDS_CONCLUSION_KEYS = Object.freeze([
23
39
  "constitutionality",
@@ -85,6 +101,15 @@ export function reviewerAxisMethodAdapter(axis: ReviewerAxis): string {
85
101
  }
86
102
 
87
103
  export type ConstructedReviewerLeg = Readonly<{ axis: "standards" | "spec"; prompt: ReviewerPromptText }>;
104
+ /**
105
+ * Unique discovery product for Skill step 2: durable refs Spec child can read, or confirmed missing.
106
+ * Construction builds Standards/Spec solely from this product — no secondary launch decision.
107
+ */
108
+ export type ReviewerSpecAuthorityDiscovery =
109
+ | Readonly<{ status: "available"; refs: readonly string[] }>
110
+ | Readonly<{ status: "missing" }>;
111
+ /** Spec-child cardinality decision recorded on the accepted dispatch. */
112
+ export type ReviewerSpecDisposition = "launched" | "skipped-missing";
88
113
  export type ConstructedReviewerDispatch = Readonly<{
89
114
  identity: string;
90
115
  recipe: "reviewer-common-bundle-v1";
@@ -94,17 +119,40 @@ export type ConstructedReviewerDispatch = Readonly<{
94
119
  }>;
95
120
  targetSnapshot: ReviewerPinnedTarget;
96
121
  range: ReviewerRange;
122
+ /** Frozen durable authority references carried into Spec evidence-child material only. */
123
+ authorityRefs: readonly string[];
124
+ /** Honest Spec-child disposition: launched, or skipped after confirmed missing Spec. */
125
+ specDisposition: ReviewerSpecDisposition;
97
126
  legs: readonly ConstructedReviewerLeg[];
98
127
  }>;
99
128
 
100
- /** Deterministic compiler: fixed target/range plus packaged Skill in, dispatch text out. */
129
+ /**
130
+ * Spec-only evidence-child material carrier for durable authority references.
131
+ * Exact values preserved; no prose extraction and no Standards/parent injection.
132
+ */
133
+ export function reviewerAuthorityRefsMaterial(authorityRefs: readonly string[]): string {
134
+ return [
135
+ "Authority-Refs:",
136
+ JSON.stringify(Object.freeze([...authorityRefs])),
137
+ "These are durable authority references only. Read them as Spec grounding materials; do not invent Spec prose from caller instruction.",
138
+ ].join("\n");
139
+ }
140
+
141
+ /** Deterministic compiler: fixed target/range plus discovery product in, dispatch text out. */
101
142
  export function constructReviewerDispatch(input: {
102
143
  identity: string;
103
144
  canonicalSkill: string;
104
145
  target: ReviewerPinnedTarget;
105
146
  range: ReviewerRange;
106
147
  reviewScopeKeys?: readonly string[];
148
+ /** Unique discovery product (available+refs material, or missing). */
149
+ specAuthority: ReviewerSpecAuthorityDiscovery;
107
150
  }): ConstructedReviewerDispatch {
151
+ const launchSpec = input.specAuthority.status === "available";
152
+ const authorityRefs = Object.freeze(
153
+ input.specAuthority.status === "available" ? [...input.specAuthority.refs] : [],
154
+ );
155
+ const specDisposition: ReviewerSpecDisposition = launchSpec ? "launched" : "skipped-missing";
108
156
  const common = [
109
157
  `Target: ${input.range.target}`,
110
158
  `Base: ${input.range.base}`,
@@ -116,13 +164,20 @@ export function constructReviewerDispatch(input: {
116
164
  "Fixed-Range:",
117
165
  JSON.stringify(input.range, null, 2),
118
166
  ].join("\n");
119
- const axes = [{ axis: "standards" as const }, { axis: "spec" as const }];
120
- const legs = axes.map((x) =>
121
- Object.freeze({
167
+ const axes: readonly { axis: "standards" | "spec" }[] = launchSpec
168
+ ? [{ axis: "standards" }, { axis: "spec" }]
169
+ : [{ axis: "standards" }];
170
+ const legs = axes.map((x) => {
171
+ const parts = [common, reviewerAxisMethodAdapter(x.axis)];
172
+ // 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));
175
+ }
176
+ return Object.freeze({
122
177
  axis: x.axis,
123
- prompt: `${common}\n${reviewerAxisMethodAdapter(x.axis)}\n`,
124
- }),
125
- );
178
+ prompt: `${parts.join("\n")}\n`,
179
+ });
180
+ });
126
181
  return Object.freeze({
127
182
  identity: input.identity,
128
183
  recipe: "reviewer-common-bundle-v1",
@@ -132,6 +187,8 @@ export function constructReviewerDispatch(input: {
132
187
  }),
133
188
  targetSnapshot: input.target,
134
189
  range: input.range,
190
+ authorityRefs,
191
+ specDisposition,
135
192
  legs: Object.freeze(legs),
136
193
  });
137
194
  }
@@ -1,13 +1,84 @@
1
1
  import { sameReviewerPinnedTarget } from "./reviewer-git-snapshot.ts";
2
- import { immutableReviewerPin, type ReviewerPinnedGitReader, type ReviewerPinnedTarget } from "./reviewer-pinned-git.ts";
2
+ import { immutableReviewerPin, type ReviewerPinnedGitReader, type ReviewerPinnedTarget, type ReviewerRange } from "./reviewer-pinned-git.ts";
3
3
  export { createReviewerPinnedGitReader, immutableReviewerPin, type ReviewerPinnedGitReader, type ReviewerPinnedTarget, type ReviewerRange } from "./reviewer-pinned-git.ts";
4
4
  import { isReviewerPromptText, sameReviewerPromptText, type ReviewerPromptText } from "./reviewer-prompt-identity.ts";
5
5
  import { sha256Hex } from "./sha256.ts";
6
- import { constructReviewerDispatch, type ConstructedReviewerDispatch } from "./reviewer-construction.ts";
6
+ import {
7
+ constructReviewerDispatch,
8
+ type ConstructedReviewerDispatch,
9
+ type ReviewerSpecAuthorityDiscovery,
10
+ } from "./reviewer-construction.ts";
11
+ export {
12
+ type ReviewerSpecAuthorityDiscovery,
13
+ type ReviewerSpecDisposition,
14
+ } from "./reviewer-construction.ts";
7
15
  import { ReviewerCorrectablePreflightError } from "./reviewer-preflight-error.ts";
8
16
  export { sha256Hex } from "./sha256.ts";
9
17
  export { isReviewerPromptText as isReviewerPromptIdentity, sameReviewerPromptText as sameReviewerPromptIdentity, type ReviewerPromptText as ReviewerPromptIdentity } from "./reviewer-prompt-identity.ts";
10
18
 
19
+ const GENERIC_FEATURE_TOKENS = new Set(["", "head", "main", "master", "trunk", "develop", "development"]);
20
+ /** Conventional branch shells that must not hide the feature token (feat/login → login). */
21
+ const BRANCH_SHELL_PREFIX = /^(?:feat|feature|fix|bugfix|hotfix|chore|docs|refactor)-/;
22
+
23
+ function normalizeFeatureToken(value: string): string {
24
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
25
+ }
26
+
27
+ /** Expand one branch/ref name into matchable tokens, stripping conventional shells. */
28
+ function expandFeatureTokens(raw: string): readonly string[] {
29
+ const normalized = normalizeFeatureToken(raw);
30
+ if (normalized.length === 0) return Object.freeze([]);
31
+ const tokens = new Set<string>([normalized]);
32
+ const stripped = normalized.replace(BRANCH_SHELL_PREFIX, "");
33
+ if (stripped.length > 0 && stripped !== normalized) tokens.add(stripped);
34
+ return Object.freeze([...tokens]);
35
+ }
36
+
37
+ /**
38
+ * Unique production owner of code-review Skill step 2 Spec discovery.
39
+ * Directly yields durable refs Spec child can read, or confirmed missing.
40
+ * - Supplied authorityRefs ⇒ available with those refs as material.
41
+ * - Matching pinned-target docs/specs/.scratch paths ⇒ available with those paths as material.
42
+ * - Commit message bare #N without durable source ⇒ missing (not available).
43
+ * Only confirmed absence yields missing; other Git/I-O failures keep true cause for preflight.
44
+ * Construction builds Standards/Spec solely from this product.
45
+ */
46
+ export async function discoverReviewerSpecAuthority(input: {
47
+ authorityRefs: readonly string[];
48
+ reader: ReviewerPinnedGitReader;
49
+ }): Promise<ReviewerSpecAuthorityDiscovery> {
50
+ if (input.authorityRefs.length > 0) {
51
+ return Object.freeze({
52
+ status: "available" as const,
53
+ refs: Object.freeze([...input.authorityRefs]),
54
+ });
55
+ }
56
+ const featureTokens = await input.reader.featureTokens();
57
+ const tokens = [
58
+ ...new Set(
59
+ featureTokens
60
+ .flatMap((raw) => expandFeatureTokens(raw))
61
+ .filter((token) => token.length >= 3 && !GENERIC_FEATURE_TOKENS.has(token)),
62
+ ),
63
+ ];
64
+ if (tokens.length === 0) {
65
+ return Object.freeze({ status: "missing" as const });
66
+ }
67
+ // Pinned target tree only — Spec child cannot read live-worktree or gitignored paths.
68
+ const candidates = await input.reader.listSpecCandidatePaths();
69
+ const matched = candidates.filter((relativePath) => {
70
+ const normalizedPath = normalizeFeatureToken(relativePath);
71
+ return tokens.some((token) => normalizedPath.includes(token));
72
+ });
73
+ if (matched.length === 0) {
74
+ return Object.freeze({ status: "missing" as const });
75
+ }
76
+ return Object.freeze({
77
+ status: "available" as const,
78
+ refs: Object.freeze(matched),
79
+ });
80
+ }
81
+
11
82
  export type AcceptedReviewerLeg = ConstructedReviewerDispatch["legs"][number];
12
83
  export type AcceptedReviewerDispatch = ConstructedReviewerDispatch;
13
84
  export type AcceptedReviewerExecution = Readonly<{
@@ -28,6 +99,8 @@ type DispatcherDependencies = Readonly<{
28
99
  canonicalSkill: string;
29
100
  reader: ReviewerPinnedGitReader;
30
101
  reviewScopeKeys?: readonly string[];
102
+ /** Durable authority references preserved unchanged into Spec-leg construction only. */
103
+ authorityRefs?: readonly string[];
31
104
  run(execution: AcceptedReviewerExecution, invocation: unknown): Promise<unknown>;
32
105
  decisionEvidence?(decision: ReviewerDecisionEvidence): void;
33
106
  }>;
@@ -66,12 +139,18 @@ export function createReviewerDispatcher(d: DispatcherDependencies) {
66
139
  try {
67
140
  const base = await d.reader.resolve(baseRevision);
68
141
  const range = await d.reader.range(base);
142
+ const authorityRefs = Object.freeze([...(d.authorityRefs ?? [])]);
143
+ const specAuthority = await discoverReviewerSpecAuthority({
144
+ authorityRefs,
145
+ reader: d.reader,
146
+ });
69
147
  dispatch = constructReviewerDispatch({
70
148
  identity,
71
149
  canonicalSkill: d.canonicalSkill,
72
150
  target,
73
151
  range,
74
152
  ...(d.reviewScopeKeys === undefined ? {} : { reviewScopeKeys: d.reviewScopeKeys }),
153
+ specAuthority,
75
154
  });
76
155
  if (!sameReviewerPinnedTarget(await d.reader.snapshot(), target)) {
77
156
  throw new ReviewerPreflightError("target-drift", "pinned target snapshot changed before child execution");
@@ -27,7 +27,8 @@ export function projectAcceptedDispatch(dispatch: AcceptedReviewerDispatch): Rev
27
27
  return {
28
28
  source: "reviewer-dispatch", type: "accepted", identity: dispatch.identity,
29
29
  recipe: dispatch.recipe, input: dispatch.input, target: dispatch.targetSnapshot,
30
- range: dispatch.range, legs: dispatch.legs,
30
+ range: dispatch.range, authorityRefs: dispatch.authorityRefs,
31
+ specDisposition: dispatch.specDisposition, legs: dispatch.legs,
31
32
  };
32
33
  }
33
34
 
@@ -25,6 +25,18 @@ export type ReviewerPinnedGitReader = {
25
25
  snapshot(): Promise<ReviewerPinnedTarget>;
26
26
  resolve(base: string): Promise<string>;
27
27
  range(base: string): Promise<ReviewerRange>;
28
+ /**
29
+ * Branch/feature name tokens at the pinned target for Spec path matching.
30
+ * Derived from the pinned ref snapshot (heads/tags/remotes pointing at targetHead);
31
+ * does not depend on current symbolic HEAD, so detached/remote-only tips stay honest.
32
+ */
33
+ featureTokens(): Promise<readonly string[]>;
34
+ /**
35
+ * Durable Spec-candidate paths present in the pinned target tree under docs/specs/.scratch.
36
+ * Spec child clones this target — live working tree is not a source of material facts.
37
+ * Empty list is confirmed absence; other Git/I-O failures propagate with true cause.
38
+ */
39
+ listSpecCandidatePaths(): Promise<readonly string[]>;
28
40
  };
29
41
 
30
42
  const execFileAsync = promisify(execFile);
@@ -139,6 +151,37 @@ export async function createReviewerPinnedGitReader(root = process.cwd()): Promi
139
151
  if (diff.length === 0) invalid("range-invalid", "review range must contain a non-empty diff between base and pinned target");
140
152
  return Object.freeze({ base: mergeBase, target: targetHead, diffCommand, diffSha256: sha256Hex(Uint8Array.from(diff)), commits: Object.freeze(commitsText ? commitsText.split("\n") : []) });
141
153
  },
154
+ async featureTokens() {
155
+ // Pinned ref snapshot is the target-tree fact — no live branch/symbolic-ref walk,
156
+ // no catch-to-empty. Detached/remote-only tips surface via refs/remotes/* entries.
157
+ const names = new Set<string>();
158
+ for (const [refName, entry] of Object.entries(pin.refs)) {
159
+ if (entry.peeledCommitId !== targetHead) continue;
160
+ const short = refName.startsWith("refs/heads/")
161
+ ? refName.slice("refs/heads/".length)
162
+ : refName.startsWith("refs/tags/")
163
+ ? refName.slice("refs/tags/".length)
164
+ : refName.startsWith("refs/remotes/")
165
+ ? refName.slice("refs/remotes/".length).replace(/^[^/]+\//, "")
166
+ : refName;
167
+ if (short.trim() !== "") names.add(short.trim());
168
+ }
169
+ return Object.freeze([...names]);
170
+ },
171
+ async listSpecCandidatePaths() {
172
+ const roots = ["docs", "specs", ".scratch"] as const;
173
+ // git ls-tree exits 0 with empty stdout when none of the roots exist at targetHead.
174
+ // Other Git/I-O failures keep their true cause for the dispatch preflight path.
175
+ const text = await gitText(repositoryRoot, [
176
+ "ls-tree",
177
+ "-r",
178
+ "--name-only",
179
+ targetHead,
180
+ "--",
181
+ ...roots,
182
+ ]);
183
+ return Object.freeze(text === "" ? [] : text.split("\n").filter((line) => line.length > 0));
184
+ },
142
185
 
143
186
  });
144
187
  }