@akagilnc/pi-workflow-roles 0.1.2004 → 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.
@@ -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),
@@ -3185,6 +3185,10 @@ export async function publishReviewerArtifacts(
3185
3185
  ...(admitted.instructionEmpty
3186
3186
  ? {}
3187
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 }),
3188
3192
  attachments: admitted.attachments.map((a) => ({
3189
3193
  provenancePath: a.provenancePath,
3190
3194
  frozenPath: a.frozenPath,
@@ -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
  }
@@ -1,16 +1,22 @@
1
1
  import { sameReviewerPinnedTarget } from "./reviewer-git-snapshot.ts";
2
- import { immutableReviewerPin, type ReviewerPinnedGitReader, type ReviewerPinnedTarget, type ReviewerRange } from "./reviewer-pinned-git.ts";
3
- export { createReviewerPinnedGitReader, immutableReviewerPin, type ReviewerPinnedGitReader, type ReviewerPinnedTarget, type ReviewerRange } from "./reviewer-pinned-git.ts";
2
+ import { branchNamesAtPinnedHead, immutableReviewerPin, type ReviewerPinnedGitReader, type ReviewerPinnedTarget, type ReviewerRange } from "./reviewer-pinned-git.ts";
3
+ export { branchNamesAtPinnedHead, 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
6
  import {
7
7
  constructReviewerDispatch,
8
8
  type ConstructedReviewerDispatch,
9
9
  type ReviewerSpecAuthorityDiscovery,
10
+ type ReviewerSpecFetchedMaterial,
11
+ type ReviewerTicketNumberCandidate,
12
+ type ReviewerTicketNumberSource,
10
13
  } from "./reviewer-construction.ts";
11
14
  export {
12
15
  type ReviewerSpecAuthorityDiscovery,
13
16
  type ReviewerSpecDisposition,
17
+ type ReviewerSpecFetchedMaterial,
18
+ type ReviewerTicketNumberCandidate,
19
+ type ReviewerTicketNumberSource,
14
20
  } from "./reviewer-construction.ts";
15
21
  import { ReviewerCorrectablePreflightError } from "./reviewer-preflight-error.ts";
16
22
  export { sha256Hex } from "./sha256.ts";
@@ -19,6 +25,12 @@ export { isReviewerPromptText as isReviewerPromptIdentity, sameReviewerPromptTex
19
25
  const GENERIC_FEATURE_TOKENS = new Set(["", "head", "main", "master", "trunk", "develop", "development"]);
20
26
  /** Conventional branch shells that must not hide the feature token (feat/login → login). */
21
27
  const BRANCH_SHELL_PREFIX = /^(?:feat|feature|fix|bugfix|hotfix|chore|docs|refactor)-/;
28
+ /** Branch token ticket capture: (fix|feat|docs|audit|test)/issue-(\d+)- (#343). */
29
+ const BRANCH_ISSUE_TOKEN = /(?:^|\/)((?:fix|feat|docs|audit|test)\/issue-(\d+)-)/;
30
+ /** First #N in a commit subject (positive integer). */
31
+ const COMMIT_TICKET_TOKEN = /#([1-9]\d*)/;
32
+ /** docs/adr paths referenced inside an issue body. */
33
+ const ADR_PATH_IN_BODY = /docs\/adr\/[A-Za-z0-9][A-Za-z0-9._/-]*\.md/g;
22
34
 
23
35
  function normalizeFeatureToken(value: string): string {
24
36
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -35,25 +47,184 @@ function expandFeatureTokens(raw: string): readonly string[] {
35
47
  }
36
48
 
37
49
  /**
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.
50
+ * Shared capture/number positive-integer frozen candidate conversion.
51
+ * Single true source for branch/commit (and typed) ticket candidate materialization.
52
+ */
53
+ function ticketCandidateFromRaw(
54
+ source: ReviewerTicketNumberSource,
55
+ raw: unknown,
56
+ ): ReviewerTicketNumberCandidate | undefined {
57
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : Number.NaN;
58
+ if (!Number.isInteger(n) || n < 1) return undefined;
59
+ return Object.freeze({ source, ticketNumber: n });
60
+ }
61
+
62
+ /**
63
+ * Resolve ticket number with unique priority (#343):
64
+ * typed ticketNumber → branch token → newest commit message first #N.
65
+ * High-priority hit is adopted; lower sources that also yield a number are abandoned candidates.
66
+ */
67
+ export function resolveReviewerTicketNumber(input: {
68
+ ticketNumber?: number;
69
+ branchNames: readonly string[];
70
+ commitMessagesNewestFirst: readonly string[];
71
+ }): Readonly<{ adopted: ReviewerTicketNumberCandidate; abandoned: readonly ReviewerTicketNumberCandidate[] }> | undefined {
72
+ const typed = ticketCandidateFromRaw("typed-ticket-number", input.ticketNumber);
73
+
74
+ let branch: ReviewerTicketNumberCandidate | undefined;
75
+ for (const name of input.branchNames) {
76
+ const match = BRANCH_ISSUE_TOKEN.exec(name);
77
+ if (match) {
78
+ branch = ticketCandidateFromRaw("branch-token", match[2]);
79
+ if (branch !== undefined) break;
80
+ }
81
+ }
82
+
83
+ let commit: ReviewerTicketNumberCandidate | undefined;
84
+ const newest = input.commitMessagesNewestFirst[0];
85
+ if (newest !== undefined) {
86
+ const match = COMMIT_TICKET_TOKEN.exec(newest);
87
+ if (match) {
88
+ commit = ticketCandidateFromRaw("commit-message", match[1]);
89
+ }
90
+ }
91
+
92
+ if (typed !== undefined) {
93
+ const abandoned = [branch, commit].filter((c): c is ReviewerTicketNumberCandidate => c !== undefined);
94
+ return Object.freeze({ adopted: typed, abandoned: Object.freeze(abandoned) });
95
+ }
96
+ if (branch !== undefined) {
97
+ const abandoned = commit === undefined ? Object.freeze([]) : Object.freeze([commit]);
98
+ return Object.freeze({ adopted: branch, abandoned });
99
+ }
100
+ if (commit !== undefined) {
101
+ return Object.freeze({ adopted: commit, abandoned: Object.freeze([]) });
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /** Extract unique docs/adr/*.md paths referenced by issue body text (order of first appearance). */
107
+ export function extractReferencedAdrPaths(issueBody: string): readonly string[] {
108
+ const seen = new Set<string>();
109
+ const paths: string[] = [];
110
+ for (const match of issueBody.matchAll(ADR_PATH_IN_BODY)) {
111
+ const path = match[0]!;
112
+ if (seen.has(path)) continue;
113
+ seen.add(path);
114
+ paths.push(path);
115
+ }
116
+ return Object.freeze(paths);
117
+ }
118
+
119
+ export type ReviewerIssueFetchResult = Readonly<{ body: string }>;
120
+ /**
121
+ * Soft issue fetch capability: undefined means confirmed tracker unreachable / issue not found.
122
+ * Unrecognized runner failures and parse/implementation errors propagate with true cause (not washed into degrade).
123
+ * Optional signal rides the shared GhApiRunner cancellation chain; role modules never own gh lifecycle.
124
+ */
125
+ export type ReviewerIssueFetcher = (input: {
126
+ owner: string;
127
+ repo: string;
128
+ ticketNumber: number;
129
+ signal?: AbortSignal;
130
+ }) => Promise<ReviewerIssueFetchResult | undefined>;
131
+
132
+ /** Optional AbortSignal carried on the dispatch invocation bag (same shape runDispatch already reads). */
133
+ function optionalInvocationSignal(invocation: unknown): AbortSignal | undefined {
134
+ if (typeof invocation !== "object" || invocation === null) return undefined;
135
+ const signal = (invocation as { signal?: unknown }).signal;
136
+ return signal instanceof AbortSignal ? signal : undefined;
137
+ }
138
+
139
+ /**
140
+ * Unique production owner of code-review Skill step 2 Spec discovery (#343).
141
+ * Primary: self-fetch latest issue by ticket number (typed → branch token → commit #N).
142
+ * Degradation (unique order): self-fetch fail → supplied authorityRefs → local path match → missing.
143
+ * Prompt/admitted-request prose is never Spec material.
144
+ * Only confirmed absence yields missing; non-absence Git/I-O failures keep true cause for preflight.
44
145
  * Construction builds Standards/Spec solely from this product.
45
146
  */
46
147
  export async function discoverReviewerSpecAuthority(input: {
47
148
  authorityRefs: readonly string[];
48
149
  reader: ReviewerPinnedGitReader;
150
+ /** Typed #176 ticketNumber from admitted invocation, when present. */
151
+ ticketNumber?: number;
152
+ /** base..HEAD commit scan base (resolved oid). Required for commit-message ticket source. */
153
+ baseCommit?: string;
154
+ /**
155
+ * Injected issue-fetch capability from the shared execution seam.
156
+ * Absent capability = self-fetch unavailable (degrade); role module never owns gh lifecycle.
157
+ */
158
+ fetchIssue?: ReviewerIssueFetcher;
159
+ /** Optional cancellation signal for the soft-fetch gh subprocess (invocation AbortSignal). */
160
+ signal?: AbortSignal;
49
161
  }): Promise<ReviewerSpecAuthorityDiscovery> {
162
+ // Path-matching tokens (heads/tags/remotes) stay separate from branch-ticket provenance.
163
+ const featureTokens = await input.reader.featureTokens();
164
+ const commitMessages =
165
+ input.baseCommit === undefined
166
+ ? Object.freeze([])
167
+ : await input.reader.commitMessagesNewestFirst(input.baseCommit);
168
+ const ticketResolution = resolveReviewerTicketNumber({
169
+ ...(input.ticketNumber === undefined ? {} : { ticketNumber: input.ticketNumber }),
170
+ // Branch ticket source: real heads/remotes at targetHead only — never tags via featureTokens.
171
+ branchNames: branchNamesAtPinnedHead(input.reader.pin),
172
+ commitMessagesNewestFirst: commitMessages,
173
+ });
174
+
175
+ // ① Primary: self-fetch latest issue + referenced docs/adr via injected capability only.
176
+ if (ticketResolution !== undefined) {
177
+ const origin = await input.reader.originRepository();
178
+ if (origin !== undefined && input.fetchIssue !== undefined) {
179
+ const issue = await input.fetchIssue({
180
+ owner: origin.owner,
181
+ repo: origin.repo,
182
+ ticketNumber: ticketResolution.adopted.ticketNumber,
183
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
184
+ });
185
+ if (issue !== undefined) {
186
+ const adrPaths = extractReferencedAdrPaths(issue.body);
187
+ const adrs = [];
188
+ for (const path of adrPaths) {
189
+ const body = await input.reader.readPinnedText(path);
190
+ if (body === undefined) {
191
+ adrs.push(Object.freeze({ path, status: "missing" as const }));
192
+ } else {
193
+ adrs.push(Object.freeze({ path, status: "present" as const, body }));
194
+ }
195
+ }
196
+ const issueRef = `https://github.com/${origin.owner}/${origin.repo}/issues/${ticketResolution.adopted.ticketNumber}`;
197
+ const presentAdrRefs = adrs
198
+ .filter((a) => a.status === "present")
199
+ .map((a) => a.path);
200
+ const fetched: ReviewerSpecFetchedMaterial = Object.freeze({
201
+ issueRef,
202
+ owner: origin.owner,
203
+ repo: origin.repo,
204
+ ticketNumber: ticketResolution.adopted.ticketNumber,
205
+ adopted: ticketResolution.adopted,
206
+ abandoned: ticketResolution.abandoned,
207
+ issueBody: issue.body,
208
+ adrs: Object.freeze(adrs),
209
+ });
210
+ return Object.freeze({
211
+ status: "available" as const,
212
+ refs: Object.freeze([issueRef, ...presentAdrRefs]),
213
+ fetched,
214
+ });
215
+ }
216
+ }
217
+ }
218
+
219
+ // ② Degrade: explicit --authority-ref (human intent before local heuristics).
50
220
  if (input.authorityRefs.length > 0) {
51
221
  return Object.freeze({
52
222
  status: "available" as const,
53
223
  refs: Object.freeze([...input.authorityRefs]),
54
224
  });
55
225
  }
56
- const featureTokens = await input.reader.featureTokens();
226
+
227
+ // ③ Degrade: matching pinned-target docs/specs/.scratch paths via branch feature tokens.
57
228
  const tokens = [
58
229
  ...new Set(
59
230
  featureTokens
@@ -101,6 +272,10 @@ type DispatcherDependencies = Readonly<{
101
272
  reviewScopeKeys?: readonly string[];
102
273
  /** Durable authority references preserved unchanged into Spec-leg construction only. */
103
274
  authorityRefs?: readonly string[];
275
+ /** Typed #176 ticketNumber from admitted invocation (Spec self-fetch primary). */
276
+ ticketNumber?: number;
277
+ /** Injected issue-fetch capability from shared execution seam (production/tests). */
278
+ fetchIssue?: ReviewerIssueFetcher;
104
279
  run(execution: AcceptedReviewerExecution, invocation: unknown): Promise<unknown>;
105
280
  decisionEvidence?(decision: ReviewerDecisionEvidence): void;
106
281
  }>;
@@ -140,9 +315,14 @@ export function createReviewerDispatcher(d: DispatcherDependencies) {
140
315
  const base = await d.reader.resolve(baseRevision);
141
316
  const range = await d.reader.range(base);
142
317
  const authorityRefs = Object.freeze([...(d.authorityRefs ?? [])]);
318
+ const signal = optionalInvocationSignal(invocation);
143
319
  const specAuthority = await discoverReviewerSpecAuthority({
144
320
  authorityRefs,
145
321
  reader: d.reader,
322
+ baseCommit: base,
323
+ ...(d.ticketNumber === undefined ? {} : { ticketNumber: d.ticketNumber }),
324
+ ...(d.fetchIssue === undefined ? {} : { fetchIssue: d.fetchIssue }),
325
+ ...(signal === undefined ? {} : { signal }),
146
326
  });
147
327
  dispatch = constructReviewerDispatch({
148
328
  identity,
@@ -28,7 +28,11 @@ export function projectAcceptedDispatch(dispatch: AcceptedReviewerDispatch): Rev
28
28
  source: "reviewer-dispatch", type: "accepted", identity: dispatch.identity,
29
29
  recipe: dispatch.recipe, input: dispatch.input, target: dispatch.targetSnapshot,
30
30
  range: dispatch.range, authorityRefs: dispatch.authorityRefs,
31
- specDisposition: dispatch.specDisposition, legs: dispatch.legs,
31
+ specDisposition: dispatch.specDisposition,
32
+ ...(dispatch.specFetchedMaterial === undefined
33
+ ? {}
34
+ : { specFetchedMaterial: dispatch.specFetchedMaterial }),
35
+ legs: dispatch.legs,
32
36
  };
33
37
  }
34
38
 
@@ -20,15 +20,18 @@ export type ReviewerRange = Readonly<{
20
20
  diffSha256: string;
21
21
  commits: readonly string[];
22
22
  }>;
23
+ export type ReviewerOriginRepository = Readonly<{ owner: string; repo: string }>;
24
+
23
25
  export type ReviewerPinnedGitReader = {
24
26
  pin: ReviewerPinnedTarget;
25
27
  snapshot(): Promise<ReviewerPinnedTarget>;
26
28
  resolve(base: string): Promise<string>;
27
29
  range(base: string): Promise<ReviewerRange>;
28
30
  /**
29
- * Branch/feature name tokens at the pinned target for Spec path matching.
31
+ * Branch/feature name tokens at the pinned target for Spec *path* matching only.
30
32
  * Derived from the pinned ref snapshot (heads/tags/remotes pointing at targetHead);
31
33
  * does not depend on current symbolic HEAD, so detached/remote-only tips stay honest.
34
+ * Ticket-number branch provenance must not use this set — see branchNamesAtPinnedHead.
32
35
  */
33
36
  featureTokens(): Promise<readonly string[]>;
34
37
  /**
@@ -37,13 +40,33 @@ export type ReviewerPinnedGitReader = {
37
40
  * Empty list is confirmed absence; other Git/I-O failures propagate with true cause.
38
41
  */
39
42
  listSpecCandidatePaths(): Promise<readonly string[]>;
43
+ /**
44
+ * github.com owner/repo from `origin` remote at the pinned repository root.
45
+ * undefined = no remote / non-github / unparseable — self-fetch unavailable (degrade).
46
+ */
47
+ originRepository(): Promise<ReviewerOriginRepository | undefined>;
48
+ /**
49
+ * Commit subjects for base..targetHead, newest first (for #N ticket extraction).
50
+ * Empty when the range has no commits; other Git failures propagate with true cause.
51
+ */
52
+ commitMessagesNewestFirst(base: string): Promise<readonly string[]>;
53
+ /**
54
+ * Read one path from the pinned target tree as UTF-8 text.
55
+ * undefined = path absent at targetHead; other Git failures propagate with true cause.
56
+ */
57
+ readPinnedText(path: string): Promise<string | undefined>;
40
58
  };
41
59
 
42
60
  const execFileAsync = promisify(execFile);
43
61
  type GitProcessError = Error & Readonly<{ code: number | string | null; signal: NodeJS.Signals | null; timedOut: boolean; aborted: boolean; stderr: string; stdout: string }>;
44
62
  async function execGit<T extends "utf8" | "buffer">(args: readonly string[], options: { encoding: T; maxBuffer?: number }): Promise<{ stdout: T extends "buffer" ? Buffer : string; stderr: string }> {
45
- try { return await execFileAsync("git", args, options) as unknown as { stdout: T extends "buffer" ? Buffer : string; stderr: string }; }
46
- catch (error) {
63
+ // Pin C locale at the sole Git exec seam so English diagnostic classifiers stay honest under translated gettext installs.
64
+ try {
65
+ return await execFileAsync("git", args, {
66
+ ...options,
67
+ env: { ...process.env, LC_ALL: "C" },
68
+ }) as unknown as { stdout: T extends "buffer" ? Buffer : string; stderr: string };
69
+ } catch (error) {
47
70
  const source = error as Partial<GitProcessError>;
48
71
  const wrapped = new Error("git process failed", { cause: error }) as GitProcessError;
49
72
  Object.assign(wrapped, { code: source.code ?? null, signal: source.signal ?? null, timedOut: (source as { killed?: unknown }).killed === true && source.signal === "SIGTERM", aborted: source.name === "AbortError", stderr: String(source.stderr ?? ""), stdout: String(source.stdout ?? "") });
@@ -51,10 +74,56 @@ async function execGit<T extends "utf8" | "buffer">(args: readonly string[], opt
51
74
  }
52
75
  }
53
76
  function exitCode(error: unknown): number | undefined { const code = typeof error === "object" && error !== null ? (error as { code?: unknown }).code : undefined; return typeof code === "number" ? code : undefined; }
77
+ function gitStderr(error: unknown): string {
78
+ if (typeof error !== "object" || error === null) return "";
79
+ const stderr = (error as { stderr?: unknown }).stderr;
80
+ return typeof stderr === "string" ? stderr : "";
81
+ }
82
+ /** Confirmed `origin` remote absence only (`git remote get-url origin`). */
83
+ function isConfirmedMissingOriginRemote(error: unknown): boolean {
84
+ return /No such remote ['"]origin['"]/.test(gitStderr(error));
85
+ }
86
+ /** Confirmed path-at-pinned-tree absence only — exit 128 alone is not enough. */
87
+ function isConfirmedPinnedPathAbsent(error: unknown, path: string): boolean {
88
+ const stderr = gitStderr(error);
89
+ const quoted = `'${path}'`;
90
+ return (
91
+ stderr.includes(`path ${quoted} does not exist in `) ||
92
+ stderr.includes(`path ${quoted} exists on disk, but not in `)
93
+ );
94
+ }
54
95
  async function repositoryIsAvailable(root: string): Promise<{ available: boolean; cause?: unknown }> { try { await access(`${root}/.git`); return { available: true }; } catch (cause) { return { available: false, cause }; } }
55
96
  export const immutableReviewerPin = (pin: ReviewerPinnedTarget): ReviewerPinnedTarget => Object.freeze({
56
97
  repositoryRoot: pin.repositoryRoot, objectFormat: pin.objectFormat, targetHead: pin.targetHead, refs: immutableReviewerRefs(pin.refs),
57
98
  });
99
+
100
+ /** Short name from a full ref, stripping heads/tags/remotes namespaces (and remote remote-name). */
101
+ function shortNameFromPinnedRef(refName: string): string | undefined {
102
+ const short = refName.startsWith("refs/heads/")
103
+ ? refName.slice("refs/heads/".length)
104
+ : refName.startsWith("refs/tags/")
105
+ ? refName.slice("refs/tags/".length)
106
+ : refName.startsWith("refs/remotes/")
107
+ ? refName.slice("refs/remotes/".length).replace(/^[^/]+\//, "")
108
+ : refName;
109
+ const trimmed = short.trim();
110
+ return trimmed === "" ? undefined : trimmed;
111
+ }
112
+
113
+ /**
114
+ * Branch-only short names at pinned targetHead for ticket-number provenance (#343).
115
+ * Heads and remotes only — tags never supply branch-token ticket candidates.
116
+ */
117
+ export function branchNamesAtPinnedHead(pin: ReviewerPinnedTarget): readonly string[] {
118
+ const names = new Set<string>();
119
+ for (const [refName, entry] of Object.entries(pin.refs)) {
120
+ if (entry.peeledCommitId !== pin.targetHead) continue;
121
+ if (!refName.startsWith("refs/heads/") && !refName.startsWith("refs/remotes/")) continue;
122
+ const short = shortNameFromPinnedRef(refName);
123
+ if (short !== undefined) names.add(short);
124
+ }
125
+ return Object.freeze([...names]);
126
+ }
58
127
  async function gitText(root: string, args: readonly string[]): Promise<string> {
59
128
  const { stdout } = await execGit(["-C", root, ...args], { encoding: "utf8" });
60
129
  return stdout.trim();
@@ -154,17 +223,12 @@ export async function createReviewerPinnedGitReader(root = process.cwd()): Promi
154
223
  async featureTokens() {
155
224
  // Pinned ref snapshot is the target-tree fact — no live branch/symbolic-ref walk,
156
225
  // no catch-to-empty. Detached/remote-only tips surface via refs/remotes/* entries.
226
+ // Includes tags for local Spec-path matching only; ticket branch source is separate.
157
227
  const names = new Set<string>();
158
228
  for (const [refName, entry] of Object.entries(pin.refs)) {
159
229
  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());
230
+ const short = shortNameFromPinnedRef(refName);
231
+ if (short !== undefined) names.add(short);
168
232
  }
169
233
  return Object.freeze([...names]);
170
234
  },
@@ -182,6 +246,85 @@ export async function createReviewerPinnedGitReader(root = process.cwd()): Promi
182
246
  ]);
183
247
  return Object.freeze(text === "" ? [] : text.split("\n").filter((line) => line.length > 0));
184
248
  },
249
+ async originRepository() {
250
+ let remoteUrl: string;
251
+ try {
252
+ remoteUrl = await gitText(repositoryRoot, ["remote", "get-url", "origin"]);
253
+ } catch (error) {
254
+ // Only confirmed origin absence softens to unavailable; other Git failures keep true cause.
255
+ if (isConfirmedMissingOriginRemote(error)) return undefined;
256
+ throw error;
257
+ }
258
+ // Non-github / unparseable remote URL = self-fetch unavailable (soft degrade).
259
+ return parseGitHubOriginRemote(remoteUrl);
260
+ },
261
+ async commitMessagesNewestFirst(base: string) {
262
+ const text = await gitText(repositoryRoot, [
263
+ "log",
264
+ "--format=%s",
265
+ `${base}..${targetHead}`,
266
+ ]);
267
+ return Object.freeze(text === "" ? [] : text.split("\n"));
268
+ },
269
+ async readPinnedText(path: string) {
270
+ // Reject path traversal / absolute paths — Spec material is relative tree paths only.
271
+ if (
272
+ path.length === 0 ||
273
+ path.startsWith("/") ||
274
+ path.includes("\0") ||
275
+ path.split("/").some((part) => part === ".." || part === "")
276
+ ) {
277
+ return undefined;
278
+ }
279
+ try {
280
+ const { stdout } = await execGit(
281
+ ["-C", repositoryRoot, "show", `${targetHead}:${path}`],
282
+ { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 },
283
+ );
284
+ return stdout;
285
+ } catch (error) {
286
+ // Only confirmed path-at-pinned-tree absence softens to missing; exit 128 is not a blanket.
287
+ if (isConfirmedPinnedPathAbsent(error, path)) return undefined;
288
+ throw error;
289
+ }
290
+ },
185
291
 
186
292
  });
187
293
  }
294
+
295
+ /**
296
+ * Parse github.com owner/repo from a git remote URL.
297
+ * Supports scp-like SSH, ssh://, https://, and git:// shapes. Soft: undefined when not github.
298
+ */
299
+ export function parseGitHubOriginRemote(remoteUrl: string): ReviewerOriginRepository | undefined {
300
+ const trimmed = remoteUrl.trim();
301
+ if (trimmed.length === 0) return undefined;
302
+ const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
303
+ if (scp) return normalizeOrigin(scp[1]!, scp[2]!);
304
+ const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed);
305
+ if (ssh) return normalizeOrigin(ssh[1]!, ssh[2]!);
306
+ let parsed: URL;
307
+ try {
308
+ parsed = new URL(trimmed);
309
+ } catch {
310
+ return undefined;
311
+ }
312
+ if (!/^github\.com$/i.test(parsed.hostname)) return undefined;
313
+ if (parsed.search !== "" || parsed.hash !== "") return undefined;
314
+ const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
315
+ if (parts.length !== 2) return undefined;
316
+ return normalizeOrigin(parts[0]!, parts[1]!);
317
+ }
318
+
319
+ function normalizeOrigin(ownerRaw: string, repoRaw: string): ReviewerOriginRepository | undefined {
320
+ const owner = ownerRaw.trim();
321
+ const repo = stripGitSuffix(repoRaw.trim());
322
+ if (owner.length === 0 || repo.length === 0) return undefined;
323
+ // Conservative identity: no path separators or URL material inside segments.
324
+ if (/[/?#@\\]/.test(owner) || /[/?#@\\]/.test(repo)) return undefined;
325
+ return Object.freeze({ owner, repo });
326
+ }
327
+
328
+ function stripGitSuffix(name: string): string {
329
+ return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
330
+ }