@jwilger/pi-development-system 0.7.0 → 0.8.1
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 +6 -0
- package/package.json +1 -1
- package/skills/delivery-discipline/SKILL.md +79 -0
- package/skills/delivery-discipline/references/lefthook.yml +18 -0
- package/src/context/status.ts +11 -1
- package/src/core/commit-command.ts +60 -76
- package/src/core/commit-message.ts +1 -1
- package/src/core/gates.ts +1 -1
- package/src/core/git-intent.ts +32 -12
- package/src/core/git-invocations.ts +95 -0
- package/src/core/models.ts +5 -2
- package/src/core/push-command.ts +43 -18
- package/src/core/redact.ts +11 -2
- package/src/core/types.ts +7 -0
- package/src/gates/commit-guard.ts +27 -18
- package/src/gates/push-guard.ts +10 -5
- package/src/gates/request-approval-tool.ts +6 -0
- package/src/state/ci-command.ts +77 -0
- package/src/state/ci-watch.ts +34 -0
- package/src/state/ci.ts +16 -9
- package/src/state/config.ts +3 -1
- package/src/state/session-state.ts +17 -1
|
@@ -11,6 +11,7 @@ import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts
|
|
|
11
11
|
import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
|
|
12
12
|
import { registerTestGuard } from "../src/gates/test-guard.ts";
|
|
13
13
|
import { createJevHolder } from "../src/jev/holder.ts";
|
|
14
|
+
import { registerCiCommand } from "../src/state/ci-command.ts";
|
|
14
15
|
import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
|
|
15
16
|
import { createSessionState } from "../src/state/session-state.ts";
|
|
16
17
|
|
|
@@ -66,6 +67,11 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
66
67
|
jev: (ctx) => jevHolder.forContext(ctx),
|
|
67
68
|
exec: (command, args, options) => pi.exec(command, args, options),
|
|
68
69
|
});
|
|
70
|
+
registerCiCommand({
|
|
71
|
+
pi,
|
|
72
|
+
state,
|
|
73
|
+
exec: (command, args, options) => pi.exec(command, args, options),
|
|
74
|
+
});
|
|
69
75
|
registerPushGuard({
|
|
70
76
|
pi,
|
|
71
77
|
state,
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: delivery-discipline
|
|
3
|
+
description: How to commit, push and release in this development system - trunk-based delivery, Conventional Commit messages that carry their rationale, handling a red build, and when worktrees are warranted. Use before committing, pushing, opening a pull request, or when CI is red.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Delivery discipline
|
|
7
|
+
|
|
8
|
+
Delivery mode comes from `.development-system.toml` (`[delivery] mode`):
|
|
9
|
+
`trunk` (default), `pull-request`, or `local-only`. The repository's own policy
|
|
10
|
+
wins over any habit. The extension enforces the hard parts; this skill is how
|
|
11
|
+
to work with them.
|
|
12
|
+
|
|
13
|
+
## Commit messages
|
|
14
|
+
|
|
15
|
+
Shape: `type(scope): subject` then a body that says **why**.
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
fix(auth): reject expired refresh tokens
|
|
19
|
+
|
|
20
|
+
Expired tokens were accepted for up to a minute because the clock check used
|
|
21
|
+
the issue time. Sessions outliving their grant is a security defect, so the
|
|
22
|
+
check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Do not write a body that only restates the diff. The diff already says what;
|
|
26
|
+
the body is the only place the reason survives.
|
|
27
|
+
- Do not add `Co-Authored-By`, "Generated with" or any AI trailer. This is
|
|
28
|
+
never a departure; the guard blocks it outright.
|
|
29
|
+
- Do not mix structural changes (rename, move, reformat) with behavioural ones
|
|
30
|
+
in one commit. Reviewers cannot see the behaviour change inside the noise.
|
|
31
|
+
Tidy first, commit, then change behaviour.
|
|
32
|
+
- A body-less commit is allowed only through a recorded departure
|
|
33
|
+
(`devsys_record_departure`, gate `commit.rationale`); say why in it.
|
|
34
|
+
|
|
35
|
+
## Trunk flow
|
|
36
|
+
|
|
37
|
+
1. Small increment, tests green locally, commit with rationale.
|
|
38
|
+
2. Push to trunk, then watch CI (`/devsys-ci`). A push is not done until CI is green.
|
|
39
|
+
3. If an hour passes without a pushed commit, stop and ask whether the
|
|
40
|
+
increment is too big; split it.
|
|
41
|
+
|
|
42
|
+
Do not start unrelated work while trunk CI is red. Fix it first; a red build
|
|
43
|
+
hides every later failure. A push to a red trunk is a hard stop unless the
|
|
44
|
+
commit is `fix(ci): ...` and its diff only repairs the failing build. If `gh`
|
|
45
|
+
is missing or unauthenticated, CI reads as unknown and the push is allowed:
|
|
46
|
+
check CI by other means before relying on it.
|
|
47
|
+
|
|
48
|
+
## Pull-request mode
|
|
49
|
+
|
|
50
|
+
Never push to the trunk directly (hard stop, gate `push.delivery-mode`). Push a
|
|
51
|
+
branch, open the PR, and wait for its checks.
|
|
52
|
+
|
|
53
|
+
## Local-only
|
|
54
|
+
|
|
55
|
+
Pushes are blocked. Commit locally, tell the user what is unpublished.
|
|
56
|
+
|
|
57
|
+
## Worktrees
|
|
58
|
+
|
|
59
|
+
Use `.worktrees/<name>` only when two pieces of work genuinely run in
|
|
60
|
+
parallel. One increment at a time needs none; they add cleanup and confusion.
|
|
61
|
+
|
|
62
|
+
## Never
|
|
63
|
+
|
|
64
|
+
- `--no-verify`, `git push --force`, rewriting published history, deleting
|
|
65
|
+
remote branches: hard stops needing the user's explicit approval each time.
|
|
66
|
+
- Weakening or deleting tests to get green (gate `tests.weaken`).
|
|
67
|
+
- Claiming CI passed without having looked at it.
|
|
68
|
+
|
|
69
|
+
## Checklist
|
|
70
|
+
|
|
71
|
+
Copy and tick:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
- [ ] Commit is one kind of change (structural or behavioural)
|
|
75
|
+
- [ ] Subject is Conventional; body explains why
|
|
76
|
+
- [ ] No AI trailer
|
|
77
|
+
- [ ] Local gates green (see references/lefthook.yml for the template)
|
|
78
|
+
- [ ] Pushed per delivery mode; CI watched to green
|
|
79
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Template: local gates that mirror CI so a red build is found before the push.
|
|
3
|
+
# Adapt the commands to the repository's toolchain.
|
|
4
|
+
# Keep CI re-running every gate.
|
|
5
|
+
commit-msg:
|
|
6
|
+
commands:
|
|
7
|
+
conventional:
|
|
8
|
+
run: your-commit-message-check {1}
|
|
9
|
+
|
|
10
|
+
pre-commit:
|
|
11
|
+
parallel: true
|
|
12
|
+
commands:
|
|
13
|
+
typecheck:
|
|
14
|
+
run: npx tsc --noEmit
|
|
15
|
+
lint:
|
|
16
|
+
run: biome check .
|
|
17
|
+
test:
|
|
18
|
+
run: npm test
|
package/src/context/status.ts
CHANGED
|
@@ -5,8 +5,17 @@ export const STATUS_KEY = "devsys";
|
|
|
5
5
|
const jevLabel = (state: DevsysState, model: string | undefined): string =>
|
|
6
6
|
state.jev === "online" && model !== undefined ? `online (${model})` : state.jev;
|
|
7
7
|
|
|
8
|
+
const ciLabel = (state: DevsysState): string | undefined => {
|
|
9
|
+
const { ci } = state;
|
|
10
|
+
if (ci === undefined || ci.status === "unknown") return undefined;
|
|
11
|
+
const sha = ci.sha === undefined ? "" : ` (${ci.sha.slice(0, 7)})`;
|
|
12
|
+
return `ci ${ci.status}${sha}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
8
15
|
export const renderStatusLine = (state: DevsysState, jevModel?: string): string =>
|
|
9
|
-
`devsys: ${state.phase}
|
|
16
|
+
[`devsys: ${state.phase}`, `jev ${jevLabel(state, jevModel)}`, ciLabel(state)]
|
|
17
|
+
.filter((part) => part !== undefined)
|
|
18
|
+
.join(" · ");
|
|
10
19
|
|
|
11
20
|
export const renderStatus = (state: DevsysState, jevModel?: string): string =>
|
|
12
21
|
[
|
|
@@ -16,4 +25,5 @@ export const renderStatus = (state: DevsysState, jevModel?: string): string =>
|
|
|
16
25
|
`- active slice: ${state.activeSlice ?? "none"}`,
|
|
17
26
|
`- open departures: ${state.openDepartures.length}`,
|
|
18
27
|
`- jev: ${jevLabel(state, jevModel)}`,
|
|
28
|
+
`- ci: ${ciLabel(state)?.slice(3) ?? "unknown"}`,
|
|
19
29
|
].join("\n");
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parseShell } from "./shell-parse.ts";
|
|
1
|
+
import { resolveGit } from "./git-invocations.ts";
|
|
3
2
|
|
|
4
3
|
export type CommitExtraction =
|
|
5
4
|
| { readonly kind: "not-commit" }
|
|
6
5
|
| { readonly kind: "message"; readonly message: string }
|
|
7
6
|
| { readonly kind: "file"; readonly path: string }
|
|
8
|
-
| { readonly kind: "unknown" };
|
|
7
|
+
| { readonly kind: "unknown"; readonly trailers: readonly string[] };
|
|
9
8
|
|
|
10
9
|
const HEREDOC =
|
|
11
10
|
/<<-?[ \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
11
|
|
|
14
12
|
/** Heredoc bodies are data: take them out so their quotes cannot confuse line splitting. */
|
|
15
13
|
function stripHeredocs(command: string): { rest: string; bodies: string[] } {
|
|
@@ -21,34 +19,6 @@ function stripHeredocs(command: string): { rest: string; bodies: string[] } {
|
|
|
21
19
|
return { rest, bodies };
|
|
22
20
|
}
|
|
23
21
|
|
|
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
22
|
type Options = {
|
|
53
23
|
messages: string[];
|
|
54
24
|
trailers: string[];
|
|
@@ -56,59 +26,73 @@ type Options = {
|
|
|
56
26
|
opaque: boolean;
|
|
57
27
|
};
|
|
58
28
|
|
|
29
|
+
type Parsed = {
|
|
30
|
+
readonly kind: "message" | "file" | "trailer";
|
|
31
|
+
readonly value: string | undefined;
|
|
32
|
+
/** Index of the next argument to read. */
|
|
33
|
+
readonly next: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const LONG: ReadonlyArray<readonly [string, Parsed["kind"]]> = [
|
|
37
|
+
["--message", "message"],
|
|
38
|
+
["--file", "file"],
|
|
39
|
+
["--trailer", "trailer"],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Reads the message/file/trailer option starting at args[i], in any of its spellings. */
|
|
43
|
+
function parseArg(args: readonly string[], i: number): Parsed | undefined {
|
|
44
|
+
const a = args[i] ?? "";
|
|
45
|
+
for (const [name, kind] of LONG) {
|
|
46
|
+
if (a === name) return { kind, value: args[i + 1], next: i + 2 };
|
|
47
|
+
if (a.startsWith(`${name}=`)) return { kind, value: a.slice(name.length + 1), next: i + 1 };
|
|
48
|
+
}
|
|
49
|
+
if (a === "-F") return { kind: "file", value: args[i + 1], next: i + 2 };
|
|
50
|
+
if (/^-F./.test(a)) return { kind: "file", value: a.slice(2), next: i + 1 };
|
|
51
|
+
if (/^-[A-Za-z]*m$/.test(a)) return { kind: "message", value: args[i + 1], next: i + 2 };
|
|
52
|
+
if (/^-[A-Za-z]*m./.test(a)) {
|
|
53
|
+
return { kind: "message", value: a.slice(a.indexOf("m") + 1), next: i + 1 };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
59
58
|
function readOptions(args: readonly string[]): Options {
|
|
60
59
|
const options: Options = { messages: [], trailers: [], file: undefined, opaque: false };
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
else if (
|
|
69
|
-
else
|
|
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));
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < args.length) {
|
|
62
|
+
const parsed = parseArg(args, i);
|
|
63
|
+
i = parsed?.next ?? i + 1;
|
|
64
|
+
if (parsed === undefined) continue;
|
|
65
|
+
if (parsed.kind === "file") options.file = parsed.value;
|
|
66
|
+
else if (parsed.kind === "trailer") options.trailers.push(parsed.value ?? "");
|
|
67
|
+
else if (parsed.value === undefined || /\$\(|`/.test(parsed.value)) options.opaque = true;
|
|
68
|
+
else options.messages.push(parsed.value);
|
|
75
69
|
}
|
|
76
70
|
return options;
|
|
77
71
|
}
|
|
78
72
|
|
|
73
|
+
const unknown = (trailers: readonly string[] = []): CommitExtraction => ({
|
|
74
|
+
kind: "unknown",
|
|
75
|
+
trailers,
|
|
76
|
+
});
|
|
77
|
+
|
|
79
78
|
/** The commit message a `git commit` shell command will use, or why it cannot be known. */
|
|
80
79
|
export function extractCommit(command: string): CommitExtraction {
|
|
81
80
|
const { rest, bodies } = stripHeredocs(command);
|
|
82
81
|
const heredoc = bodies[0]?.trim();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
}
|
|
82
|
+
const resolution = resolveGit(rest);
|
|
83
|
+
const commit = resolution.invocations.find((g) => g.sub === "commit");
|
|
84
|
+
if (commit === undefined) {
|
|
85
|
+
return resolution.opaque && /\bcommit\b/.test(command) ? unknown() : { kind: "not-commit" };
|
|
98
86
|
}
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const sub = at === undefined ? undefined : segment[at];
|
|
111
|
-
return at === undefined || sub === undefined ? [] : [{ sub, args: segment.slice(at + 1) }];
|
|
112
|
-
}),
|
|
113
|
-
);
|
|
87
|
+
const options = readOptions(commit.args);
|
|
88
|
+
if (options.file === "-" && heredoc !== undefined) return { kind: "message", message: heredoc };
|
|
89
|
+
if (options.file !== undefined) return { kind: "file", path: options.file };
|
|
90
|
+
if (options.opaque) {
|
|
91
|
+
return heredoc === undefined
|
|
92
|
+
? unknown(options.trailers)
|
|
93
|
+
: { kind: "message", message: heredoc };
|
|
94
|
+
}
|
|
95
|
+
if (options.messages.length === 0) return unknown(options.trailers);
|
|
96
|
+
const trailers = options.trailers.length === 0 ? "" : `\n\n${options.trailers.join("\n")}`;
|
|
97
|
+
return { kind: "message", message: `${options.messages.join("\n\n")}${trailers}` };
|
|
114
98
|
}
|
|
@@ -86,7 +86,7 @@ export function findForbiddenTrailers(
|
|
|
86
86
|
.split("\n")
|
|
87
87
|
.filter((line) => {
|
|
88
88
|
const trimmed = line.trim();
|
|
89
|
-
const key = /^([A-Za-z][A-Za-z-]*)
|
|
89
|
+
const key = /^([A-Za-z][A-Za-z-]*)\s*[:=]/.exec(trimmed)?.[1]?.toLowerCase();
|
|
90
90
|
if (key !== undefined && keys.includes(key)) return true;
|
|
91
91
|
if (key === "signed-off-by") return AI_NAMES.test(trimmed);
|
|
92
92
|
return GENERATED_BANNER.test(trimmed) && AI_NAMES.test(trimmed);
|
package/src/core/gates.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const GATES: Readonly<Record<string, GateInfo>> = {
|
|
|
18
18
|
tier: "soft",
|
|
19
19
|
default: "structural and behavioural changes go in separate commits",
|
|
20
20
|
},
|
|
21
|
-
"commit.forbidden-trailer": { tier: "
|
|
21
|
+
"commit.forbidden-trailer": { tier: "hard", default: "no Co-Authored-By or AI trailers" },
|
|
22
22
|
"push.red-trunk": { tier: "hard", default: "do not push unrelated work onto a red trunk" },
|
|
23
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" },
|
package/src/core/git-intent.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function splitLines(command: string): string[] {
|
|
|
59
59
|
const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
|
|
60
60
|
|
|
61
61
|
/** Tokens of one line grouped into simple commands (operators separate segments). */
|
|
62
|
-
function segments(line: string): string[][] {
|
|
62
|
+
export function segments(line: string): string[][] {
|
|
63
63
|
const result: string[][] = [[]];
|
|
64
64
|
for (const token of parseShell(line, (name) => `$${name}`)) {
|
|
65
65
|
if (typeof token === "string") result[result.length - 1]?.push(token);
|
|
@@ -70,9 +70,29 @@ function segments(line: string): string[][] {
|
|
|
70
70
|
return result.filter((s) => s.length > 0);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const WRAPPERS = new Set([
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
export const WRAPPERS = new Set([
|
|
74
|
+
"command",
|
|
75
|
+
"sudo",
|
|
76
|
+
"env",
|
|
77
|
+
"time",
|
|
78
|
+
"nohup",
|
|
79
|
+
"exec",
|
|
80
|
+
"builtin",
|
|
81
|
+
"nice",
|
|
82
|
+
]);
|
|
83
|
+
export const KEYWORDS = new Set([
|
|
84
|
+
"then",
|
|
85
|
+
"do",
|
|
86
|
+
"else",
|
|
87
|
+
"elif",
|
|
88
|
+
"if",
|
|
89
|
+
"while",
|
|
90
|
+
"until",
|
|
91
|
+
"{",
|
|
92
|
+
"!",
|
|
93
|
+
"time",
|
|
94
|
+
]);
|
|
95
|
+
export const DATA_ONLY = new Set([
|
|
76
96
|
"echo",
|
|
77
97
|
"printf",
|
|
78
98
|
"cat",
|
|
@@ -84,13 +104,13 @@ const DATA_ONLY = new Set([
|
|
|
84
104
|
"head",
|
|
85
105
|
"tail",
|
|
86
106
|
]);
|
|
87
|
-
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
88
|
-
const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
89
|
-
const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
|
|
107
|
+
export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
108
|
+
export const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
109
|
+
export const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
|
|
90
110
|
const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
|
|
91
111
|
|
|
92
|
-
const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
93
|
-
const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
112
|
+
export const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
113
|
+
export const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
94
114
|
|
|
95
115
|
/** True when `arg` is `name` or an unambiguous-looking git abbreviation of it (`--amen`, `--del=x`). */
|
|
96
116
|
const isLong = (arg: string, ...names: string[]): boolean => {
|
|
@@ -200,7 +220,7 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
200
220
|
* The parts of a line bash may still execute or treat as syntax: single-quoted text and
|
|
201
221
|
* backslash-escaped characters are dropped; double-quoted text is kept unless `dropDouble`.
|
|
202
222
|
*/
|
|
203
|
-
function live(line: string, dropDouble: boolean): string {
|
|
223
|
+
export function live(line: string, dropDouble: boolean): string {
|
|
204
224
|
let out = "";
|
|
205
225
|
let quote: "'" | '"' | undefined;
|
|
206
226
|
for (let i = 0; i < line.length; i++) {
|
|
@@ -222,7 +242,7 @@ function live(line: string, dropDouble: boolean): string {
|
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
/** Bodies of `$(...)` and backtick substitutions anywhere in the text (quoted or not). */
|
|
225
|
-
function substitutions(text: string): string[] {
|
|
245
|
+
export function substitutions(text: string): string[] {
|
|
226
246
|
const bodies: string[] = [];
|
|
227
247
|
for (let at = text.indexOf("$("); at >= 0; at = text.indexOf("$(", at + 2)) {
|
|
228
248
|
let depth = 1;
|
|
@@ -241,7 +261,7 @@ function substitutions(text: string): string[] {
|
|
|
241
261
|
const HEREDOC = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
|
|
242
262
|
|
|
243
263
|
/** The delimiter of a heredoc started by an unquoted `<<` on this line, if any. */
|
|
244
|
-
function heredocDelimiter(line: string): string | undefined {
|
|
264
|
+
export function heredocDelimiter(line: string): string | undefined {
|
|
245
265
|
let quote: "'" | '"' | undefined;
|
|
246
266
|
for (let i = 0; i < line.length; i++) {
|
|
247
267
|
const ch = line.charAt(i);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
basename,
|
|
3
|
+
DATA_ONLY,
|
|
4
|
+
GLOBAL_WITH_VALUE,
|
|
5
|
+
heredocDelimiter,
|
|
6
|
+
isAssignment,
|
|
7
|
+
KEYWORDS,
|
|
8
|
+
live,
|
|
9
|
+
OPAQUE,
|
|
10
|
+
SHELLS,
|
|
11
|
+
segments,
|
|
12
|
+
splitLines,
|
|
13
|
+
substitutions,
|
|
14
|
+
WRAPPERS,
|
|
15
|
+
} from "./git-intent.ts";
|
|
16
|
+
|
|
17
|
+
export type GitInvocation = { readonly sub: string; readonly args: readonly string[] };
|
|
18
|
+
|
|
19
|
+
export type GitResolution = {
|
|
20
|
+
readonly invocations: readonly GitInvocation[];
|
|
21
|
+
/** True when git may run through something the parser cannot see into (xargs, `$CMD`, …). */
|
|
22
|
+
readonly opaque: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Collector = { invocations: GitInvocation[]; opaque: boolean };
|
|
26
|
+
|
|
27
|
+
const firstCommand = (tokens: readonly string[]): number => {
|
|
28
|
+
const at = tokens.findIndex((t) => !(isAssignment(t) || WRAPPERS.has(t) || KEYWORDS.has(t)));
|
|
29
|
+
return at < 0 ? tokens.length : at;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const gitIndex = (tokens: readonly string[]): number =>
|
|
33
|
+
tokens.findIndex((t) => basename(t) === "git");
|
|
34
|
+
|
|
35
|
+
/** `git [global options] <sub> <args…>` (tokens start at the word after `git`). */
|
|
36
|
+
function visitGit(rest: readonly string[], into: Collector): void {
|
|
37
|
+
let j = 0;
|
|
38
|
+
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
39
|
+
j += GLOBAL_WITH_VALUE.has(rest[j] ?? "") ? 2 : 1;
|
|
40
|
+
}
|
|
41
|
+
const sub = rest[j];
|
|
42
|
+
if (sub === undefined) return;
|
|
43
|
+
if (sub.startsWith("$")) into.opaque = true;
|
|
44
|
+
else into.invocations.push({ sub, args: rest.slice(j + 1) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A shell or `eval`: its script is more command text to resolve. */
|
|
48
|
+
function visitScript(base: string, rest: readonly string[], into: Collector): void {
|
|
49
|
+
if (base === "eval") {
|
|
50
|
+
merge(into, resolveGit(rest.join(" ")));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
|
|
54
|
+
const script = c >= 0 ? rest[c + 1] : undefined;
|
|
55
|
+
if (script !== undefined) merge(into, resolveGit(script));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function visitSegment(tokens: readonly string[], into: Collector): void {
|
|
59
|
+
const head = tokens[firstCommand(tokens)];
|
|
60
|
+
if (head === undefined) return;
|
|
61
|
+
const rest = tokens.slice(firstCommand(tokens) + 1);
|
|
62
|
+
const base = basename(head);
|
|
63
|
+
if (head.startsWith("$")) into.opaque = true;
|
|
64
|
+
else if (SHELLS.has(base) || base === "eval") visitScript(base, rest, into);
|
|
65
|
+
else if (OPAQUE.has(base)) into.opaque = into.opaque || gitIndex(rest) >= 0;
|
|
66
|
+
else if (base === "git") visitGit(rest, into);
|
|
67
|
+
else if (!DATA_ONLY.has(base) && gitIndex(rest) >= 0) {
|
|
68
|
+
// Wrapper with options (`sudo -E git …`, `timeout 5 git …`): resolve from the git token.
|
|
69
|
+
visitSegment(rest.slice(gitIndex(rest)), into);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function merge(into: Collector, other: GitResolution): void {
|
|
74
|
+
into.invocations.push(...other.invocations);
|
|
75
|
+
if (other.opaque) into.opaque = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every `git <sub> <args…>` the shell command may run: simple commands, wrapped ones (`env`,
|
|
80
|
+
* `timeout`, `if … then`), `bash -c`/`eval` scripts and `$(…)` substitutions. Heredoc bodies are data.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveGit(command: string): GitResolution {
|
|
83
|
+
const into: Collector = { invocations: [], opaque: false };
|
|
84
|
+
let heredocEnd: string | undefined;
|
|
85
|
+
for (const line of splitLines(command)) {
|
|
86
|
+
if (heredocEnd !== undefined) {
|
|
87
|
+
if (line.trim() === heredocEnd) heredocEnd = undefined;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const segment of segments(line)) visitSegment(segment, into);
|
|
91
|
+
for (const body of substitutions(live(line, false))) merge(into, resolveGit(body));
|
|
92
|
+
heredocEnd = heredocDelimiter(line) ?? heredocEnd;
|
|
93
|
+
}
|
|
94
|
+
return into;
|
|
95
|
+
}
|
package/src/core/models.ts
CHANGED
|
@@ -40,7 +40,10 @@ const MIN_DATE_DIGITS = 6;
|
|
|
40
40
|
function versionKey(id: string): VersionKey {
|
|
41
41
|
const numbers: number[] = [];
|
|
42
42
|
let date: number | undefined;
|
|
43
|
-
|
|
43
|
+
// Hyphenated ISO snapshot dates (`-2024-08-06`) collapse to one date, like compact YYYYMMDD.
|
|
44
|
+
const iso = /-(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(id);
|
|
45
|
+
if (iso !== null) date = Number(`${iso[1]}${iso[2]}${iso[3]}`);
|
|
46
|
+
for (const run of id.replace(/-\d{4}-\d{2}-\d{2}(?!\d)/, "").match(/\d+/g) ?? []) {
|
|
44
47
|
if (run.length >= MIN_DATE_DIGITS) date = Number(run);
|
|
45
48
|
else numbers.push(Number(run));
|
|
46
49
|
}
|
|
@@ -208,7 +211,7 @@ export function renderMatrixToml(matrix: ModelMatrix): string {
|
|
|
208
211
|
export function upsertModelsTable(text: string, matrix: ModelMatrix): string {
|
|
209
212
|
const table = renderMatrixToml(matrix);
|
|
210
213
|
const lines = text.split("\n");
|
|
211
|
-
const start = lines.findIndex((l) => l.trim()
|
|
214
|
+
const start = lines.findIndex((l) => /^\[models\]\s*(#.*)?$/.test(l.trim()));
|
|
212
215
|
if (start === -1) {
|
|
213
216
|
const body = text.trimEnd();
|
|
214
217
|
return body === "" ? table : `${body}\n\n${table}`;
|
package/src/core/push-command.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveGit } from "./git-invocations.ts";
|
|
2
2
|
|
|
3
3
|
export type PushTarget = {
|
|
4
4
|
readonly remote: string | undefined;
|
|
@@ -8,29 +8,54 @@ export type PushTarget = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
const VALUE_OPTIONS = new Set(["--repo", "-o", "--push-option", "--receive-pack", "--exec"]);
|
|
11
|
+
const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
|
|
11
12
|
|
|
12
13
|
const destination = (refspec: string): string => {
|
|
13
14
|
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
14
15
|
return dst.replace(/^\+/, "").replace(/^refs\/heads\//, "");
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
type ParsedArgs = { positional: string[]; all: boolean; repo: string | undefined };
|
|
19
|
+
|
|
20
|
+
const VALUE_PREFIX = "--repo=";
|
|
21
|
+
|
|
22
|
+
function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
23
|
+
const parsed: ParsedArgs = { positional: [], all: false, repo: undefined };
|
|
24
|
+
let valueFor: "repo" | "skip" | undefined;
|
|
25
|
+
for (const a of args) {
|
|
26
|
+
if (valueFor === "repo") parsed.repo = a;
|
|
27
|
+
if (valueFor !== undefined) valueFor = undefined;
|
|
28
|
+
else if (a === "--repo") valueFor = "repo";
|
|
29
|
+
else if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
|
|
30
|
+
else if (VALUE_OPTIONS.has(a)) valueFor = "skip";
|
|
31
|
+
else if (a === "--all" || a === "--mirror") parsed.all = true;
|
|
32
|
+
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toTarget = (args: readonly string[]): PushTarget => {
|
|
38
|
+
const { positional, all, repo } = positionalArgs(args);
|
|
39
|
+
// With --repo, every positional argument is a refspec.
|
|
40
|
+
const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
|
|
41
|
+
const destinations = refspecs.map(destination);
|
|
42
|
+
const named = (b: string): boolean => b !== "HEAD" && b !== "" && !isDynamic(b);
|
|
43
|
+
return {
|
|
44
|
+
remote,
|
|
45
|
+
branches: destinations.filter(named),
|
|
46
|
+
allBranches: all || destinations.some(isDynamic),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
17
50
|
/** The remote and branches of every `git push` in a command (empty list when there is no push). */
|
|
18
51
|
export function pushTargets(command: string): PushTarget[] {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
});
|
|
52
|
+
const resolution = resolveGit(command);
|
|
53
|
+
const targets = resolution.invocations.flatMap((g) =>
|
|
54
|
+
g.sub === "push" ? [toTarget(g.args)] : [],
|
|
55
|
+
);
|
|
56
|
+
// git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
|
|
57
|
+
const opaquePush = resolution.opaque && /\bpush\b/.test(command);
|
|
58
|
+
return opaquePush
|
|
59
|
+
? [...targets, { remote: undefined, branches: [], allBranches: true }]
|
|
60
|
+
: targets;
|
|
36
61
|
}
|
package/src/core/redact.ts
CHANGED
|
@@ -11,8 +11,17 @@ export function redactSecrets(text: string): string {
|
|
|
11
11
|
"[redacted private key]",
|
|
12
12
|
)
|
|
13
13
|
.replace(
|
|
14
|
-
/\b(\w*(?:token|key|secret|password|passwd)\w*["']?\s*[:=]\s*)(["'])[^"'\n]+\2/gi,
|
|
14
|
+
/\b(\w*(?:token|key|secret|password|passwd)\w*["']?\s*(?::=|[:=])\s*)(["'])[^"'\n]+\2/gi,
|
|
15
15
|
"$1$2[redacted]$2",
|
|
16
16
|
)
|
|
17
|
-
.replace(
|
|
17
|
+
.replace(
|
|
18
|
+
/\b(\w*(?:token|key|secret|password|passwd|pwd)\w*\s*(?::=|[:=])[ \t]*)[^\s"'`,;]{4,}/gi,
|
|
19
|
+
"$1[redacted]",
|
|
20
|
+
)
|
|
21
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]")
|
|
22
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
|
|
23
|
+
.replace(/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "[redacted]")
|
|
24
|
+
.replace(/\bglpat-[A-Za-z0-9_-]{16,}/g, "[redacted]")
|
|
25
|
+
.replace(/\bnpm_[A-Za-z0-9]{30,}/g, "[redacted]")
|
|
26
|
+
.replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g, "[redacted]");
|
|
18
27
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -56,6 +56,12 @@ export type Departure = {
|
|
|
56
56
|
|
|
57
57
|
export type JevStatus = "online" | "offline" | "unknown";
|
|
58
58
|
|
|
59
|
+
export type CiStatusName = "green" | "red" | "pending" | "unknown";
|
|
60
|
+
export const CI_STATUSES: readonly CiStatusName[] = ["green", "red", "pending", "unknown"];
|
|
61
|
+
|
|
62
|
+
/** Last observed CI state of the trunk (see `/devsys-ci`). */
|
|
63
|
+
export type CiState = { readonly status: CiStatusName; readonly sha?: string };
|
|
64
|
+
|
|
59
65
|
export type DevsysState = {
|
|
60
66
|
readonly phase: Phase;
|
|
61
67
|
readonly sizing?: Sizing;
|
|
@@ -63,6 +69,7 @@ export type DevsysState = {
|
|
|
63
69
|
readonly openDepartures: ReadonlyArray<Departure>;
|
|
64
70
|
readonly jev: JevStatus;
|
|
65
71
|
readonly lastPushAt?: string;
|
|
72
|
+
readonly ci?: CiState;
|
|
66
73
|
};
|
|
67
74
|
|
|
68
75
|
export const initialState = (): DevsysState => ({
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
type ExtensionAPI,
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
isToolCallEventType,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { extractCommit } from "../core/commit-command.ts";
|
|
8
|
+
import { type CommitExtraction, extractCommit } from "../core/commit-command.ts";
|
|
9
9
|
import {
|
|
10
10
|
findForbiddenTrailers,
|
|
11
11
|
hasRationaleBody,
|
|
@@ -38,12 +38,17 @@ const MIXED = gateId("commit.mixed-change");
|
|
|
38
38
|
|
|
39
39
|
const readMessageFile = (cwd: string, path: string): string | undefined => {
|
|
40
40
|
try {
|
|
41
|
-
return readFileSync(
|
|
41
|
+
return readFileSync(resolve(cwd, path), "utf8");
|
|
42
42
|
} catch {
|
|
43
43
|
return undefined;
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
const messageOf = (extracted: CommitExtraction, cwd: string): string | undefined => {
|
|
48
|
+
if (extracted.kind === "message") return extracted.message;
|
|
49
|
+
return extracted.kind === "file" ? readMessageFile(cwd, extracted.path) : undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
47
52
|
/** Deterministic checks: Conventional subject and a prose rationale body. */
|
|
48
53
|
function messageNeeds(message: string): Need[] {
|
|
49
54
|
const parsed = parseConventionalCommit(message);
|
|
@@ -58,12 +63,16 @@ async function pendingDiff(
|
|
|
58
63
|
exec: Exec,
|
|
59
64
|
cwd: string,
|
|
60
65
|
): Promise<{ stat: string; diff: string } | undefined> {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
try {
|
|
67
|
+
const [stat, diff] = await Promise.all([
|
|
68
|
+
exec("git", ["diff", "HEAD", "--stat"], { cwd, timeout: 10_000 }),
|
|
69
|
+
exec("git", ["diff", "HEAD"], { cwd, timeout: 10_000 }),
|
|
70
|
+
]);
|
|
71
|
+
if (stat.code !== 0 || diff.code !== 0 || diff.stdout.trim() === "") return undefined;
|
|
72
|
+
return { stat: stat.stdout, diff: diff.stdout };
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
async function jevNeeds(
|
|
@@ -103,16 +112,15 @@ const blockReason = (need: Need): string =>
|
|
|
103
112
|
export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
104
113
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
105
114
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: extracted.kind === "file"
|
|
111
|
-
? readMessageFile(ctx.cwd, extracted.path)
|
|
112
|
-
: undefined;
|
|
113
|
-
if (message === undefined) return undefined;
|
|
115
|
+
const command = event.input.command;
|
|
116
|
+
const extracted = extractCommit(command);
|
|
117
|
+
if (extracted.kind === "not-commit") return undefined;
|
|
118
|
+
const message = messageOf(extracted, ctx.cwd);
|
|
114
119
|
|
|
115
|
-
|
|
120
|
+
// Safety net: whatever the message source, the command text itself must not carry an AI trailer
|
|
121
|
+
// (heredocs written to a file first, --trailer values on amends, messages built elsewhere).
|
|
122
|
+
const trailers = extracted.kind === "unknown" ? extracted.trailers.join("\n") : "";
|
|
123
|
+
const forbidden = findForbiddenTrailers(`${message ?? ""}\n${trailers}\n${command}`);
|
|
116
124
|
if (forbidden.length > 0) {
|
|
117
125
|
return {
|
|
118
126
|
block: true,
|
|
@@ -122,6 +130,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
|
122
130
|
"something to depart from. Remove the trailer and commit again.",
|
|
123
131
|
};
|
|
124
132
|
}
|
|
133
|
+
if (message === undefined) return undefined;
|
|
125
134
|
|
|
126
135
|
const needs = messageNeeds(message);
|
|
127
136
|
const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
|
package/src/gates/push-guard.ts
CHANGED
|
@@ -35,9 +35,13 @@ const RED_TRUNK = gate("push.red-trunk");
|
|
|
35
35
|
const DELIVERY_MODE = gate("push.delivery-mode");
|
|
36
36
|
|
|
37
37
|
async function currentBranch(exec: Exec, cwd: string): Promise<string | undefined> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
try {
|
|
39
|
+
const r = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeout: 5000 });
|
|
40
|
+
const name = r.code === 0 ? r.stdout.trim() : "";
|
|
41
|
+
return name === "" || name === "HEAD" ? undefined : name;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
async function pushesTrunk(
|
|
@@ -58,6 +62,7 @@ async function repairsRedTrunk(
|
|
|
58
62
|
deps: PushGuardDeps,
|
|
59
63
|
ctx: ExtensionContext,
|
|
60
64
|
config: DevsysConfig,
|
|
65
|
+
runId: number | undefined,
|
|
61
66
|
): Promise<boolean> {
|
|
62
67
|
const message = await deps.exec("git", ["log", "-1", "--format=%B"], {
|
|
63
68
|
cwd: ctx.cwd,
|
|
@@ -70,7 +75,7 @@ async function repairsRedTrunk(
|
|
|
70
75
|
if (diff.code !== 0 || diff.stdout.trim() === "") return false;
|
|
71
76
|
const judged = await judgeFixRelated(deps.jev(ctx), {
|
|
72
77
|
diff: diff.stdout,
|
|
73
|
-
failingLog: await getFailureLog(deps.exec, ctx.cwd),
|
|
78
|
+
failingLog: await getFailureLog(deps.exec, ctx.cwd, runId),
|
|
74
79
|
message: message.stdout,
|
|
75
80
|
});
|
|
76
81
|
return judged.ok && judged.value >= FIX_RELATED_THRESHOLD;
|
|
@@ -145,7 +150,7 @@ export function registerPushGuard(deps: PushGuardDeps): void {
|
|
|
145
150
|
}
|
|
146
151
|
const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
|
|
147
152
|
if (status.status !== "red") return undefined;
|
|
148
|
-
if (await repairsRedTrunk(deps, ctx, loaded.value)) return undefined;
|
|
153
|
+
if (await repairsRedTrunk(deps, ctx, loaded.value, status.runId)) return undefined;
|
|
149
154
|
return hardStop(deps, {
|
|
150
155
|
ctx,
|
|
151
156
|
toolCallId: event.toolCallId,
|
|
@@ -33,6 +33,12 @@ export function createRequestApprovalTool(
|
|
|
33
33
|
exposure: "direct",
|
|
34
34
|
async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
35
35
|
const gate = parseGateId(params.gate);
|
|
36
|
+
if (params.gate === "commit.forbidden-trailer") {
|
|
37
|
+
return text(
|
|
38
|
+
"commit.forbidden-trailer is never approvable: remove the AI attribution from the commit instead.",
|
|
39
|
+
true,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
36
42
|
if (isParseError(gate) || gate.includes(":") || lookupGate(gate)?.tier !== "hard") {
|
|
37
43
|
const hard = gateIds().filter((id) => lookupGate(id)?.tier === "hard");
|
|
38
44
|
return text(`"${params.gate}" is not a hard gate. Hard gates: ${hard.join(", ")}`, true);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Exec } from "../core/exec.ts";
|
|
3
|
+
import type { CiState } from "../core/types.ts";
|
|
4
|
+
import { watchCi } from "./ci-watch.ts";
|
|
5
|
+
import { loadConfig } from "./config.ts";
|
|
6
|
+
import type { SessionState } from "./session-state.ts";
|
|
7
|
+
|
|
8
|
+
export const CI_ENTRY_TYPE = "devsys-ci";
|
|
9
|
+
|
|
10
|
+
export type CiCommandDeps = {
|
|
11
|
+
pi: ExtensionAPI;
|
|
12
|
+
state: SessionState;
|
|
13
|
+
exec: Exec;
|
|
14
|
+
sleep?: (ms: number) => Promise<void>;
|
|
15
|
+
intervalMs?: number;
|
|
16
|
+
maxPolls?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function headSha(exec: Exec, cwd: string): Promise<string | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
const r = await exec("git", ["rev-parse", "HEAD"], { cwd, timeout: 5000 });
|
|
22
|
+
return r.code === 0 && r.stdout.trim() !== "" ? r.stdout.trim() : undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sameCi = (a: CiState | undefined, b: CiState): boolean =>
|
|
29
|
+
a?.status === b.status && a?.sha === b.sha;
|
|
30
|
+
|
|
31
|
+
/** Observes trunk CI until it settles, mirroring each change into state (status line) and a `devsys-ci` entry. */
|
|
32
|
+
export async function runCiWatch(
|
|
33
|
+
deps: CiCommandDeps,
|
|
34
|
+
ctx: Pick<ExtensionCommandContext, "cwd" | "ui">,
|
|
35
|
+
): Promise<CiState | undefined> {
|
|
36
|
+
const config = await loadConfig(ctx.cwd);
|
|
37
|
+
if (!config.ok) {
|
|
38
|
+
ctx.ui.notify(`cannot read the delivery policy: ${config.error.message}`, "error");
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const head = await headSha(deps.exec, ctx.cwd);
|
|
42
|
+
const final = await watchCi({
|
|
43
|
+
expectSha: head,
|
|
44
|
+
exec: deps.exec,
|
|
45
|
+
branch: config.value.delivery.trunk,
|
|
46
|
+
cwd: ctx.cwd,
|
|
47
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
48
|
+
intervalMs: deps.intervalMs ?? 15_000,
|
|
49
|
+
maxPolls: deps.maxPolls ?? 120,
|
|
50
|
+
onObserved: (observed) => {
|
|
51
|
+
if (sameCi(deps.state.get().ci, observed)) return;
|
|
52
|
+
deps.state.update((s) => ({ ...s, ci: observed }));
|
|
53
|
+
deps.pi.appendEntry(CI_ENTRY_TYPE, observed);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
ctx.ui.notify(
|
|
57
|
+
`CI on ${config.value.delivery.trunk}: ${final.status}${final.sha === undefined ? "" : ` (${final.sha.slice(0, 7)})`}`,
|
|
58
|
+
final.status === "red" ? "error" : "info",
|
|
59
|
+
);
|
|
60
|
+
return final;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `/devsys-ci`: watch the trunk's CI without blocking the session. */
|
|
64
|
+
export function registerCiCommand(deps: CiCommandDeps): void {
|
|
65
|
+
deps.pi.registerCommand("devsys-ci", {
|
|
66
|
+
description: "Watch CI on the trunk and show its state in the status line",
|
|
67
|
+
handler: async (_args, ctx) => {
|
|
68
|
+
ctx.ui.notify("watching CI on the trunk…", "info");
|
|
69
|
+
runCiWatch(deps, ctx).catch((cause: unknown) => {
|
|
70
|
+
ctx.ui.notify(
|
|
71
|
+
`CI watch failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
72
|
+
"error",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Exec } from "../core/exec.ts";
|
|
2
|
+
import type { CiState } from "../core/types.ts";
|
|
3
|
+
import { getTrunkStatus } from "./ci.ts";
|
|
4
|
+
|
|
5
|
+
export type WatchOptions = {
|
|
6
|
+
exec: Exec;
|
|
7
|
+
branch: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
/** Called with every observation, including the first. */
|
|
10
|
+
onObserved: (observed: CiState) => void;
|
|
11
|
+
sleep: (ms: number) => Promise<void>;
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
maxPolls: number;
|
|
14
|
+
/** The commit whose run is being waited for; runs of other commits count as still pending. */
|
|
15
|
+
expectSha?: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Observes the trunk's newest run (for `expectSha`, when given) until it settles (not pending) or the poll budget is spent. */
|
|
19
|
+
export async function watchCi(options: WatchOptions): Promise<CiState> {
|
|
20
|
+
let last: CiState = { status: "unknown" };
|
|
21
|
+
for (let poll = 0; poll < options.maxPolls; poll++) {
|
|
22
|
+
const trunk = await getTrunkStatus(options.exec, {
|
|
23
|
+
branch: options.branch,
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
});
|
|
26
|
+
const stale = options.expectSha !== undefined && trunk.headSha !== options.expectSha;
|
|
27
|
+
const status = stale ? "pending" : trunk.status;
|
|
28
|
+
last = trunk.headSha === undefined ? { status } : { status, sha: trunk.headSha };
|
|
29
|
+
options.onObserved(last);
|
|
30
|
+
if (last.status !== "pending") return last;
|
|
31
|
+
await options.sleep(options.intervalMs);
|
|
32
|
+
}
|
|
33
|
+
return last;
|
|
34
|
+
}
|
package/src/state/ci.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Exec } from "../core/exec.ts";
|
|
|
3
3
|
export type TrunkStatus = {
|
|
4
4
|
readonly status: "green" | "red" | "pending" | "unknown";
|
|
5
5
|
readonly headSha: string | undefined;
|
|
6
|
+
readonly runId?: number | undefined;
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
const UNKNOWN: TrunkStatus = { status: "unknown", headSha: undefined };
|
|
@@ -12,7 +13,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
|
12
13
|
|
|
13
14
|
const FAILED = new Set(["failure", "timed_out", "startup_failure", "action_required"]);
|
|
14
15
|
|
|
15
|
-
/** Parses `gh run list --json status,conclusion,headSha` output for the newest run. */
|
|
16
|
+
/** Parses `gh run list --json status,conclusion,headSha,databaseId` output for the newest run. */
|
|
16
17
|
export function parseRunList(stdout: string): TrunkStatus {
|
|
17
18
|
let parsed: unknown;
|
|
18
19
|
try {
|
|
@@ -23,12 +24,13 @@ export function parseRunList(stdout: string): TrunkStatus {
|
|
|
23
24
|
const run = Array.isArray(parsed) ? parsed[0] : undefined;
|
|
24
25
|
if (!isRecord(run)) return UNKNOWN;
|
|
25
26
|
const headSha = typeof run.headSha === "string" ? run.headSha : undefined;
|
|
26
|
-
|
|
27
|
-
if (run.
|
|
27
|
+
const id = typeof run.databaseId === "number" ? { runId: run.databaseId } : {};
|
|
28
|
+
if (run.status !== "completed") return { status: "pending", headSha, ...id };
|
|
29
|
+
if (run.conclusion === "success") return { status: "green", headSha, ...id };
|
|
28
30
|
if (typeof run.conclusion === "string" && FAILED.has(run.conclusion)) {
|
|
29
|
-
return { status: "red", headSha };
|
|
31
|
+
return { status: "red", headSha, ...id };
|
|
30
32
|
}
|
|
31
|
-
return { status: "unknown", headSha };
|
|
33
|
+
return { status: "unknown", headSha, ...id };
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** CI state of the newest run on the trunk branch; `unknown` whenever `gh` cannot say. */
|
|
@@ -47,7 +49,7 @@ export async function getTrunkStatus(
|
|
|
47
49
|
"--limit",
|
|
48
50
|
"1",
|
|
49
51
|
"--json",
|
|
50
|
-
"status,conclusion,headSha",
|
|
52
|
+
"status,conclusion,headSha,databaseId",
|
|
51
53
|
],
|
|
52
54
|
{ ...(where.cwd !== undefined ? { cwd: where.cwd } : {}), timeout: 15_000 },
|
|
53
55
|
);
|
|
@@ -57,10 +59,15 @@ export async function getTrunkStatus(
|
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/** The failing log of
|
|
61
|
-
export async function getFailureLog(
|
|
62
|
+
/** The failing log of a failed run, clipped; empty when unavailable (`gh run view` needs an id when non-interactive). */
|
|
63
|
+
export async function getFailureLog(
|
|
64
|
+
exec: Exec,
|
|
65
|
+
cwd: string | undefined,
|
|
66
|
+
runId: number | undefined,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
if (runId === undefined) return "";
|
|
62
69
|
try {
|
|
63
|
-
const r = await exec("gh", ["run", "view", "--log-failed"], {
|
|
70
|
+
const r = await exec("gh", ["run", "view", String(runId), "--log-failed"], {
|
|
64
71
|
...(cwd !== undefined ? { cwd } : {}),
|
|
65
72
|
timeout: 30_000,
|
|
66
73
|
});
|
package/src/state/config.ts
CHANGED
|
@@ -124,11 +124,13 @@ const POSITIVE = { min: 1, max: 1_000_000, integer: true };
|
|
|
124
124
|
|
|
125
125
|
const MODEL_CANDIDATE = /^(@[a-z]+|[^/\s]+\/\S+)$/;
|
|
126
126
|
|
|
127
|
+
const knownSlotRef = (c: string): boolean => !c.startsWith("@") || isSlot(c.slice(1));
|
|
128
|
+
|
|
127
129
|
function candidates(raw: unknown, key: string): Result<readonly string[], ConfigError> {
|
|
128
130
|
const valid =
|
|
129
131
|
Array.isArray(raw) &&
|
|
130
132
|
raw.length > 0 &&
|
|
131
|
-
raw.every((c) => typeof c === "string" && MODEL_CANDIDATE.test(c));
|
|
133
|
+
raw.every((c) => typeof c === "string" && MODEL_CANDIDATE.test(c) && knownSlotRef(c));
|
|
132
134
|
return valid
|
|
133
135
|
? ok(raw.map(String))
|
|
134
136
|
: fail(
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { parseDeparture } from "../core/departure.ts";
|
|
3
3
|
import {
|
|
4
|
+
CI_STATUSES,
|
|
5
|
+
type CiState,
|
|
6
|
+
type CiStatusName,
|
|
4
7
|
type Departure,
|
|
5
8
|
type DevsysState,
|
|
6
9
|
initialState,
|
|
@@ -29,10 +32,20 @@ const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
|
|
|
29
32
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
30
33
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
31
34
|
|
|
35
|
+
function parseCi(input: unknown): CiState | ParseError {
|
|
36
|
+
if (!isRecord(input)) return parseError("ci must be an object");
|
|
37
|
+
const { status, sha } = input;
|
|
38
|
+
if (!CI_STATUSES.includes(status as CiStatusName)) {
|
|
39
|
+
return parseError(`unknown ci status: ${String(status)}`);
|
|
40
|
+
}
|
|
41
|
+
if (sha !== undefined && typeof sha !== "string") return parseError("ci.sha must be a string");
|
|
42
|
+
return { status: status as CiStatusName, ...(sha !== undefined ? { sha } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/** Boundary parse for persisted state: the only place state is cast from `unknown`. */
|
|
33
46
|
export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
34
47
|
if (!isRecord(input)) return parseError("devsys state must be an object");
|
|
35
|
-
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
|
|
48
|
+
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt, ci } = input;
|
|
36
49
|
if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
|
|
37
50
|
if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
|
|
38
51
|
if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
|
|
@@ -51,6 +64,8 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
51
64
|
if (lastPushAt !== undefined && typeof lastPushAt !== "string") {
|
|
52
65
|
return parseError("lastPushAt must be a string");
|
|
53
66
|
}
|
|
67
|
+
const parsedCi = ci === undefined ? undefined : parseCi(ci);
|
|
68
|
+
if (isParseError(parsedCi)) return parsedCi;
|
|
54
69
|
return {
|
|
55
70
|
phase: phase as Phase,
|
|
56
71
|
jev: jev as JevStatus,
|
|
@@ -58,6 +73,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
58
73
|
...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
|
|
59
74
|
...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
|
|
60
75
|
...(lastPushAt !== undefined ? { lastPushAt } : {}),
|
|
76
|
+
...(parsedCi !== undefined ? { ci: parsedCi } : {}),
|
|
61
77
|
};
|
|
62
78
|
}
|
|
63
79
|
|