@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,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
+ }
@@ -9,7 +9,7 @@ import type { ComplianceDecision } from "./compliance-transport.ts";
9
9
  import { appendActiveSessionCustomEntry } from "./compliance-transport.ts";
10
10
  import { REVIEWER_CANDIDATE_ENTRY_TYPE } from "./dossier-resolution.ts";
11
11
  import { type ReviewerSpecDisposition } from "./reviewer-construction.ts";
12
- import { createReviewerDispatcher, type AcceptedReviewerDispatch, type AcceptedReviewerExecution, type ReviewerPinnedGitReader } from "./reviewer-dispatch.ts";
12
+ import { createReviewerDispatcher, type AcceptedReviewerDispatch, type AcceptedReviewerExecution, type ReviewerIssueFetcher, type ReviewerPinnedGitReader } from "./reviewer-dispatch.ts";
13
13
  import { ReviewerDispatchExecutionError, type ReviewerDispatchRunResult } from "./reviewer-agent.ts";
14
14
  import { createReviewerExecutionLedger, projectAcceptedDispatch, projectReviewerDispatchOutcome, type ReviewerExecutionRecord } from "./reviewer-execution-ledger.ts";
15
15
  import { assembleRuntimeReviewerReceipt } from "./reviewer-settlement.ts";
@@ -24,6 +24,8 @@ export type ReviewerAdmittedInputs = Readonly<{
24
24
  baseRevision: string;
25
25
  reviewScopeKeys?: readonly string[];
26
26
  authorityRefs?: readonly string[];
27
+ /** Typed #176 ticketNumber from admitted invocation (Spec self-fetch primary). */
28
+ ticketNumber?: number;
27
29
  }>;
28
30
 
