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