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