29
31
  const reviewerOutputVariants = Type.Union([
@@ -35,6 +37,8 @@ export type ReviewerRoleDependencies = {
35
37
  loadSoul(): Promise<string>;
36
38
  loadCanonicalSkillBinding(name: "code-review"): Promise<AnyCanonicalSkillBinding>;
37
39
  createPinnedGitReader(): Promise<ReviewerPinnedGitReader>;
40
+ /** Injected issue-fetch capability; shared seam owns gh lifecycle. */
41
+ fetchIssue?: ReviewerIssueFetcher;
38
42
  runDispatch(execution: AcceptedReviewerExecution, options: { context: ExtensionContext; signal?: AbortSignal }): Promise<ReviewerDispatchRunResult>;
39
43
  shutdownAgent?(): Promise<void>;
40
44
  auditCompliance(options: { context: ExtensionContext; signal?: AbortSignal }): Promise<ComplianceDecision>;
@@ -86,6 +90,7 @@ export function createReviewerRoleRuntime(
86
90
  fixedBaseRevision = admitted.baseRevision;
87
91
  const reviewScopeKeys = admitted.reviewScopeKeys;
88
92
  const authorityRefs = admitted.authorityRefs;
93
+ const ticketNumber = admitted.ticketNumber;
89
94
  const loaded = await dependencies.loadCanonicalSkillBinding("code-review");
90
95
  if (loaded.name !== "code-review") throw new Error("Canonical Skill binding loader returned tdd for code-review");
91
96
  binding = loaded;
@@ -115,6 +120,8 @@ export function createReviewerRoleRuntime(
115
120
  reader,
116
121
  ...(reviewScopeKeys === undefined ? {} : { reviewScopeKeys }),
117
122
  ...(authorityRefs === undefined ? {} : { authorityRefs }),
123
+ ...(ticketNumber === undefined ? {} : { ticketNumber }),
124
+ ...(dependencies.fetchIssue === undefined ? {} : { fetchIssue: dependencies.fetchIssue }),
118
125
  decisionEvidence(decision) {
119
126
  try {
120
127
  if (decision.disposition === "accepted") {
@@ -56,6 +56,10 @@ export function assembleRuntimeReviewerReceipt(input: {
56
56
  ...(accepted.specDisposition === undefined
57
57
  ? {}
58
58
  : { specDisposition: accepted.specDisposition }),
59
+ // Self-fetch bytes + source annotation retained for audit (#343 fetch-then-store).
60
+ ...(accepted.specFetchedMaterial === undefined
61
+ ? {}
62
+ : { specFetchedMaterial: accepted.specFetchedMaterial }),
59
63
  }),
60
64
  reports,
61
65
  outcomes,
@@ -61,7 +61,7 @@ import {
61
61
  type ReviewerActivation,
62
62
  type ReviewerAdmittedInputs,
63
63
  } from "./reviewer-role.ts";
64
- import type { AcceptedReviewerExecution, ReviewerPinnedGitReader } from "./reviewer-dispatch.ts";
64
+ import type { AcceptedReviewerExecution, ReviewerIssueFetcher, ReviewerPinnedGitReader } from "./reviewer-dispatch.ts";
65
65
  import type { ReviewerDispatchRunResult } from "./reviewer-agent.ts";
66
66
  import {
67
67
  REVIEWER_VERIFICATION_BOUNDARY,
@@ -94,6 +94,13 @@ const REVIEWER_TRANSPORT_FLAGS = Object.freeze([
94
94
  type: "string" as const,
95
95
  }),
96
96
  }),
97
+ Object.freeze({
98
+ name: "ak-review-ticket-number",
99
+ definition: Object.freeze({
100
+ description: "Typed ticketNumber for Spec self-fetch primary path",
101
+ type: "string" as const,
102
+ }),
103
+ }),
97
104
  ] as const);
98
105
 
99
106
  /**
@@ -134,6 +141,13 @@ function decodeReviewerAdmittedInputs(getFlag: (name: string) => unknown): Revie
134
141
  authorityRefs = Object.freeze(parsed as string[]);
135
142
  }
136
143
 
144
+ let ticketNumber: number | undefined;
145
+ const rawTicketNumber = getFlag("ak-review-ticket-number");
146
+ // Shape-invalid flag values do not abort: omit typed candidate; branch→commit→degrade continues.
147
+ if (typeof rawTicketNumber === "string" && /^[1-9]\d*$/.test(rawTicketNumber)) {
148
+ ticketNumber = Number(rawTicketNumber);
149
+ }
150
+
137
151
  const baseRevision = getFlag("ak-review-base");
138
152
  if (typeof baseRevision !== "string" || !baseRevision.trim()) {
139
153
  throw new Error("Reviewer role requires --ak-review-base");
@@ -142,6 +156,7 @@ function decodeReviewerAdmittedInputs(getFlag: (name: string) => unknown): Revie
142
156
  baseRevision,
143
157
  ...(reviewScopeKeys === undefined ? {} : { reviewScopeKeys }),
144
158
  ...(authorityRefs === undefined ? {} : { authorityRefs }),
159
+ ...(ticketNumber === undefined ? {} : { ticketNumber }),
145
160
  });
146
161
  }
147
162
 
@@ -454,6 +469,8 @@ export type RoleRuntimeDependencies = {
454
469
  loadCoderTask?(path: string): Promise<string>;
455
470
  loadReviewerSoul?(): Promise<string>;
456
471
  createReviewerPinnedGitReader?(): Promise<ReviewerPinnedGitReader>;
472
+ /** Shared-seam issue-fetch capability for Reviewer Spec self-fetch (#343). */
473
+ createReviewerIssueFetcher?(): ReviewerIssueFetcher;
457
474
  loadCollectorSoul?(): Promise<string>;
458
475
  createCollectorTransport?(): CollectorGitHubTransport;
459
476
  loadDoctorSoul?(): Promise<string>;
@@ -892,6 +909,9 @@ export function createRoleRuntimeExtension(
892
909
  }
893
910
  return dependencies.loadCanonicalSkillBinding(name);
894
911
  },
912
+ ...(dependencies.createReviewerIssueFetcher === undefined
913
+ ? {}
914
+ : { fetchIssue: dependencies.createReviewerIssueFetcher() }),
895
915
  async runDispatch(dispatch, options) {
896
916
  if (dependencies.runReviewerDispatch === undefined) throw new Error("Reviewer runtime dependencies are not configured");
897
917
  return dependencies.runReviewerDispatch(dispatch, options);