@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
@@ -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.1941",
3
+ "version": "0.1.2014",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Same-directory temp + rename atomic file replace.
3
+ * Shared primitive for ledger-adjacent typed pages (taishi metrics, etc.).
4
+ * Does not open/truncate an existing destination inode, so hard-linked twins
5
+ * keep prior bytes until the directory entry is swapped.
6
+ * Parent directory must already exist — callers that write under the package
7
+ * ledger home own confinement via ensureRealDirectoryTree (ADR 0038).
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { rename, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+
13
+ export async function writeFileAtomically(
14
+ destination: string,
15
+ contents: string | Uint8Array,
16
+ ): Promise<void> {
17
+ const parent = dirname(destination);
18
+ const temporary = join(parent, `.atomic-write-${randomUUID()}.tmp`);
19
+ try {
20
+ await writeFile(temporary, contents);
21
+ await rename(temporary, destination);
22
+ } catch (error) {
23
+ await rm(temporary, { force: true }).catch(() => undefined);
24
+ throw error;
25
+ }
26
+ }
@@ -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 {