@jwilger/pi-development-system 0.4.0 → 0.7.0
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/extensions/development-system.ts +18 -0
- package/package.json +1 -1
- package/src/core/commit-command.ts +114 -0
- package/src/core/commit-message.ts +105 -0
- package/src/core/exec.ts +6 -0
- package/src/core/gates.ts +2 -2
- package/src/core/git-intent.ts +1 -1
- package/src/core/models.ts +16 -0
- package/src/core/push-command.ts +36 -0
- package/src/gates/commit-guard.ts +136 -0
- package/src/gates/departure-use.ts +28 -0
- package/src/gates/push-guard.ts +168 -0
- package/src/gates/test-guard.ts +4 -23
- package/src/jev/questions/commit.ts +62 -0
- package/src/jev/questions/fix-related.ts +37 -0
- package/src/state/ci.ts +71 -0
- package/src/state/models-command.ts +234 -0
|
@@ -4,11 +4,14 @@ import { appendContextTail, renderContextTail } from "../src/context/context-tai
|
|
|
4
4
|
import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
|
|
5
5
|
import { applyPromptSection } from "../src/context/system-prompt.ts";
|
|
6
6
|
import { createApprovalStore } from "../src/gates/approvals.ts";
|
|
7
|
+
import { registerCommitGuard } from "../src/gates/commit-guard.ts";
|
|
7
8
|
import { registerGitGuard } from "../src/gates/git-guard.ts";
|
|
9
|
+
import { registerPushGuard } from "../src/gates/push-guard.ts";
|
|
8
10
|
import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts";
|
|
9
11
|
import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
|
|
10
12
|
import { registerTestGuard } from "../src/gates/test-guard.ts";
|
|
11
13
|
import { createJevHolder } from "../src/jev/holder.ts";
|
|
14
|
+
import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
|
|
12
15
|
import { createSessionState } from "../src/state/session-state.ts";
|
|
13
16
|
|
|
14
17
|
const nonNegotiables = readFileSync(
|
|
@@ -57,9 +60,24 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
57
60
|
});
|
|
58
61
|
|
|
59
62
|
registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
63
|
+
registerCommitGuard({
|
|
64
|
+
pi,
|
|
65
|
+
state,
|
|
66
|
+
jev: (ctx) => jevHolder.forContext(ctx),
|
|
67
|
+
exec: (command, args, options) => pi.exec(command, args, options),
|
|
68
|
+
});
|
|
69
|
+
registerPushGuard({
|
|
70
|
+
pi,
|
|
71
|
+
state,
|
|
72
|
+
approvals,
|
|
73
|
+
jev: (ctx) => jevHolder.forContext(ctx),
|
|
74
|
+
exec: (command, args, options) => pi.exec(command, args, options),
|
|
75
|
+
});
|
|
60
76
|
registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
61
77
|
pi.registerTool(createRecordDepartureTool({ pi, state }));
|
|
62
78
|
pi.registerTool(createRequestApprovalTool({ pi, approvals }));
|
|
79
|
+
pi.registerTool(createModelsTool());
|
|
80
|
+
registerModelsCommand(pi);
|
|
63
81
|
|
|
64
82
|
pi.registerCommand("devsys-status", {
|
|
65
83
|
description: "Show development-system phase, sizing, slice, departures and Jev status",
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { splitLines } from "./git-intent.ts";
|
|
2
|
+
import { parseShell } from "./shell-parse.ts";
|
|
3
|
+
|
|
4
|
+
export type CommitExtraction =
|
|
5
|
+
| { readonly kind: "not-commit" }
|
|
6
|
+
| { readonly kind: "message"; readonly message: string }
|
|
7
|
+
| { readonly kind: "file"; readonly path: string }
|
|
8
|
+
| { readonly kind: "unknown" };
|
|
9
|
+
|
|
10
|
+
const HEREDOC =
|
|
11
|
+
/<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n([\s\S]*?)\n[ \t]*\2[ \t]*(?:\n|$)/g;
|
|
12
|
+
const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
|
|
13
|
+
|
|
14
|
+
/** Heredoc bodies are data: take them out so their quotes cannot confuse line splitting. */
|
|
15
|
+
function stripHeredocs(command: string): { rest: string; bodies: string[] } {
|
|
16
|
+
const bodies: string[] = [];
|
|
17
|
+
const rest = command.replace(HEREDOC, (_all, _quote, _tag, body: string) => {
|
|
18
|
+
bodies.push(body);
|
|
19
|
+
return "\n";
|
|
20
|
+
});
|
|
21
|
+
return { rest, bodies };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function segmentsOf(line: string): string[][] {
|
|
25
|
+
const out: string[][] = [[]];
|
|
26
|
+
for (const t of parseShell(line, (name) => `$${name}`)) {
|
|
27
|
+
if (typeof t === "string") out.at(-1)?.push(t);
|
|
28
|
+
else if ("op" in t && /^[<>]/.test(t.op)) continue;
|
|
29
|
+
else if (!("comment" in t)) out.push([]);
|
|
30
|
+
}
|
|
31
|
+
return out.filter((s) => s.length > 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const WRAPPERS = new Set(["command", "sudo", "env", "time", "nohup", "exec", "builtin", "nice"]);
|
|
35
|
+
const isAssignment = (w: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w);
|
|
36
|
+
const isGit = (w: string): boolean => w === "git" || w.endsWith("/git");
|
|
37
|
+
|
|
38
|
+
/** Index of the word after `git [global options]`, when this segment's command is git. */
|
|
39
|
+
function subcommandIndex(segment: readonly string[]): number | undefined {
|
|
40
|
+
const start = segment.findIndex((w) => !isAssignment(w) && !WRAPPERS.has(w));
|
|
41
|
+
if (start === -1 || !isGit(segment[start] ?? "")) return undefined;
|
|
42
|
+
let skipNext = false;
|
|
43
|
+
for (let i = start + 1; i < segment.length; i++) {
|
|
44
|
+
const w = segment[i] ?? "";
|
|
45
|
+
if (skipNext) skipNext = false;
|
|
46
|
+
else if (GLOBAL_WITH_VALUE.has(w)) skipNext = true;
|
|
47
|
+
else if (!w.startsWith("-")) return i;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Options = {
|
|
53
|
+
messages: string[];
|
|
54
|
+
trailers: string[];
|
|
55
|
+
file: string | undefined;
|
|
56
|
+
opaque: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function readOptions(args: readonly string[]): Options {
|
|
60
|
+
const options: Options = { messages: [], trailers: [], file: undefined, opaque: false };
|
|
61
|
+
const take = (value: string | undefined) => {
|
|
62
|
+
if (value === undefined || /\$\(|`/.test(value)) options.opaque = true;
|
|
63
|
+
else options.messages.push(value);
|
|
64
|
+
};
|
|
65
|
+
for (let i = 0; i < args.length; i++) {
|
|
66
|
+
const a = args[i] ?? "";
|
|
67
|
+
if (a === "-m" || a === "--message") take(args[++i]);
|
|
68
|
+
else if (a.startsWith("--message=")) take(a.slice("--message=".length));
|
|
69
|
+
else if (a === "-F" || a === "--file") options.file = args[++i];
|
|
70
|
+
else if (a.startsWith("--file=")) options.file = a.slice("--file=".length);
|
|
71
|
+
else if (a === "--trailer") options.trailers.push(args[++i] ?? "");
|
|
72
|
+
else if (a.startsWith("--trailer=")) options.trailers.push(a.slice("--trailer=".length));
|
|
73
|
+
else if (/^-[A-Za-z]*m$/.test(a)) take(args[++i]);
|
|
74
|
+
else if (/^-[A-Za-z]*m./.test(a)) take(a.slice(a.indexOf("m") + 1));
|
|
75
|
+
}
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The commit message a `git commit` shell command will use, or why it cannot be known. */
|
|
80
|
+
export function extractCommit(command: string): CommitExtraction {
|
|
81
|
+
const { rest, bodies } = stripHeredocs(command);
|
|
82
|
+
const heredoc = bodies[0]?.trim();
|
|
83
|
+
for (const line of splitLines(rest)) {
|
|
84
|
+
for (const segment of segmentsOf(line)) {
|
|
85
|
+
const at = subcommandIndex(segment);
|
|
86
|
+
if (at === undefined || segment[at] !== "commit") continue;
|
|
87
|
+
const options = readOptions(segment.slice(at + 1));
|
|
88
|
+
if (options.file === "-" && heredoc !== undefined)
|
|
89
|
+
return { kind: "message", message: heredoc };
|
|
90
|
+
if (options.file !== undefined) return { kind: "file", path: options.file };
|
|
91
|
+
if (options.opaque) {
|
|
92
|
+
return heredoc === undefined ? { kind: "unknown" } : { kind: "message", message: heredoc };
|
|
93
|
+
}
|
|
94
|
+
if (options.messages.length === 0) return { kind: "unknown" };
|
|
95
|
+
const trailers = options.trailers.length === 0 ? "" : `\n\n${options.trailers.join("\n")}`;
|
|
96
|
+
return { kind: "message", message: `${options.messages.join("\n\n")}${trailers}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { kind: "not-commit" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type GitInvocation = { readonly sub: string; readonly args: readonly string[] };
|
|
103
|
+
|
|
104
|
+
/** Every `git <sub> <args…>` simple command in a shell command string (heredoc bodies excluded). */
|
|
105
|
+
export function gitInvocations(command: string): GitInvocation[] {
|
|
106
|
+
const { rest } = stripHeredocs(command);
|
|
107
|
+
return splitLines(rest).flatMap((line) =>
|
|
108
|
+
segmentsOf(line).flatMap((segment): GitInvocation[] => {
|
|
109
|
+
const at = subcommandIndex(segment);
|
|
110
|
+
const sub = at === undefined ? undefined : segment[at];
|
|
111
|
+
return at === undefined || sub === undefined ? [] : [{ sub, args: segment.slice(at + 1) }];
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { err, ok, type Result } from "./result.ts";
|
|
2
|
+
import { type ParseError, parseError } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
type Trailer = { readonly key: string; readonly value: string };
|
|
5
|
+
|
|
6
|
+
export type ParsedCommit = {
|
|
7
|
+
readonly type: string;
|
|
8
|
+
readonly scope: string | undefined;
|
|
9
|
+
readonly breaking: boolean;
|
|
10
|
+
readonly subject: string;
|
|
11
|
+
readonly body: string;
|
|
12
|
+
readonly trailers: readonly Trailer[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SUBJECT = /^([a-z]+)(?:\(([^()\s][^()]*)\))?(!)?: (\S.*)$/;
|
|
16
|
+
const TRAILER = /^([A-Za-z][A-Za-z-]*|BREAKING CHANGE)(?:: | #)(.*)$/;
|
|
17
|
+
|
|
18
|
+
const withoutComments = (message: string): string =>
|
|
19
|
+
message
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter((line) => !line.startsWith("#"))
|
|
22
|
+
.join("\n")
|
|
23
|
+
.trim();
|
|
24
|
+
|
|
25
|
+
const paragraphs = (text: string): string[] =>
|
|
26
|
+
text
|
|
27
|
+
.split(/\n\s*\n/)
|
|
28
|
+
.map((p) => p.trim())
|
|
29
|
+
.filter((p) => p !== "");
|
|
30
|
+
|
|
31
|
+
const trailerBlock = (parts: readonly string[]): Trailer[] | undefined => {
|
|
32
|
+
const last = parts.at(-1);
|
|
33
|
+
if (last === undefined) return undefined;
|
|
34
|
+
const lines = last.split("\n");
|
|
35
|
+
const parsed = lines.map((line) => TRAILER.exec(line));
|
|
36
|
+
if (parsed.some((m) => m === null)) return undefined;
|
|
37
|
+
return parsed.flatMap((m) =>
|
|
38
|
+
m === null ? [] : [{ key: m[1] ?? "", value: (m[2] ?? "").trim() }],
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Splits a message into subject, body paragraphs and the trailing trailer block. */
|
|
43
|
+
function split(message: string): { subject: string; body: string[]; trailers: Trailer[] } {
|
|
44
|
+
const parts = paragraphs(withoutComments(message));
|
|
45
|
+
const subject = parts[0] ?? "";
|
|
46
|
+
const rest = parts.slice(1);
|
|
47
|
+
const trailers = trailerBlock(rest);
|
|
48
|
+
return {
|
|
49
|
+
subject,
|
|
50
|
+
body: trailers === undefined ? rest : rest.slice(0, -1),
|
|
51
|
+
trailers: trailers ?? [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseConventionalCommit(message: string): Result<ParsedCommit, ParseError> {
|
|
56
|
+
const { subject, body, trailers } = split(message);
|
|
57
|
+
const m = SUBJECT.exec(subject.split("\n")[0] ?? "");
|
|
58
|
+
if (m === null) {
|
|
59
|
+
return err(
|
|
60
|
+
parseError(
|
|
61
|
+
`"${subject.split("\n")[0] ?? ""}" is not a Conventional Commit subject: type(scope)!: subject`,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return ok({
|
|
66
|
+
type: m[1] ?? "",
|
|
67
|
+
scope: m[2],
|
|
68
|
+
breaking: m[3] === "!" || trailers.some((t) => t.key === "BREAKING CHANGE"),
|
|
69
|
+
subject: m[4] ?? "",
|
|
70
|
+
body: body.join("\n\n"),
|
|
71
|
+
trailers,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const FORBIDDEN_KEYS = ["co-authored-by", "generated-by"];
|
|
76
|
+
const AI_NAMES = /\b(ai|claude|copilot|codex|gpt|chatgpt|openai|anthropic|gemini|llm)\b/i;
|
|
77
|
+
const GENERATED_BANNER = /generated with/i;
|
|
78
|
+
|
|
79
|
+
/** Lines of the message that carry an AI attribution trailer (non-negotiable 8). */
|
|
80
|
+
export function findForbiddenTrailers(
|
|
81
|
+
message: string,
|
|
82
|
+
extraKeys: readonly string[] = [],
|
|
83
|
+
): string[] {
|
|
84
|
+
const keys = [...FORBIDDEN_KEYS, ...extraKeys.map((k) => k.toLowerCase())];
|
|
85
|
+
return withoutComments(message)
|
|
86
|
+
.split("\n")
|
|
87
|
+
.filter((line) => {
|
|
88
|
+
const trimmed = line.trim();
|
|
89
|
+
const key = /^([A-Za-z][A-Za-z-]*):/.exec(trimmed)?.[1]?.toLowerCase();
|
|
90
|
+
if (key !== undefined && keys.includes(key)) return true;
|
|
91
|
+
if (key === "signed-off-by") return AI_NAMES.test(trimmed);
|
|
92
|
+
return GENERATED_BANNER.test(trimmed) && AI_NAMES.test(trimmed);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isBullets = (paragraph: string): boolean =>
|
|
97
|
+
paragraph.split("\n").every((line) => /^\s*(?:[-*+]|\d+[.)])\s/.test(line));
|
|
98
|
+
|
|
99
|
+
const wordCount = (text: string): number => text.split(/\s+/).filter((w) => w !== "").length;
|
|
100
|
+
|
|
101
|
+
/** A body needs at least one prose paragraph (not a file list) that says more than a few words. */
|
|
102
|
+
export function hasRationaleBody(message: string): boolean {
|
|
103
|
+
const { body } = split(message);
|
|
104
|
+
return body.some((paragraph) => !isBullets(paragraph) && wordCount(paragraph) >= 5);
|
|
105
|
+
}
|
package/src/core/exec.ts
ADDED
package/src/core/gates.ts
CHANGED
|
@@ -19,8 +19,8 @@ export const GATES: Readonly<Record<string, GateInfo>> = {
|
|
|
19
19
|
default: "structural and behavioural changes go in separate commits",
|
|
20
20
|
},
|
|
21
21
|
"commit.forbidden-trailer": { tier: "soft", default: "no Co-Authored-By or AI trailers" },
|
|
22
|
-
"push.red-trunk": { tier: "
|
|
23
|
-
"push.delivery-mode": { tier: "
|
|
22
|
+
"push.red-trunk": { tier: "hard", default: "do not push unrelated work onto a red trunk" },
|
|
23
|
+
"push.delivery-mode": { tier: "hard", default: "follow the repository's delivery mode" },
|
|
24
24
|
"tdd.red-first": { tier: "soft", default: "write a failing test before changing behaviour" },
|
|
25
25
|
"lints.suppression": { tier: "soft", default: "do not suppress lints without a reasoned note" },
|
|
26
26
|
"review.unsatisfied": { tier: "soft", default: "finish the fresh-context review before release" },
|
package/src/core/git-intent.ts
CHANGED
|
@@ -24,7 +24,7 @@ const worst = (a: GitIntent, b: GitIntent): GitIntent =>
|
|
|
24
24
|
SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
|
|
25
25
|
|
|
26
26
|
/** Splits a command string on newlines that are outside quotes; backslash-newline is a continuation. */
|
|
27
|
-
function splitLines(command: string): string[] {
|
|
27
|
+
export function splitLines(command: string): string[] {
|
|
28
28
|
const lines: string[] = [];
|
|
29
29
|
let current = "";
|
|
30
30
|
let quote: "'" | '"' | undefined;
|
package/src/core/models.ts
CHANGED
|
@@ -203,3 +203,19 @@ export function renderMatrixToml(matrix: ModelMatrix): string {
|
|
|
203
203
|
);
|
|
204
204
|
return `[models]\n${lines.join("\n")}\n`;
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
/** `text` with its `[models]` table replaced by `matrix` (appended when absent); other tables untouched. */
|
|
208
|
+
export function upsertModelsTable(text: string, matrix: ModelMatrix): string {
|
|
209
|
+
const table = renderMatrixToml(matrix);
|
|
210
|
+
const lines = text.split("\n");
|
|
211
|
+
const start = lines.findIndex((l) => l.trim() === "[models]");
|
|
212
|
+
if (start === -1) {
|
|
213
|
+
const body = text.trimEnd();
|
|
214
|
+
return body === "" ? table : `${body}\n\n${table}`;
|
|
215
|
+
}
|
|
216
|
+
const nextHeader = lines.findIndex((l, i) => i > start && l.startsWith("["));
|
|
217
|
+
const head = lines.slice(0, start).join("\n");
|
|
218
|
+
const tail = nextHeader === -1 ? "" : lines.slice(nextHeader).join("\n");
|
|
219
|
+
const before = head === "" ? "" : `${head}\n`;
|
|
220
|
+
return tail === "" ? `${before}${table}` : `${before}${table}\n${tail}`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { gitInvocations } from "./commit-command.ts";
|
|
2
|
+
|
|
3
|
+
export type PushTarget = {
|
|
4
|
+
readonly remote: string | undefined;
|
|
5
|
+
/** Destination branch names named by refspecs; empty means "the current branch's default". */
|
|
6
|
+
readonly branches: readonly string[];
|
|
7
|
+
readonly allBranches: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const VALUE_OPTIONS = new Set(["--repo", "-o", "--push-option", "--receive-pack", "--exec"]);
|
|
11
|
+
|
|
12
|
+
const destination = (refspec: string): string => {
|
|
13
|
+
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
14
|
+
return dst.replace(/^\+/, "").replace(/^refs\/heads\//, "");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The remote and branches of every `git push` in a command (empty list when there is no push). */
|
|
18
|
+
export function pushTargets(command: string): PushTarget[] {
|
|
19
|
+
return gitInvocations(command).flatMap((g): PushTarget[] => {
|
|
20
|
+
if (g.sub !== "push") return [];
|
|
21
|
+
const positional: string[] = [];
|
|
22
|
+
let allBranches = false;
|
|
23
|
+
for (let i = 0; i < g.args.length; i++) {
|
|
24
|
+
const a = g.args[i] ?? "";
|
|
25
|
+
if (VALUE_OPTIONS.has(a)) i++;
|
|
26
|
+
else if (a === "--all" || a === "--mirror") allBranches = true;
|
|
27
|
+
else if (!a.startsWith("-")) positional.push(a);
|
|
28
|
+
}
|
|
29
|
+
const [remote, ...refspecs] = positional;
|
|
30
|
+
const branches = refspecs.flatMap((r) => {
|
|
31
|
+
const b = destination(r);
|
|
32
|
+
return b === "HEAD" || b === "" ? [] : [b];
|
|
33
|
+
});
|
|
34
|
+
return [{ remote, branches, allBranches }];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
isToolCallEventType,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { extractCommit } from "../core/commit-command.ts";
|
|
9
|
+
import {
|
|
10
|
+
findForbiddenTrailers,
|
|
11
|
+
hasRationaleBody,
|
|
12
|
+
parseConventionalCommit,
|
|
13
|
+
} from "../core/commit-message.ts";
|
|
14
|
+
import type { Exec } from "../core/exec.ts";
|
|
15
|
+
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
16
|
+
import type { Jev } from "../jev/client.ts";
|
|
17
|
+
import { judgeCommit, MIX_THRESHOLD, RATIONALE_FLOOR } from "../jev/questions/commit.ts";
|
|
18
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
19
|
+
import { departureUse } from "./departure-use.ts";
|
|
20
|
+
|
|
21
|
+
export type CommitGuardDeps = {
|
|
22
|
+
pi: ExtensionAPI;
|
|
23
|
+
state: SessionState;
|
|
24
|
+
jev: (ctx: ExtensionContext) => Jev;
|
|
25
|
+
exec: Exec;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Need = { gate: GateId; why: string };
|
|
29
|
+
|
|
30
|
+
const gateId = (id: string): GateId => {
|
|
31
|
+
const parsed = parseGateId(id);
|
|
32
|
+
if (isParseError(parsed)) throw new Error(parsed.message);
|
|
33
|
+
return parsed;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const RATIONALE = gateId("commit.rationale");
|
|
37
|
+
const MIXED = gateId("commit.mixed-change");
|
|
38
|
+
|
|
39
|
+
const readMessageFile = (cwd: string, path: string): string | undefined => {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(join(cwd, path), "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Deterministic checks: Conventional subject and a prose rationale body. */
|
|
48
|
+
function messageNeeds(message: string): Need[] {
|
|
49
|
+
const parsed = parseConventionalCommit(message);
|
|
50
|
+
if (!parsed.ok) return [{ gate: RATIONALE, why: parsed.error.message }];
|
|
51
|
+
return hasRationaleBody(message)
|
|
52
|
+
? []
|
|
53
|
+
: [{ gate: RATIONALE, why: "the message has no body explaining why the change was made" }];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What is about to be committed, as far as the working tree shows (staged and unstaged tracked changes). */
|
|
57
|
+
async function pendingDiff(
|
|
58
|
+
exec: Exec,
|
|
59
|
+
cwd: string,
|
|
60
|
+
): Promise<{ stat: string; diff: string } | undefined> {
|
|
61
|
+
const [stat, diff] = await Promise.all([
|
|
62
|
+
exec("git", ["diff", "HEAD", "--stat"], { cwd, timeout: 10_000 }),
|
|
63
|
+
exec("git", ["diff", "HEAD"], { cwd, timeout: 10_000 }),
|
|
64
|
+
]);
|
|
65
|
+
if (stat.code !== 0 || diff.code !== 0 || diff.stdout.trim() === "") return undefined;
|
|
66
|
+
return { stat: stat.stdout, diff: diff.stdout };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function jevNeeds(
|
|
70
|
+
deps: CommitGuardDeps,
|
|
71
|
+
ctx: ExtensionContext,
|
|
72
|
+
message: string,
|
|
73
|
+
bodyPresent: boolean,
|
|
74
|
+
): Promise<Need[]> {
|
|
75
|
+
const pending = await pendingDiff(deps.exec, ctx.cwd);
|
|
76
|
+
if (pending === undefined) return [];
|
|
77
|
+
const judged = await judgeCommit(deps.jev(ctx), {
|
|
78
|
+
message,
|
|
79
|
+
diffStat: pending.stat,
|
|
80
|
+
diff: pending.diff,
|
|
81
|
+
});
|
|
82
|
+
if (!judged.ok) return [];
|
|
83
|
+
const needs: Need[] = [];
|
|
84
|
+
if (bodyPresent && judged.value.rationale < RATIONALE_FLOOR) {
|
|
85
|
+
needs.push({ gate: RATIONALE, why: "Jev reads the body as restating what changed, not why" });
|
|
86
|
+
}
|
|
87
|
+
if (judged.value.mixesStructuralAndBehavioural >= MIX_THRESHOLD) {
|
|
88
|
+
needs.push({
|
|
89
|
+
gate: MIXED,
|
|
90
|
+
why: "Jev reads this diff as mixing a structural change with a behavioural one",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return needs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const blockReason = (need: Need): string =>
|
|
97
|
+
`${need.gate}: ${need.why}. Commit messages carry their rationale and structural and behavioural ` +
|
|
98
|
+
"changes go in separate commits. Fix the commit (split it, or write the why in the body), or if " +
|
|
99
|
+
`departing is deliberate call devsys_record_departure with gate "${need.gate}", what you are doing ` +
|
|
100
|
+
"instead, why, and the cost if wrong; then retry.";
|
|
101
|
+
|
|
102
|
+
/** Soft gates on `git commit`: rationale, Conventional shape, structural/behavioural separation; AI trailers are refused. */
|
|
103
|
+
export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
104
|
+
deps.pi.on("tool_call", async (event, ctx) => {
|
|
105
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
106
|
+
const extracted = extractCommit(event.input.command);
|
|
107
|
+
const message =
|
|
108
|
+
extracted.kind === "message"
|
|
109
|
+
? extracted.message
|
|
110
|
+
: extracted.kind === "file"
|
|
111
|
+
? readMessageFile(ctx.cwd, extracted.path)
|
|
112
|
+
: undefined;
|
|
113
|
+
if (message === undefined) return undefined;
|
|
114
|
+
|
|
115
|
+
const forbidden = findForbiddenTrailers(message);
|
|
116
|
+
if (forbidden.length > 0) {
|
|
117
|
+
return {
|
|
118
|
+
block: true,
|
|
119
|
+
reason:
|
|
120
|
+
`commit.forbidden-trailer: this message carries an AI attribution (${forbidden.join("; ")}). ` +
|
|
121
|
+
"Commits in this repository have no Co-Authored-By or generated-by trailers and this is not " +
|
|
122
|
+
"something to depart from. Remove the trailer and commit again.",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const needs = messageNeeds(message);
|
|
127
|
+
const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
|
|
128
|
+
const viaJev = await jevNeeds(deps, ctx, message, bodyPresent);
|
|
129
|
+
const all = [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
|
|
130
|
+
const uses = all.map((need) => ({ need, use: departureUse(deps.state, need.gate) }));
|
|
131
|
+
const uncovered = uses.find(({ use }) => !use.hasOpen());
|
|
132
|
+
if (uncovered !== undefined) return { block: true, reason: blockReason(uncovered.need) };
|
|
133
|
+
for (const { use } of uses) use.consume();
|
|
134
|
+
return undefined;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GateId } from "../core/types.ts";
|
|
2
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
3
|
+
|
|
4
|
+
/** Open recorded departures for one soft gate: whether one applies now, and spending a once-scoped one. */
|
|
5
|
+
export type DepartureUse = { hasOpen(): boolean; consume(): void };
|
|
6
|
+
|
|
7
|
+
export function departureUse(state: SessionState, gate: GateId): DepartureUse {
|
|
8
|
+
const applicable = () => {
|
|
9
|
+
const { openDepartures, activeSlice } = state.get();
|
|
10
|
+
return openDepartures.filter(
|
|
11
|
+
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
hasOpen: () => applicable().length > 0,
|
|
16
|
+
consume() {
|
|
17
|
+
const open = applicable();
|
|
18
|
+
// A broader departure covers the call without being spent; only a lone once-scoped one is used up.
|
|
19
|
+
if (open.some((d) => d.scope.kind !== "once")) return;
|
|
20
|
+
const used = open[0];
|
|
21
|
+
if (used === undefined) return;
|
|
22
|
+
state.update((s) => ({
|
|
23
|
+
...s,
|
|
24
|
+
openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
|
|
25
|
+
}));
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isBashToolResult,
|
|
5
|
+
isToolCallEventType,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { parseConventionalCommit } from "../core/commit-message.ts";
|
|
8
|
+
import type { Exec } from "../core/exec.ts";
|
|
9
|
+
import { type PushTarget, pushTargets } from "../core/push-command.ts";
|
|
10
|
+
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
11
|
+
import type { Jev } from "../jev/client.ts";
|
|
12
|
+
import { FIX_RELATED_THRESHOLD, judgeFixRelated } from "../jev/questions/fix-related.ts";
|
|
13
|
+
import { getFailureLog, getTrunkStatus } from "../state/ci.ts";
|
|
14
|
+
import { type DevsysConfig, loadConfig } from "../state/config.ts";
|
|
15
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
16
|
+
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
17
|
+
|
|
18
|
+
export type PushGuardDeps = {
|
|
19
|
+
pi: ExtensionAPI;
|
|
20
|
+
state: SessionState;
|
|
21
|
+
approvals: ApprovalStore;
|
|
22
|
+
jev: (ctx: ExtensionContext) => Jev;
|
|
23
|
+
exec: Exec;
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Verdict = { readonly block: true; readonly reason: string } | undefined;
|
|
28
|
+
|
|
29
|
+
const gate = (id: string): GateId => {
|
|
30
|
+
const parsed = parseGateId(id);
|
|
31
|
+
if (isParseError(parsed)) throw new Error(parsed.message);
|
|
32
|
+
return parsed;
|
|
33
|
+
};
|
|
34
|
+
const RED_TRUNK = gate("push.red-trunk");
|
|
35
|
+
const DELIVERY_MODE = gate("push.delivery-mode");
|
|
36
|
+
|
|
37
|
+
async function currentBranch(exec: Exec, cwd: string): Promise<string | undefined> {
|
|
38
|
+
const r = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeout: 5000 });
|
|
39
|
+
const name = r.code === 0 ? r.stdout.trim() : "";
|
|
40
|
+
return name === "" || name === "HEAD" ? undefined : name;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function pushesTrunk(
|
|
44
|
+
exec: Exec,
|
|
45
|
+
cwd: string,
|
|
46
|
+
targets: readonly PushTarget[],
|
|
47
|
+
trunk: string,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
for (const t of targets) {
|
|
50
|
+
if (t.allBranches || t.branches.includes(trunk)) return true;
|
|
51
|
+
if (t.branches.length === 0 && (await currentBranch(exec, cwd)) === trunk) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether the unpushed work is a `fix` commit whose diff Jev reads as only repairing the red build. */
|
|
57
|
+
async function repairsRedTrunk(
|
|
58
|
+
deps: PushGuardDeps,
|
|
59
|
+
ctx: ExtensionContext,
|
|
60
|
+
config: DevsysConfig,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
const message = await deps.exec("git", ["log", "-1", "--format=%B"], {
|
|
63
|
+
cwd: ctx.cwd,
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
});
|
|
66
|
+
const parsed = parseConventionalCommit(message.stdout);
|
|
67
|
+
if (message.code !== 0 || !parsed.ok || parsed.value.type !== "fix") return false;
|
|
68
|
+
const range = `${config.delivery.remote}/${config.delivery.trunk}..HEAD`;
|
|
69
|
+
const diff = await deps.exec("git", ["diff", range], { cwd: ctx.cwd, timeout: 10_000 });
|
|
70
|
+
if (diff.code !== 0 || diff.stdout.trim() === "") return false;
|
|
71
|
+
const judged = await judgeFixRelated(deps.jev(ctx), {
|
|
72
|
+
diff: diff.stdout,
|
|
73
|
+
failingLog: await getFailureLog(deps.exec, ctx.cwd),
|
|
74
|
+
message: message.stdout,
|
|
75
|
+
});
|
|
76
|
+
return judged.ok && judged.value >= FIX_RELATED_THRESHOLD;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type HardStop = {
|
|
80
|
+
readonly ctx: ExtensionContext;
|
|
81
|
+
readonly toolCallId: string;
|
|
82
|
+
readonly command: string;
|
|
83
|
+
readonly gate: GateId;
|
|
84
|
+
readonly why: string;
|
|
85
|
+
readonly costIfWrong: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const declinedAdvice = (which: GateId): string =>
|
|
89
|
+
which === RED_TRUNK ? "fix the red build first" : "open a pull request instead";
|
|
90
|
+
|
|
91
|
+
async function hardStop(deps: PushGuardDeps, stop: HardStop): Promise<Verdict> {
|
|
92
|
+
const { gate: which, command } = stop;
|
|
93
|
+
if (deps.approvals.consume(which, command)) return undefined;
|
|
94
|
+
const outcome = await requestHardStop({
|
|
95
|
+
pi: deps.pi,
|
|
96
|
+
ctx: stop.ctx,
|
|
97
|
+
gate: which,
|
|
98
|
+
command,
|
|
99
|
+
why: stop.why,
|
|
100
|
+
toolCallId: stop.toolCallId,
|
|
101
|
+
costIfWrong: stop.costIfWrong,
|
|
102
|
+
now: deps.now,
|
|
103
|
+
});
|
|
104
|
+
if (outcome.kind === "approved") return undefined;
|
|
105
|
+
if (outcome.kind === "unavailable") {
|
|
106
|
+
return {
|
|
107
|
+
block: true,
|
|
108
|
+
reason: `hard stop ${which}: ${stop.why}. Requires user approval; run interactively`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
block: true,
|
|
113
|
+
reason: `hard stop ${which}: the user declined this push. Do not retry it; ${declinedAdvice(which)}.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Delivery-mode and red-trunk hard stops on `git push`; records when a push last succeeded. */
|
|
118
|
+
export function registerPushGuard(deps: PushGuardDeps): void {
|
|
119
|
+
deps.pi.on("tool_call", async (event, ctx) => {
|
|
120
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
121
|
+
const targets = pushTargets(event.input.command);
|
|
122
|
+
if (targets.length === 0) return undefined;
|
|
123
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
124
|
+
if (!loaded.ok) {
|
|
125
|
+
return { block: true, reason: `cannot read the delivery policy: ${loaded.error.message}` };
|
|
126
|
+
}
|
|
127
|
+
const { mode, trunk } = loaded.value.delivery;
|
|
128
|
+
if (mode === "local-only") {
|
|
129
|
+
return {
|
|
130
|
+
block: true,
|
|
131
|
+
reason: `delivery.mode is "local-only" in .development-system.toml: nothing is pushed. Change the mode with the user if this should be published.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!(await pushesTrunk(deps.exec, ctx.cwd, targets, trunk))) return undefined;
|
|
135
|
+
const command = event.input.command;
|
|
136
|
+
if (mode === "pull-request") {
|
|
137
|
+
return hardStop(deps, {
|
|
138
|
+
ctx,
|
|
139
|
+
toolCallId: event.toolCallId,
|
|
140
|
+
command,
|
|
141
|
+
gate: DELIVERY_MODE,
|
|
142
|
+
why: `delivery.mode is "pull-request" but this pushes ${trunk}`,
|
|
143
|
+
costIfWrong: "unreviewed work lands directly on the trunk",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
|
|
147
|
+
if (status.status !== "red") return undefined;
|
|
148
|
+
if (await repairsRedTrunk(deps, ctx, loaded.value)) return undefined;
|
|
149
|
+
return hardStop(deps, {
|
|
150
|
+
ctx,
|
|
151
|
+
toolCallId: event.toolCallId,
|
|
152
|
+
command,
|
|
153
|
+
gate: RED_TRUNK,
|
|
154
|
+
why: `CI on ${trunk} is red (${status.headSha?.slice(0, 7) ?? "unknown sha"}) and this push is not a recognised fix for it`,
|
|
155
|
+
costIfWrong: "unrelated work piles onto a broken build and hides the failure",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
deps.pi.on("tool_result", (event) => {
|
|
160
|
+
if (!isBashToolResult(event) || event.isError) return undefined;
|
|
161
|
+
if (pushTargets(String(event.input.command)).length === 0) return undefined;
|
|
162
|
+
deps.state.update((s) => ({
|
|
163
|
+
...s,
|
|
164
|
+
lastPushAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
165
|
+
}));
|
|
166
|
+
return undefined;
|
|
167
|
+
});
|
|
168
|
+
}
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { Jev } from "../jev/client.ts";
|
|
|
23
23
|
import { judgeTestChange, WEAKENS_THRESHOLD } from "../jev/questions/test-change.ts";
|
|
24
24
|
import type { SessionState } from "../state/session-state.ts";
|
|
25
25
|
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
26
|
+
import { departureUse } from "./departure-use.ts";
|
|
26
27
|
|
|
27
28
|
export type TestGuardDeps = {
|
|
28
29
|
pi: ExtensionAPI;
|
|
@@ -110,27 +111,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
110
111
|
})();
|
|
111
112
|
if (gate === undefined) return;
|
|
112
113
|
|
|
113
|
-
const
|
|
114
|
-
const { openDepartures, activeSlice } = deps.state.get();
|
|
115
|
-
return openDepartures.some(
|
|
116
|
-
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
117
|
-
);
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const consumeOpenDeparture = (): void => {
|
|
121
|
-
const { openDepartures, activeSlice } = deps.state.get();
|
|
122
|
-
const applicable = openDepartures.filter(
|
|
123
|
-
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
124
|
-
);
|
|
125
|
-
// A broader departure covers the call without being spent; only a lone once-scoped one is used up.
|
|
126
|
-
if (applicable.some((d) => d.scope.kind !== "once")) return;
|
|
127
|
-
const used = applicable[0];
|
|
128
|
-
if (used === undefined) return;
|
|
129
|
-
deps.state.update((s) => ({
|
|
130
|
-
...s,
|
|
131
|
-
openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
|
|
132
|
-
}));
|
|
133
|
-
};
|
|
114
|
+
const departure = departureUse(deps.state, gate);
|
|
134
115
|
|
|
135
116
|
const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
|
|
136
117
|
const deleted = change.after === undefined && change.rewritten === undefined;
|
|
@@ -225,9 +206,9 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
225
206
|
);
|
|
226
207
|
const first = needing[0];
|
|
227
208
|
if (first === undefined) return undefined;
|
|
228
|
-
if (
|
|
209
|
+
if (departure.hasOpen()) {
|
|
229
210
|
// One departure covers every path in this single tool call.
|
|
230
|
-
|
|
211
|
+
departure.consume();
|
|
231
212
|
return undefined;
|
|
232
213
|
}
|
|
233
214
|
return {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { redactSecrets } from "../../core/redact.ts";
|
|
3
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
|
+
import type { Jev, JevError } from "../client.ts";
|
|
5
|
+
|
|
6
|
+
/** At or above this, the commit is treated as mixing structural and behavioural change. */
|
|
7
|
+
export const MIX_THRESHOLD = 0.7;
|
|
8
|
+
/** Below this a message that has a body is still treated as not carrying its rationale. */
|
|
9
|
+
export const RATIONALE_FLOOR = 0.3;
|
|
10
|
+
|
|
11
|
+
export type CommitInput = { message: string; diffStat: string; diff: string };
|
|
12
|
+
export type CommitJudgement = { rationale: number; mixesStructuralAndBehavioural: number };
|
|
13
|
+
|
|
14
|
+
const DIFF_CLIP = 8000;
|
|
15
|
+
|
|
16
|
+
export const RATIONALE_QUESTION: ClassifierBoolQuestion = {
|
|
17
|
+
type: "bool",
|
|
18
|
+
instructions:
|
|
19
|
+
"Read the commit `message` in light of the change in `diffStat` and `diff`. Does the message explain WHY the change was made (the problem, constraint, decision or trade-off), rather than only restating WHAT changed?",
|
|
20
|
+
criteria: {
|
|
21
|
+
true: "The body gives a reason: the problem solved, the constraint, the alternative rejected or the trade-off accepted",
|
|
22
|
+
false:
|
|
23
|
+
"The message only names or lists what changed (file names, 'update X', 'fix bug', bullet list of edits) with no reason",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MIX_QUESTION: ClassifierBoolQuestion = {
|
|
28
|
+
type: "bool",
|
|
29
|
+
instructions:
|
|
30
|
+
"Does the change in `diffStat` and `diff` combine a structural change (rename, move, extract, reformat, dependency or config tidying that does not alter behaviour) with a behavioural change (new or altered logic, fixed bug, new feature) in the same commit?",
|
|
31
|
+
criteria: {
|
|
32
|
+
true: "Both a behaviour-preserving restructuring AND a behaviour change are present in the diff",
|
|
33
|
+
false:
|
|
34
|
+
"The diff is only structural, or only behavioural (including a behavioural change with its own tests and a minimal necessary signature tweak)",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const clip = (text: string, max: number): string => redactSecrets(text).slice(0, max);
|
|
39
|
+
|
|
40
|
+
export async function judgeCommit(
|
|
41
|
+
jev: Jev,
|
|
42
|
+
input: CommitInput,
|
|
43
|
+
): Promise<Result<CommitJudgement, JevError>> {
|
|
44
|
+
const asked = await jev.ask(
|
|
45
|
+
{
|
|
46
|
+
message: clip(input.message, 4000),
|
|
47
|
+
diffStat: clip(input.diffStat, 2000),
|
|
48
|
+
diff: clip(input.diff, DIFF_CLIP),
|
|
49
|
+
},
|
|
50
|
+
{ rationale: RATIONALE_QUESTION, mix: MIX_QUESTION },
|
|
51
|
+
);
|
|
52
|
+
if (!asked.ok) return asked;
|
|
53
|
+
const rationale = asked.value.rationale;
|
|
54
|
+
const mix = asked.value.mix;
|
|
55
|
+
if (rationale?.type !== "bool" || mix?.type !== "bool") {
|
|
56
|
+
return err({ kind: "provider", message: "missing commit answers" });
|
|
57
|
+
}
|
|
58
|
+
return ok({
|
|
59
|
+
rationale: rationale.probability,
|
|
60
|
+
mixesStructuralAndBehavioural: mix.probability,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { redactSecrets } from "../../core/redact.ts";
|
|
3
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
|
+
import type { Jev, JevError } from "../client.ts";
|
|
5
|
+
|
|
6
|
+
/** At or above this, the unpushed change is treated as a fix for the red build. */
|
|
7
|
+
export const FIX_RELATED_THRESHOLD = 0.6;
|
|
8
|
+
|
|
9
|
+
export const FIX_RELATED_QUESTION: ClassifierBoolQuestion = {
|
|
10
|
+
type: "bool",
|
|
11
|
+
instructions:
|
|
12
|
+
"Is every change in `diff` plausibly part of fixing the failure shown in `failingLog`? Answer false if the diff contains unrelated changes such as new features, refactors or behaviour changes that the failure does not call for.",
|
|
13
|
+
criteria: {
|
|
14
|
+
true: "The diff only touches what is needed to repair the failing build or test",
|
|
15
|
+
false:
|
|
16
|
+
"The diff includes work the failure does not call for (features, refactors, unrelated fixes)",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function judgeFixRelated(
|
|
21
|
+
jev: Jev,
|
|
22
|
+
input: { diff: string; failingLog: string; message: string },
|
|
23
|
+
): Promise<Result<number, JevError>> {
|
|
24
|
+
const asked = await jev.ask(
|
|
25
|
+
{
|
|
26
|
+
failingLog: redactSecrets(input.failingLog).slice(-4000),
|
|
27
|
+
message: redactSecrets(input.message).slice(0, 2000),
|
|
28
|
+
diff: redactSecrets(input.diff).slice(0, 8000),
|
|
29
|
+
},
|
|
30
|
+
{ related: FIX_RELATED_QUESTION },
|
|
31
|
+
);
|
|
32
|
+
if (!asked.ok) return asked;
|
|
33
|
+
const answer = asked.value.related;
|
|
34
|
+
return answer?.type === "bool"
|
|
35
|
+
? ok(answer.probability)
|
|
36
|
+
: err({ kind: "provider", message: "missing fix-related answer" });
|
|
37
|
+
}
|
package/src/state/ci.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Exec } from "../core/exec.ts";
|
|
2
|
+
|
|
3
|
+
export type TrunkStatus = {
|
|
4
|
+
readonly status: "green" | "red" | "pending" | "unknown";
|
|
5
|
+
readonly headSha: string | undefined;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const UNKNOWN: TrunkStatus = { status: "unknown", headSha: undefined };
|
|
9
|
+
|
|
10
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
11
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
|
|
13
|
+
const FAILED = new Set(["failure", "timed_out", "startup_failure", "action_required"]);
|
|
14
|
+
|
|
15
|
+
/** Parses `gh run list --json status,conclusion,headSha` output for the newest run. */
|
|
16
|
+
export function parseRunList(stdout: string): TrunkStatus {
|
|
17
|
+
let parsed: unknown;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(stdout);
|
|
20
|
+
} catch {
|
|
21
|
+
return UNKNOWN;
|
|
22
|
+
}
|
|
23
|
+
const run = Array.isArray(parsed) ? parsed[0] : undefined;
|
|
24
|
+
if (!isRecord(run)) return UNKNOWN;
|
|
25
|
+
const headSha = typeof run.headSha === "string" ? run.headSha : undefined;
|
|
26
|
+
if (run.status !== "completed") return { status: "pending", headSha };
|
|
27
|
+
if (run.conclusion === "success") return { status: "green", headSha };
|
|
28
|
+
if (typeof run.conclusion === "string" && FAILED.has(run.conclusion)) {
|
|
29
|
+
return { status: "red", headSha };
|
|
30
|
+
}
|
|
31
|
+
return { status: "unknown", headSha };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** CI state of the newest run on the trunk branch; `unknown` whenever `gh` cannot say. */
|
|
35
|
+
export async function getTrunkStatus(
|
|
36
|
+
exec: Exec,
|
|
37
|
+
where: { branch: string; cwd?: string },
|
|
38
|
+
): Promise<TrunkStatus> {
|
|
39
|
+
try {
|
|
40
|
+
const r = await exec(
|
|
41
|
+
"gh",
|
|
42
|
+
[
|
|
43
|
+
"run",
|
|
44
|
+
"list",
|
|
45
|
+
"--branch",
|
|
46
|
+
where.branch,
|
|
47
|
+
"--limit",
|
|
48
|
+
"1",
|
|
49
|
+
"--json",
|
|
50
|
+
"status,conclusion,headSha",
|
|
51
|
+
],
|
|
52
|
+
{ ...(where.cwd !== undefined ? { cwd: where.cwd } : {}), timeout: 15_000 },
|
|
53
|
+
);
|
|
54
|
+
return r.code === 0 ? parseRunList(r.stdout) : UNKNOWN;
|
|
55
|
+
} catch {
|
|
56
|
+
return UNKNOWN;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The failing log of the newest failed run, clipped; empty when unavailable. */
|
|
61
|
+
export async function getFailureLog(exec: Exec, cwd: string | undefined): Promise<string> {
|
|
62
|
+
try {
|
|
63
|
+
const r = await exec("gh", ["run", "view", "--log-failed"], {
|
|
64
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
65
|
+
timeout: 30_000,
|
|
66
|
+
});
|
|
67
|
+
return r.code === 0 ? r.stdout.slice(-6000) : "";
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionCommandContext,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
ToolDefinition,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { type Static, Type } from "typebox";
|
|
10
|
+
import {
|
|
11
|
+
type Available,
|
|
12
|
+
isSlot,
|
|
13
|
+
type ModelMatrix,
|
|
14
|
+
type Resolution,
|
|
15
|
+
resolveSlot,
|
|
16
|
+
SLOTS,
|
|
17
|
+
type Slot,
|
|
18
|
+
upsertModelsTable,
|
|
19
|
+
} from "../core/models.ts";
|
|
20
|
+
import { CONFIG_FILE, loadConfig } from "./config.ts";
|
|
21
|
+
|
|
22
|
+
export const CONFIG_ENTRY_TYPE = "devsys-config";
|
|
23
|
+
|
|
24
|
+
export type CatalogModel = Available & {
|
|
25
|
+
readonly cost?: { readonly input: number; readonly output: number };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The slice of pi's `ctx.modelRegistry` this command needs; tests pass a fake. */
|
|
29
|
+
export interface ModelCatalog {
|
|
30
|
+
getAvailable(): readonly CatalogModel[];
|
|
31
|
+
getModelsOfType(type: "classifier"): readonly CatalogModel[];
|
|
32
|
+
hasConfiguredAuth(model: CatalogModel): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type SlotRow = {
|
|
36
|
+
readonly slot: Slot;
|
|
37
|
+
readonly resolution: Resolution | undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Chat models with credentials plus classifier models with credentials: everything a slot may name. */
|
|
41
|
+
export function availableModels(catalog: ModelCatalog): CatalogModel[] {
|
|
42
|
+
const classifiers = catalog
|
|
43
|
+
.getModelsOfType("classifier")
|
|
44
|
+
.filter((m) => catalog.hasConfiguredAuth(m));
|
|
45
|
+
return [...catalog.getAvailable(), ...classifiers];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveRows(matrix: ModelMatrix, available: readonly Available[]): SlotRow[] {
|
|
49
|
+
return SLOTS.map((slot) => {
|
|
50
|
+
const r = resolveSlot(matrix, slot, available);
|
|
51
|
+
return { slot, resolution: r.ok ? r.value : undefined };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const perMillion = (n: number): string => `$${Number(n.toFixed(2))}`;
|
|
56
|
+
|
|
57
|
+
function priceOf(model: string, available: readonly CatalogModel[]): string {
|
|
58
|
+
const hit = available.find((m) => `${m.provider}/${m.id}` === model);
|
|
59
|
+
return hit?.cost === undefined
|
|
60
|
+
? ""
|
|
61
|
+
: ` · ${perMillion(hit.cost.input)}/${perMillion(hit.cost.output)} per M`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One line per slot: `slot → provider/id (via candidate) · $in/$out per M`, or `UNRESOLVED`. */
|
|
65
|
+
export function renderModelsTable(
|
|
66
|
+
rows: readonly SlotRow[],
|
|
67
|
+
available: readonly CatalogModel[],
|
|
68
|
+
): string {
|
|
69
|
+
const width = Math.max(...SLOTS.map((s) => s.length));
|
|
70
|
+
return rows
|
|
71
|
+
.map(({ slot, resolution }) =>
|
|
72
|
+
resolution === undefined
|
|
73
|
+
? `${slot.padEnd(width)} → UNRESOLVED (no candidate matches a model with credentials)`
|
|
74
|
+
: `${slot.padEnd(width)} → ${resolution.model} (via ${resolution.via})${priceOf(resolution.model, available)}`,
|
|
75
|
+
)
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DEFAULT_FILE = 'version = 1\n\n[delivery]\nmode = "trunk"\n';
|
|
80
|
+
|
|
81
|
+
export type ModelsOutcome = {
|
|
82
|
+
readonly ok: boolean;
|
|
83
|
+
readonly written: boolean;
|
|
84
|
+
readonly table: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type Choice = "accept" | "accept-rest" | "pick" | "pin";
|
|
88
|
+
|
|
89
|
+
const CHOICE_LABELS: Record<Choice, string> = {
|
|
90
|
+
accept: "accept",
|
|
91
|
+
"accept-rest": "accept this and all remaining slots",
|
|
92
|
+
pick: "pick another available model",
|
|
93
|
+
pin: "pin the current exact id (no auto-roll)",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
async function chooseSlot(
|
|
97
|
+
ctx: ExtensionCommandContext,
|
|
98
|
+
slot: Slot,
|
|
99
|
+
row: SlotRow,
|
|
100
|
+
available: readonly CatalogModel[],
|
|
101
|
+
matrix: ModelMatrix,
|
|
102
|
+
): Promise<{ matrix: ModelMatrix; acceptRest: boolean }> {
|
|
103
|
+
const current = row.resolution?.model;
|
|
104
|
+
const labels = current === undefined ? [CHOICE_LABELS.pick] : Object.values(CHOICE_LABELS);
|
|
105
|
+
const picked = await ctx.ui.select(`${slot}: ${current ?? "unresolved"}`, labels);
|
|
106
|
+
const choice = (Object.keys(CHOICE_LABELS) as Choice[]).find((k) => CHOICE_LABELS[k] === picked);
|
|
107
|
+
if (choice === "accept-rest") return { matrix, acceptRest: true };
|
|
108
|
+
if (choice === "pin" && current !== undefined) {
|
|
109
|
+
return { matrix: { ...matrix, [slot]: [current, ...matrix[slot]] }, acceptRest: false };
|
|
110
|
+
}
|
|
111
|
+
if (choice === "pick") {
|
|
112
|
+
const refs = available.map((m) => `${m.provider}/${m.id}`);
|
|
113
|
+
const model = await ctx.ui.select(`${slot}: choose a model`, refs);
|
|
114
|
+
if (model !== undefined) {
|
|
115
|
+
return { matrix: { ...matrix, [slot]: [model, ...matrix[slot]] }, acceptRest: false };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { matrix, acceptRest: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readConfigText(repoRoot: string): Promise<string> {
|
|
122
|
+
try {
|
|
123
|
+
return await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
124
|
+
} catch {
|
|
125
|
+
return DEFAULT_FILE;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Core of `/devsys-models`: resolve, optionally adjust interactively, and write the `[models]` table. */
|
|
130
|
+
export async function runModelsCommand(
|
|
131
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
132
|
+
ctx: ExtensionCommandContext,
|
|
133
|
+
args: string,
|
|
134
|
+
catalog: ModelCatalog,
|
|
135
|
+
): Promise<ModelsOutcome> {
|
|
136
|
+
const config = await loadConfig(ctx.cwd);
|
|
137
|
+
if (!config.ok) {
|
|
138
|
+
ctx.ui.notify(`${CONFIG_FILE}: ${config.error.message}`, "error");
|
|
139
|
+
return { ok: false, written: false, table: "" };
|
|
140
|
+
}
|
|
141
|
+
const available = availableModels(catalog);
|
|
142
|
+
let matrix = config.value.models;
|
|
143
|
+
let rows = resolveRows(matrix, available);
|
|
144
|
+
const check = args.split(/\s+/).includes("--check");
|
|
145
|
+
if (!check && ctx.hasUI) {
|
|
146
|
+
let acceptRest = false;
|
|
147
|
+
for (const slot of SLOTS) {
|
|
148
|
+
if (acceptRest) break;
|
|
149
|
+
const row = rows.find((r) => r.slot === slot);
|
|
150
|
+
if (row === undefined) continue;
|
|
151
|
+
const next = await chooseSlot(ctx, slot, row, available, matrix);
|
|
152
|
+
matrix = next.matrix;
|
|
153
|
+
acceptRest = next.acceptRest;
|
|
154
|
+
rows = resolveRows(matrix, available);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const table = renderModelsTable(rows, available);
|
|
158
|
+
const unresolved = rows.flatMap((r) => (r.resolution === undefined ? [r.slot] : []));
|
|
159
|
+
if (check || unresolved.length > 0) {
|
|
160
|
+
const ok = unresolved.length === 0;
|
|
161
|
+
ctx.ui.notify(
|
|
162
|
+
ok ? table : `${table}\n\nUnresolvable slots: ${unresolved.join(", ")}. Nothing written.`,
|
|
163
|
+
ok ? "info" : "error",
|
|
164
|
+
);
|
|
165
|
+
return { ok, written: false, table };
|
|
166
|
+
}
|
|
167
|
+
const text = upsertModelsTable(await readConfigText(ctx.cwd), matrix);
|
|
168
|
+
await writeFile(join(ctx.cwd, CONFIG_FILE), text);
|
|
169
|
+
pi.appendEntry(CONFIG_ENTRY_TYPE, { models: matrix });
|
|
170
|
+
ctx.ui.notify(`${table}\n\nWrote [models] to ${CONFIG_FILE}.`, "info");
|
|
171
|
+
return { ok: true, written: true, table };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function registerModelsCommand(pi: ExtensionAPI): void {
|
|
175
|
+
pi.registerCommand("devsys-models", {
|
|
176
|
+
description:
|
|
177
|
+
"Resolve the per-project model matrix against the models you can use and write it to .development-system.toml (--check: report only)",
|
|
178
|
+
handler: async (args, ctx) => {
|
|
179
|
+
await runModelsCommand(pi, ctx, args, ctx.modelRegistry);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const Parameters = Type.Object({
|
|
185
|
+
slot: Type.Optional(
|
|
186
|
+
Type.String({ description: `One of: ${SLOTS.join(", ")}. Omit for every slot.` }),
|
|
187
|
+
),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const text = (t: string, isError = false) => ({
|
|
191
|
+
content: [{ type: "text" as const, text: t }],
|
|
192
|
+
details: undefined,
|
|
193
|
+
isError,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** `devsys_models`: the resolved model for a slot (or all slots), from the project's matrix. */
|
|
197
|
+
export function createModelsTool(): ToolDefinition<typeof Parameters> {
|
|
198
|
+
return {
|
|
199
|
+
name: "devsys_models",
|
|
200
|
+
label: "Resolve models",
|
|
201
|
+
description:
|
|
202
|
+
"Return the model this project's matrix resolves each slot to (frontier, strong, fast, planning, advisor, implementer, reviewer, lens, researcher, jev) given the credentials on this machine.",
|
|
203
|
+
promptSnippet: "Look up which model a development-system slot resolves to",
|
|
204
|
+
parameters: Parameters,
|
|
205
|
+
exposure: "direct",
|
|
206
|
+
async execute(
|
|
207
|
+
_id,
|
|
208
|
+
params: Static<typeof Parameters>,
|
|
209
|
+
_signal,
|
|
210
|
+
_onUpdate,
|
|
211
|
+
ctx: ExtensionContext,
|
|
212
|
+
) {
|
|
213
|
+
if (params.slot !== undefined && !isSlot(params.slot)) {
|
|
214
|
+
return text(`unknown slot "${params.slot}". Slots: ${SLOTS.join(", ")}`, true);
|
|
215
|
+
}
|
|
216
|
+
const config = await loadConfig(ctx.cwd);
|
|
217
|
+
if (!config.ok) return text(`${CONFIG_FILE}: ${config.error.message}`, true);
|
|
218
|
+
const available = availableModels(ctx.modelRegistry);
|
|
219
|
+
const rows = resolveRows(config.value.models, available).filter(
|
|
220
|
+
(r) => params.slot === undefined || r.slot === params.slot,
|
|
221
|
+
);
|
|
222
|
+
return text(
|
|
223
|
+
rows
|
|
224
|
+
.map((r) =>
|
|
225
|
+
r.resolution === undefined
|
|
226
|
+
? `${r.slot}: unresolved`
|
|
227
|
+
: `${r.slot}: ${r.resolution.model} (via ${r.resolution.via})`,
|
|
228
|
+
)
|
|
229
|
+
.join("\n"),
|
|
230
|
+
rows.some((r) => r.resolution === undefined),
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|