@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.
@@ -21006,6 +21006,8 @@ async function publishReviewerArtifacts(admitted, roleOutcome, sessionDirectory,
21006
21006
  baseRevision: admitted.baseRevision,
21007
21007
  authorityRefs: [...admitted.authorityRefs],
21008
21008
  ...admitted.instructionEmpty ? {} : { callerProvenance: admitted.instruction },
21009
+ // Self-fetch Spec bytes + source annotation when primary path produced material (#343).
21010
+ ...options.reviewerReceipt?.specFetchedMaterial === void 0 ? {} : { specFetchedMaterial: options.reviewerReceipt.specFetchedMaterial },
21009
21011
  attachments: admitted.attachments.map((a) => ({
21010
21012
  provenancePath: a.provenancePath,
21011
21013
  frozenPath: a.frozenPath,
@@ -23685,6 +23687,9 @@ function buildModelArgs7(model) {
23685
23687
  model.thinking
23686
23688
  ];
23687
23689
  }
23690
+ function buildReviewerTicketNumberArgs(ticketNumber) {
23691
+ return ticketNumber === void 0 ? [] : ["--ak-review-ticket-number", String(ticketNumber)];
23692
+ }
23688
23693
  function buildReviewerActivationExtraArgs(admitted, options) {
23689
23694
  const prompt = buildReviewerTransportPrompt(admitted);
23690
23695
  const skillPath = resolvePackagedMethodSkillPath(
@@ -23692,6 +23697,7 @@ function buildReviewerActivationExtraArgs(admitted, options) {
23692
23697
  "code-review"
23693
23698
  );
23694
23699
  const authorityRefArgs = admitted.authorityRefs.length === 0 ? [] : ["--ak-review-authority-refs", JSON.stringify([...admitted.authorityRefs])];
23700
+ const ticketNumberArgs = buildReviewerTicketNumberArgs(admitted.ticketNumber);
23695
23701
  return [
23696
23702
  "--no-skills",
23697
23703
  "--skill",
@@ -23709,6 +23715,7 @@ function buildReviewerActivationExtraArgs(admitted, options) {
23709
23715
  "--ak-review-base",
23710
23716
  admitted.baseRevision,
23711
23717
  ...authorityRefArgs,
23718
+ ...ticketNumberArgs,
23712
23719
  "--mode",
23713
23720
  "json",
23714
23721
  ...buildModelArgs7(options.model),
@@ -23721,6 +23728,7 @@ function buildReviewerResumeActivationExtraArgs(admitted, options) {
23721
23728
  "code-review"
23722
23729
  );
23723
23730
  const authorityRefArgs = admitted.authorityRefs.length === 0 ? [] : ["--ak-review-authority-refs", JSON.stringify([...admitted.authorityRefs])];
23731
+ const ticketNumberArgs = buildReviewerTicketNumberArgs(admitted.ticketNumber);
23724
23732
  return [
23725
23733
  "--no-skills",
23726
23734
  "--skill",
@@ -23738,6 +23746,7 @@ function buildReviewerResumeActivationExtraArgs(admitted, options) {
23738
23746
  "--ak-review-base",
23739
23747
  admitted.baseRevision,
23740
23748
  ...authorityRefArgs,
23749
+ ...ticketNumberArgs,
23741
23750
  "--mode",
23742
23751
  "json",
23743
23752
  ...buildModelArgs7(options.model),
@@ -89,10 +89,35 @@ export function reviewerAuthorityRefsMaterial(authorityRefs) {
89
89
  "These are durable authority references only. Read them as Spec grounding materials; do not invent Spec prose from caller instruction.",
90
90
  ].join("\n");
91
91
  }
92
+ /**
93
+ * Spec-only material carrier for self-fetched issue bytes + source annotation (#343).
94
+ * Actual issue body and referenced ADR bytes are embedded for audit (fetch-then-store).
95
+ * Single JSON payload keeps external issue/ADR bytes inside structured field values so they
96
+ * cannot forge package framing markers on the same text layer (no plain-text section protocol).
97
+ */
98
+ export function reviewerFetchedSpecMaterial(fetched) {
99
+ return [
100
+ "Authority-Fetched-Spec:",
101
+ JSON.stringify(Object.freeze({
102
+ source: fetched.adopted.source,
103
+ ticketNumber: fetched.ticketNumber,
104
+ issueRef: fetched.issueRef,
105
+ abandoned: Object.freeze([...fetched.abandoned]),
106
+ issueBody: fetched.issueBody,
107
+ adrs: Object.freeze(fetched.adrs.map((adr) => adr.status === "present"
108
+ ? Object.freeze({ path: adr.path, status: adr.status, body: adr.body })
109
+ : Object.freeze({ path: adr.path, status: adr.status }))),
110
+ })),
111
+ "These are self-fetched Spec grounding materials. Do not invent Spec prose from caller instruction.",
112
+ ].join("\n");
113
+ }
92
114
  /** Deterministic compiler: fixed target/range plus discovery product in, dispatch text out. */
93
115
  export function constructReviewerDispatch(input) {
94
116
  const launchSpec = input.specAuthority.status === "available";
95
117
  const authorityRefs = Object.freeze(input.specAuthority.status === "available" ? [...input.specAuthority.refs] : []);
118
+ const specFetchedMaterial = input.specAuthority.status === "available" && input.specAuthority.fetched !== undefined
119
+ ? input.specAuthority.fetched
120
+ : undefined;
96
121
  const specDisposition = launchSpec ? "launched" : "skipped-missing";
97
122
  const common = [
98
123
  `Target: ${input.range.target}`,
@@ -111,8 +136,13 @@ export function constructReviewerDispatch(input) {
111
136
  const legs = axes.map((x) => {
112
137
  const parts = [common, reviewerAxisMethodAdapter(x.axis)];
113
138
  // Spec evidence-child only — never Standards or a parent replacement Spec leg.
114
- if (x.axis === "spec" && authorityRefs.length > 0) {
115
- parts.push(reviewerAuthorityRefsMaterial(authorityRefs));
139
+ if (x.axis === "spec") {
140
+ if (specFetchedMaterial !== undefined) {
141
+ parts.push(reviewerFetchedSpecMaterial(specFetchedMaterial));
142
+ }
143
+ if (authorityRefs.length > 0) {
144
+ parts.push(reviewerAuthorityRefsMaterial(authorityRefs));
145
+ }
116
146
  }
117
147
  return Object.freeze({
118
148
  axis: x.axis,
@@ -130,6 +160,7 @@ export function constructReviewerDispatch(input) {
130
160
  range: input.range,
131
161
  authorityRefs,
132
162
  specDisposition,
163
+ ...(specFetchedMaterial === undefined ? {} : { specFetchedMaterial }),
133
164
  legs: Object.freeze(legs),
134
165
  });
135
166
  }
@@ -1,6 +1,6 @@
1
1
  import { sameReviewerPinnedTarget } from "./reviewer-git-snapshot.js";
2
- import { immutableReviewerPin } from "./reviewer-pinned-git.js";
3
- export { createReviewerPinnedGitReader, immutableReviewerPin } from "./reviewer-pinned-git.js";
2
+ import { branchNamesAtPinnedHead, immutableReviewerPin } from "./reviewer-pinned-git.js";
3
+ export { branchNamesAtPinnedHead, createReviewerPinnedGitReader, immutableReviewerPin } from "./reviewer-pinned-git.js";
4
4
  import { isReviewerPromptText, sameReviewerPromptText } from "./reviewer-prompt-identity.js";
5
5
  import { sha256Hex } from "./sha256.js";
6
6
  import { constructReviewerDispatch, } from "./reviewer-construction.js";
@@ -11,6 +11,12 @@ export { isReviewerPromptText as isReviewerPromptIdentity, sameReviewerPromptTex
11
11
  const GENERIC_FEATURE_TOKENS = new Set(["", "head", "main", "master", "trunk", "develop", "development"]);
12
12
  /** Conventional branch shells that must not hide the feature token (feat/login → login). */
13
13
  const BRANCH_SHELL_PREFIX = /^(?:feat|feature|fix|bugfix|hotfix|chore|docs|refactor)-/;
14
+ /** Branch token ticket capture: (fix|feat|docs|audit|test)/issue-(\d+)- (#343). */
15
+ const BRANCH_ISSUE_TOKEN = /(?:^|\/)((?:fix|feat|docs|audit|test)\/issue-(\d+)-)/;
16
+ /** First #N in a commit subject (positive integer). */
17
+ const COMMIT_TICKET_TOKEN = /#([1-9]\d*)/;
18
+ /** docs/adr paths referenced inside an issue body. */
19
+ const ADR_PATH_IN_BODY = /docs\/adr\/[A-Za-z0-9][A-Za-z0-9._/-]*\.md/g;
14
20
  function normalizeFeatureToken(value) {
15
21
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16
22
  }
@@ -26,22 +32,144 @@ function expandFeatureTokens(raw) {
26
32
  return Object.freeze([...tokens]);
27
33
  }
28
34
  /**
29
- * Unique production owner of code-review Skill step 2 Spec discovery.
30
- * Directly yields durable refs Spec child can read, or confirmed missing.
31
- * - Supplied authorityRefs ⇒ available with those refs as material.
32
- * - Matching pinned-target docs/specs/.scratch paths ⇒ available with those paths as material.
33
- * - Commit message bare #N without durable source missing (not available).
34
- * Only confirmed absence yields missing; other Git/I-O failures keep true cause for preflight.
35
+ * Shared capture/number positive-integer frozen candidate conversion.
36
+ * Single true source for branch/commit (and typed) ticket candidate materialization.
37
+ */
38
+ function ticketCandidateFromRaw(source, raw) {
39
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : Number.NaN;
40
+ if (!Number.isInteger(n) || n < 1)
41
+ return undefined;
42
+ return Object.freeze({ source, ticketNumber: n });
43
+ }
44
+ /**
45
+ * Resolve ticket number with unique priority (#343):
46
+ * typed ticketNumber → branch token → newest commit message first #N.
47
+ * High-priority hit is adopted; lower sources that also yield a number are abandoned candidates.
48
+ */
49
+ export function resolveReviewerTicketNumber(input) {
50
+ const typed = ticketCandidateFromRaw("typed-ticket-number", input.ticketNumber);
51
+ let branch;
52
+ for (const name of input.branchNames) {
53
+ const match = BRANCH_ISSUE_TOKEN.exec(name);
54
+ if (match) {
55
+ branch = ticketCandidateFromRaw("branch-token", match[2]);
56
+ if (branch !== undefined)
57
+ break;
58
+ }
59
+ }
60
+ let commit;
61
+ const newest = input.commitMessagesNewestFirst[0];
62
+ if (newest !== undefined) {
63
+ const match = COMMIT_TICKET_TOKEN.exec(newest);
64
+ if (match) {
65
+ commit = ticketCandidateFromRaw("commit-message", match[1]);
66
+ }
67
+ }
68
+ if (typed !== undefined) {
69
+ const abandoned = [branch, commit].filter((c) => c !== undefined);
70
+ return Object.freeze({ adopted: typed, abandoned: Object.freeze(abandoned) });
71
+ }
72
+ if (branch !== undefined) {
73
+ const abandoned = commit === undefined ? Object.freeze([]) : Object.freeze([commit]);
74
+ return Object.freeze({ adopted: branch, abandoned });
75
+ }
76
+ if (commit !== undefined) {
77
+ return Object.freeze({ adopted: commit, abandoned: Object.freeze([]) });
78
+ }
79
+ return undefined;
80
+ }
81
+ /** Extract unique docs/adr/*.md paths referenced by issue body text (order of first appearance). */
82
+ export function extractReferencedAdrPaths(issueBody) {
83
+ const seen = new Set();
84
+ const paths = [];
85
+ for (const match of issueBody.matchAll(ADR_PATH_IN_BODY)) {
86
+ const path = match[0];
87
+ if (seen.has(path))
88
+ continue;
89
+ seen.add(path);
90
+ paths.push(path);
91
+ }
92
+ return Object.freeze(paths);
93
+ }
94
+ /** Optional AbortSignal carried on the dispatch invocation bag (same shape runDispatch already reads). */
95
+ function optionalInvocationSignal(invocation) {
96
+ if (typeof invocation !== "object" || invocation === null)
97
+ return undefined;
98
+ const signal = invocation.signal;
99
+ return signal instanceof AbortSignal ? signal : undefined;
100
+ }
101
+ /**
102
+ * Unique production owner of code-review Skill step 2 Spec discovery (#343).
103
+ * Primary: self-fetch latest issue by ticket number (typed → branch token → commit #N).
104
+ * Degradation (unique order): self-fetch fail → supplied authorityRefs → local path match → missing.
105
+ * Prompt/admitted-request prose is never Spec material.
106
+ * Only confirmed absence yields missing; non-absence Git/I-O failures keep true cause for preflight.
35
107
  * Construction builds Standards/Spec solely from this product.
36
108
  */
37
109
  export async function discoverReviewerSpecAuthority(input) {
110
+ // Path-matching tokens (heads/tags/remotes) stay separate from branch-ticket provenance.
111
+ const featureTokens = await input.reader.featureTokens();
112
+ const commitMessages = input.baseCommit === undefined
113
+ ? Object.freeze([])
114
+ : await input.reader.commitMessagesNewestFirst(input.baseCommit);
115
+ const ticketResolution = resolveReviewerTicketNumber({
116
+ ...(input.ticketNumber === undefined ? {} : { ticketNumber: input.ticketNumber }),
117
+ // Branch ticket source: real heads/remotes at targetHead only — never tags via featureTokens.
118
+ branchNames: branchNamesAtPinnedHead(input.reader.pin),
119
+ commitMessagesNewestFirst: commitMessages,
120
+ });
121
+ // ① Primary: self-fetch latest issue + referenced docs/adr via injected capability only.
122
+ if (ticketResolution !== undefined) {
123
+ const origin = await input.reader.originRepository();
124
+ if (origin !== undefined && input.fetchIssue !== undefined) {
125
+ const issue = await input.fetchIssue({
126
+ owner: origin.owner,
127
+ repo: origin.repo,
128
+ ticketNumber: ticketResolution.adopted.ticketNumber,
129
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
130
+ });
131
+ if (issue !== undefined) {
132
+ const adrPaths = extractReferencedAdrPaths(issue.body);
133
+ const adrs = [];
134
+ for (const path of adrPaths) {
135
+ const body = await input.reader.readPinnedText(path);
136
+ if (body === undefined) {
137
+ adrs.push(Object.freeze({ path, status: "missing" }));
138
+ }
139
+ else {
140
+ adrs.push(Object.freeze({ path, status: "present", body }));
141
+ }
142
+ }
143
+ const issueRef = `https://github.com/${origin.owner}/${origin.repo}/issues/${ticketResolution.adopted.ticketNumber}`;
144
+ const presentAdrRefs = adrs
145
+ .filter((a) => a.status === "present")
146
+ .map((a) => a.path);
147
+ const fetched = Object.freeze({
148
+ issueRef,
149
+ owner: origin.owner,
150
+ repo: origin.repo,
151
+ ticketNumber: ticketResolution.adopted.ticketNumber,
152
+ adopted: ticketResolution.adopted,
153
+ abandoned: ticketResolution.abandoned,
154
+ issueBody: issue.body,
155
+ adrs: Object.freeze(adrs),
156
+ });
157
+ return Object.freeze({
158
+ status: "available",
159
+ refs: Object.freeze([issueRef, ...presentAdrRefs]),
160
+ fetched,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ // ② Degrade: explicit --authority-ref (human intent before local heuristics).
38
166
  if (input.authorityRefs.length > 0) {
39
167
  return Object.freeze({
40
168
  status: "available",
41
169
  refs: Object.freeze([...input.authorityRefs]),
42
170
  });
43
171
  }
44
- const featureTokens = await input.reader.featureTokens();
172
+ // Degrade: matching pinned-target docs/specs/.scratch paths via branch feature tokens.
45
173
  const tokens = [
46
174
  ...new Set(featureTokens
47
175
  .flatMap((raw) => expandFeatureTokens(raw))
@@ -107,9 +235,14 @@ export function createReviewerDispatcher(d) {
107
235
  const base = await d.reader.resolve(baseRevision);
108
236
  const range = await d.reader.range(base);
109
237
  const authorityRefs = Object.freeze([...(d.authorityRefs ?? [])]);
238
+ const signal = optionalInvocationSignal(invocation);
110
239
  const specAuthority = await discoverReviewerSpecAuthority({
111
240
  authorityRefs,
112
241
  reader: d.reader,
242
+ baseCommit: base,
243
+ ...(d.ticketNumber === undefined ? {} : { ticketNumber: d.ticketNumber }),
244
+ ...(d.fetchIssue === undefined ? {} : { fetchIssue: d.fetchIssue }),
245
+ ...(signal === undefined ? {} : { signal }),
113
246
  });
114
247
  dispatch = constructReviewerDispatch({
115
248
  identity,
@@ -6,7 +6,11 @@ export function projectAcceptedDispatch(dispatch) {
6
6
  source: "reviewer-dispatch", type: "accepted", identity: dispatch.identity,
7
7
  recipe: dispatch.recipe, input: dispatch.input, target: dispatch.targetSnapshot,
8
8
  range: dispatch.range, authorityRefs: dispatch.authorityRefs,
9
- specDisposition: dispatch.specDisposition, legs: dispatch.legs,
9
+ specDisposition: dispatch.specDisposition,
10
+ ...(dispatch.specFetchedMaterial === undefined
11
+ ? {}
12
+ : { specFetchedMaterial: dispatch.specFetchedMaterial }),
13
+ legs: dispatch.legs,
10
14
  };
11
15
  }
12
16
  export function projectReviewerDispatchOutcome(ledger, dispatch, result) {
@@ -6,8 +6,12 @@ import { sha256Hex } from "./sha256.js";
6
6
  import { ReviewerCorrectablePreflightError } from "./reviewer-preflight-error.js";
7
7
  const execFileAsync = promisify(execFile);
8
8
  async function execGit(args, options) {
9
+ // Pin C locale at the sole Git exec seam so English diagnostic classifiers stay honest under translated gettext installs.
9
10
  try {
10
- return await execFileAsync("git", args, options);
11
+ return await execFileAsync("git", args, {
12
+ ...options,
13
+ env: { ...process.env, LC_ALL: "C" },
14
+ });
11
15
  }
12
16
  catch (error) {
13
17
  const source = error;
@@ -17,6 +21,23 @@ async function execGit(args, options) {
17
21
  }
18
22
  }
19
23
  function exitCode(error) { const code = typeof error === "object" && error !== null ? error.code : undefined; return typeof code === "number" ? code : undefined; }
24
+ function gitStderr(error) {
25
+ if (typeof error !== "object" || error === null)
26
+ return "";
27
+ const stderr = error.stderr;
28
+ return typeof stderr === "string" ? stderr : "";
29
+ }
30
+ /** Confirmed `origin` remote absence only (`git remote get-url origin`). */
31
+ function isConfirmedMissingOriginRemote(error) {
32
+ return /No such remote ['"]origin['"]/.test(gitStderr(error));
33
+ }
34
+ /** Confirmed path-at-pinned-tree absence only — exit 128 alone is not enough. */
35
+ function isConfirmedPinnedPathAbsent(error, path) {
36
+ const stderr = gitStderr(error);
37
+ const quoted = `'${path}'`;
38
+ return (stderr.includes(`path ${quoted} does not exist in `) ||
39
+ stderr.includes(`path ${quoted} exists on disk, but not in `));
40
+ }
20
41
  async function repositoryIsAvailable(root) { try {
21
42
  await access(`${root}/.git`);
22
43
  return { available: true };
@@ -27,6 +48,35 @@ catch (cause) {
27
48
  export const immutableReviewerPin = (pin) => Object.freeze({
28
49
  repositoryRoot: pin.repositoryRoot, objectFormat: pin.objectFormat, targetHead: pin.targetHead, refs: immutableReviewerRefs(pin.refs),
29
50
  });
51
+ /** Short name from a full ref, stripping heads/tags/remotes namespaces (and remote remote-name). */
52
+ function shortNameFromPinnedRef(refName) {
53
+ const short = refName.startsWith("refs/heads/")
54
+ ? refName.slice("refs/heads/".length)
55
+ : refName.startsWith("refs/tags/")
56
+ ? refName.slice("refs/tags/".length)
57
+ : refName.startsWith("refs/remotes/")
58
+ ? refName.slice("refs/remotes/".length).replace(/^[^/]+\//, "")
59
+ : refName;
60
+ const trimmed = short.trim();
61
+ return trimmed === "" ? undefined : trimmed;
62
+ }
63
+ /**
64
+ * Branch-only short names at pinned targetHead for ticket-number provenance (#343).
65
+ * Heads and remotes only — tags never supply branch-token ticket candidates.
66
+ */
67
+ export function branchNamesAtPinnedHead(pin) {
68
+ const names = new Set();
69
+ for (const [refName, entry] of Object.entries(pin.refs)) {
70
+ if (entry.peeledCommitId !== pin.targetHead)
71
+ continue;
72
+ if (!refName.startsWith("refs/heads/") && !refName.startsWith("refs/remotes/"))
73
+ continue;
74
+ const short = shortNameFromPinnedRef(refName);
75
+ if (short !== undefined)
76
+ names.add(short);
77
+ }
78
+ return Object.freeze([...names]);
79
+ }
30
80
  async function gitText(root, args) {
31
81
  const { stdout } = await execGit(["-C", root, ...args], { encoding: "utf8" });
32
82
  return stdout.trim();
@@ -145,19 +195,14 @@ export async function createReviewerPinnedGitReader(root = process.cwd()) {
145
195
  async featureTokens() {
146
196
  // Pinned ref snapshot is the target-tree fact — no live branch/symbolic-ref walk,
147
197
  // no catch-to-empty. Detached/remote-only tips surface via refs/remotes/* entries.
198
+ // Includes tags for local Spec-path matching only; ticket branch source is separate.
148
199
  const names = new Set();
149
200
  for (const [refName, entry] of Object.entries(pin.refs)) {
150
201
  if (entry.peeledCommitId !== targetHead)
151
202
  continue;
152
- const short = refName.startsWith("refs/heads/")
153
- ? refName.slice("refs/heads/".length)
154
- : refName.startsWith("refs/tags/")
155
- ? refName.slice("refs/tags/".length)
156
- : refName.startsWith("refs/remotes/")
157
- ? refName.slice("refs/remotes/".length).replace(/^[^/]+\//, "")
158
- : refName;
159
- if (short.trim() !== "")
160
- names.add(short.trim());
203
+ const short = shortNameFromPinnedRef(refName);
204
+ if (short !== undefined)
205
+ names.add(short);
161
206
  }
162
207
  return Object.freeze([...names]);
163
208
  },
@@ -175,5 +220,89 @@ export async function createReviewerPinnedGitReader(root = process.cwd()) {
175
220
  ]);
176
221
  return Object.freeze(text === "" ? [] : text.split("\n").filter((line) => line.length > 0));
177
222
  },
223
+ async originRepository() {
224
+ let remoteUrl;
225
+ try {
226
+ remoteUrl = await gitText(repositoryRoot, ["remote", "get-url", "origin"]);
227
+ }
228
+ catch (error) {
229
+ // Only confirmed origin absence softens to unavailable; other Git failures keep true cause.
230
+ if (isConfirmedMissingOriginRemote(error))
231
+ return undefined;
232
+ throw error;
233
+ }
234
+ // Non-github / unparseable remote URL = self-fetch unavailable (soft degrade).
235
+ return parseGitHubOriginRemote(remoteUrl);
236
+ },
237
+ async commitMessagesNewestFirst(base) {
238
+ const text = await gitText(repositoryRoot, [
239
+ "log",
240
+ "--format=%s",
241
+ `${base}..${targetHead}`,
242
+ ]);
243
+ return Object.freeze(text === "" ? [] : text.split("\n"));
244
+ },
245
+ async readPinnedText(path) {
246
+ // Reject path traversal / absolute paths — Spec material is relative tree paths only.
247
+ if (path.length === 0 ||
248
+ path.startsWith("/") ||
249
+ path.includes("\0") ||
250
+ path.split("/").some((part) => part === ".." || part === "")) {
251
+ return undefined;
252
+ }
253
+ try {
254
+ const { stdout } = await execGit(["-C", repositoryRoot, "show", `${targetHead}:${path}`], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
255
+ return stdout;
256
+ }
257
+ catch (error) {
258
+ // Only confirmed path-at-pinned-tree absence softens to missing; exit 128 is not a blanket.
259
+ if (isConfirmedPinnedPathAbsent(error, path))
260
+ return undefined;
261
+ throw error;
262
+ }
263
+ },
178
264
  });
179
265
  }
266
+ /**
267
+ * Parse github.com owner/repo from a git remote URL.
268
+ * Supports scp-like SSH, ssh://, https://, and git:// shapes. Soft: undefined when not github.
269
+ */
270
+ export function parseGitHubOriginRemote(remoteUrl) {
271
+ const trimmed = remoteUrl.trim();
272
+ if (trimmed.length === 0)
273
+ return undefined;
274
+ const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
275
+ if (scp)
276
+ return normalizeOrigin(scp[1], scp[2]);
277
+ const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed);
278
+ if (ssh)
279
+ return normalizeOrigin(ssh[1], ssh[2]);
280
+ let parsed;
281
+ try {
282
+ parsed = new URL(trimmed);
283
+ }
284
+ catch {
285
+ return undefined;
286
+ }
287
+ if (!/^github\.com$/i.test(parsed.hostname))
288
+ return undefined;
289
+ if (parsed.search !== "" || parsed.hash !== "")
290
+ return undefined;
291
+ const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
292
+ if (parts.length !== 2)
293
+ return undefined;
294
+ return normalizeOrigin(parts[0], parts[1]);
295
+ }
296
+ function normalizeOrigin(ownerRaw, repoRaw) {
297
+ const owner = ownerRaw.trim();
298
+ const repo = stripGitSuffix(repoRaw.trim());
299
+ if (owner.length === 0 || repo.length === 0)
300
+ return undefined;
301
+ // Conservative identity: no path separators or URL material inside segments.
302
+ if (/[/?#@\\]/.test(owner) || /[/?#@\\]/.test(repo))
303
+ return undefined;
304
+ return Object.freeze({ owner, repo });
305
+ }
306
+ function stripGitSuffix(name) {
307
+ return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
308
+ }
@@ -14,7 +14,7 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import type { Message } from "@earendil-works/pi-ai";
16
16
 
17
- import { createGhCollectorGitHubTransport } from "../src/collector-github.ts";
17
+ import { createGhCollectorGitHubTransport, createGhIssueSoftFetcher } from "../src/collector-github.ts";
18
18
  import { createReviewerAgentRunner } from "../src/reviewer-agent.ts";
19
19
  import { createReviewerPinnedGitReader } from "../src/reviewer-dispatch.ts";
20
20
  import { createPiReviewerAuditor } from "../src/reviewer-auditor.ts";
@@ -252,6 +252,7 @@ export default function roleRuntime(pi: ExtensionAPI): void {
252
252
  loadCoderTask: (path) => readFile(path, "utf8"),
253
253
  loadReviewerSoul: () => readFile(reviewerSoulPath, "utf8"),
254
254
  createReviewerPinnedGitReader: () => createReviewerPinnedGitReader(),
255
+ createReviewerIssueFetcher: () => createGhIssueSoftFetcher(),
255
256
  loadCollectorSoul: () => readFile(collectorSoulPath, "utf8"),
256
257
  createCollectorTransport: () => createGhCollectorGitHubTransport(),
257
258
  collectorPackageExtensionPath: extensionPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2004",
3
+ "version": "0.1.2014",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -434,6 +434,100 @@ export function createGhApiRunner(
434
434
  };
435
435
  }
436
436
 
437
+ export type GhIssueSoftFetchResult = Readonly<{ body: string }>;
438
+ /**
439
+ * Soft single-issue fetch over the shared gh api runner.
440
+ * undefined = confirmed tracker unreachable / issue not found / gh tool unavailable:
441
+ * - HTTP non-2xx, or
442
+ * - runner-tagged ambiguousGhFailure (gh ran but no parseable HTTP — auth/network/transport), or
443
+ * - gh process could not start (ENOENT / spawn syscall failure).
444
+ * After gh starts successfully: response JSON/shape/implementation errors propagate with true cause.
445
+ */
446
+ export type GhIssueSoftFetcher = (input: {
447
+ owner: string;
448
+ repo: string;
449
+ ticketNumber: number;
450
+ /** Optional cancellation signal forwarded to the shared GhApiRunner. */
451
+ signal?: AbortSignal;
452
+ }) => Promise<GhIssueSoftFetchResult | undefined>;
453
+
454
+ function isAmbiguousGhFailure(error: unknown): boolean {
455
+ return (
456
+ typeof error === "object" &&
457
+ error !== null &&
458
+ (error as { ambiguousGhFailure?: unknown }).ambiguousGhFailure === true
459
+ );
460
+ }
461
+
462
+ /** gh binary missing or otherwise unable to launch — ticket-authorized soft unavailable. */
463
+ function isGhProcessStartFailure(error: unknown): boolean {
464
+ if (typeof error !== "object" || error === null) return false;
465
+ const code = (error as NodeJS.ErrnoException).code;
466
+ if (code === "ENOENT") return true;
467
+ const syscall = (error as NodeJS.ErrnoException).syscall;
468
+ return typeof syscall === "string" && (syscall === "spawn" || syscall.startsWith("spawn "));
469
+ }
470
+
471
+ /**
472
+ * Production issue-fetch capability owned by the shared gh execution seam.
473
+ * Reuses createGhApiRunner lifecycle. Softens only ticket-authorized unavailable results
474
+ * (tracker unreachable / issue not found / gh cannot start); does not catch-all wash
475
+ * post-start parse or implementation failures into unavailable.
476
+ */
477
+ export function createGhIssueSoftFetcher(
478
+ runner: GhApiRunner = createGhApiRunner(),
479
+ ): GhIssueSoftFetcher {
480
+ return async (input) => {
481
+ const path = `repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
482
+ let response: GhApiResponse;
483
+ try {
484
+ // Forward invocation AbortSignal into the shared GhApiRunner lifecycle (no local timeout).
485
+ response = await runner(
486
+ [
487
+ "api",
488
+ "--hostname",
489
+ "github.com",
490
+ "--include",
491
+ "-X",
492
+ "GET",
493
+ path,
494
+ ],
495
+ input.signal === undefined ? {} : { signal: input.signal },
496
+ );
497
+ } catch (error) {
498
+ // Ticket-authorized soft unavailable: tagged transport ambiguity, or gh never started.
499
+ // Cancellation / other post-start failures keep true cause (not washed into degrade).
500
+ if (isAmbiguousGhFailure(error) || isGhProcessStartFailure(error)) return undefined;
501
+ throw error;
502
+ }
503
+ // Ticket-authorized degrade: issue not found / tracker non-success via HTTP status.
504
+ if (response.status < 200 || response.status >= 300) return undefined;
505
+ let parsed: unknown;
506
+ try {
507
+ parsed = JSON.parse(response.bodyText);
508
+ } catch (error) {
509
+ throw new Error("GitHub issue payload is not JSON", { cause: error });
510
+ }
511
+ if (typeof parsed !== "object" || parsed === null) {
512
+ throw new Error("GitHub issue payload must be a JSON object");
513
+ }
514
+ // Issues endpoint also returns PRs. Own-key presence of the standard pull_request marker
515
+ // means this is a PR payload — soft-unavailable so Spec does not adopt PR description as issue body.
516
+ // Key presence only; no marker-content parse, PR schema, or linked-issue chase.
517
+ if (Object.hasOwn(parsed, "pull_request")) {
518
+ return undefined;
519
+ }
520
+ // Match former gh --jq `(.body // "")`: null/missing body projects to empty string.
521
+ // Title is not ticket-authorized audited material — do not parse or validate it.
522
+ const bodyRaw = (parsed as { body?: unknown }).body;
523
+ if (bodyRaw !== undefined && bodyRaw !== null && typeof bodyRaw !== "string") {
524
+ throw new Error("GitHub issue payload body must be string or null");
525
+ }
526
+ const body = typeof bodyRaw === "string" ? bodyRaw : "";
527
+ return Object.freeze({ body });
528
+ };
529
+ }
530
+
437
531
  export function createGhCollectorGitHubTransport(
438
532
  runner: GhApiRunner = createGhApiRunner(),
439
533
  ): CollectorGitHubTransport {
@@ -35,6 +35,8 @@ export type RuntimeReviewerReceiptV2 = Readonly<{
35
35
  acceptedBatch?: RuntimeReviewerAcceptedBatch;
36
36
  /** Present on accepted batches: launched Spec child, or skipped after confirmed missing Spec. */
37
37
  specDisposition?: RuntimeReviewerSpecDisposition;
38
+ /** Self-fetch bytes + source annotation when Spec primary path produced material (#343). */
39
+ specFetchedMaterial?: ReviewerAcceptedEvidence["specFetchedMaterial"];
38
40
  reports: Readonly<Partial<Record<"standards" | "spec", VerbatimChildReport>>>;
39
41
  outcomes: Readonly<Partial<Record<"standards" | "spec", RuntimeReviewerOutcome>>>;
40
42
  identities: Readonly<{