@akagilnc/pi-workflow-roles 0.1.2004 → 0.1.2020
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.
- package/dist/public-cli/main.js +9 -0
- package/dist/reviewer-construction.js +33 -2
- package/dist/reviewer-dispatch.js +142 -9
- package/dist/reviewer-execution-ledger.js +5 -1
- package/dist/reviewer-pinned-git.js +139 -10
- package/extensions/role-runtime.ts +2 -1
- package/package.json +1 -1
- package/src/collector-github.ts +94 -0
- package/src/package-contracts/reviewer-output.ts +2 -0
- package/src/public-cli/reviewer-run.ts +13 -0
- package/src/public-cli/settlement.ts +4 -0
- package/src/reviewer-construction.ts +81 -3
- package/src/reviewer-dispatch.ts +189 -9
- package/src/reviewer-execution-ledger.ts +5 -1
- package/src/reviewer-pinned-git.ts +154 -11
- package/src/reviewer-role.ts +8 -1
- package/src/reviewer-settlement.ts +4 -0
- package/src/role-runtime.ts +21 -1
package/dist/public-cli/main.js
CHANGED
|
@@ -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"
|
|
115
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
package/src/collector-github.ts
CHANGED
|
@@ -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<{
|