@akagilnc/pi-workflow-roles 0.1.1918 → 0.1.2004

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/evidence-child-executor.js +12 -6
  2. package/dist/package-contracts/reviewer-output.js +16 -0
  3. package/dist/public-cli/main.js +2520 -72
  4. package/dist/reviewer-construction.js +46 -6
  5. package/dist/reviewer-dispatch.js +64 -1
  6. package/dist/reviewer-execution-ledger.js +2 -1
  7. package/dist/reviewer-pinned-git.js +33 -0
  8. package/package.json +1 -1
  9. package/src/atomic-write.ts +26 -0
  10. package/src/evidence-child-executor.ts +13 -6
  11. package/src/ledger-session-read.ts +260 -0
  12. package/src/package-contracts/reviewer-output.ts +19 -0
  13. package/src/public-cli/cli.ts +24 -0
  14. package/src/public-cli/invocation.ts +398 -2
  15. package/src/public-cli/main.ts +21 -2
  16. package/src/public-cli/registry.ts +18 -1
  17. package/src/public-cli/reviewer-run.ts +12 -0
  18. package/src/public-cli/run-lifecycle.ts +22 -1
  19. package/src/public-cli/settlement.ts +17 -0
  20. package/src/public-cli/taishi-run.ts +235 -0
  21. package/src/reviewer-construction.ts +64 -7
  22. package/src/reviewer-dispatch.ts +81 -2
  23. package/src/reviewer-execution-ledger.ts +2 -1
  24. package/src/reviewer-pinned-git.ts +43 -0
  25. package/src/reviewer-role.ts +143 -132
  26. package/src/reviewer-settlement.ts +4 -0
  27. package/src/role-runtime.ts +186 -3
  28. package/src/run-terminal-artifacts.ts +231 -0
  29. package/src/taishi-cohort.ts +232 -0
  30. package/src/taishi-entry.ts +429 -0
  31. package/src/taishi-index.ts +269 -0
  32. package/src/taishi-ledger.ts +466 -0
  33. package/src/taishi-median.ts +15 -0
  34. package/src/taishi-metric-families/acceptance-success-rework.ts +346 -0
  35. package/src/taishi-metric-families/b2-frame-buckets-actions.ts +274 -0
  36. package/src/taishi-metric-families/leg-wall-clock.ts +90 -0
  37. package/src/taishi-metric-families/round-timeline.ts +201 -0
  38. package/src/taishi-metric-families.ts +36 -0
  39. package/src/taishi-metric-family.ts +41 -0
  40. package/src/taishi-model-groups.ts +198 -0
  41. package/src/taishi-page.ts +320 -0
  42. package/src/ticket-trajectory.ts +9 -62
@@ -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" }
@@ -1255,6 +1264,7 @@ function reviewerDecisiveFacts(
1255
1264
  const axes = reviewerAxes(outcomes.readable ? outcomes.value : undefined);
1256
1265
  const reportAxes = reviewerAxes(reports.readable ? reports.value : undefined);
1257
1266
  const acceptedBatch = safelyRead(candidate, "acceptedBatch");
1267
+ const specDisposition = safelyRead(candidate, "specDisposition");
1258
1268
  const facts: Record<string, unknown> = {
1259
1269
  axes,
1260
1270
  reportAxes,
@@ -1262,6 +1272,12 @@ function reviewerDecisiveFacts(
1262
1272
  ...auditNoReceiptDecisiveFact(candidate),
1263
1273
  };
1264
1274
  if (status.readable && typeof status.value === "string") facts.reviewerStatus = status.value;
1275
+ if (
1276
+ specDisposition.readable &&
1277
+ (specDisposition.value === "launched" || specDisposition.value === "skipped-missing")
1278
+ ) {
1279
+ facts.specDisposition = specDisposition.value;
1280
+ }
1265
1281
  const diagnostic = safelyRead(candidate, "diagnostic");
1266
1282
  if (status.readable && status.value === "refused" && diagnostic.readable) {
1267
1283
  facts.diagnosticPresent = typeof diagnostic.value === "string" && diagnostic.value.trim().length > 0;
@@ -3165,6 +3181,7 @@ export async function publishReviewerArtifacts(
3165
3181
  sessionFile: admitted.sessionFile,
3166
3182
  admittedRequestPath: admitted.admittedRequestPath,
3167
3183
  baseRevision: admitted.baseRevision,
3184
+ authorityRefs: [...admitted.authorityRefs],
3168
3185
  ...(admitted.instructionEmpty
3169
3186
  ? {}
3170
3187
  : { callerProvenance: admitted.instruction }),
@@ -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
+ }
@@ -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
  }