@jwilger/pi-development-system 0.8.3 → 0.8.5
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/package.json +1 -1
- package/skills/delivery-discipline/SKILL.md +12 -2
- package/src/core/commit-command.ts +2 -1
- package/src/core/commit-message.ts +9 -4
- package/src/core/git-intent.ts +15 -5
- package/src/core/push-command.ts +31 -5
- package/src/gates/commit-guard.ts +16 -6
- package/src/gates/push-guard.ts +17 -2
- package/src/state/ci-watch.ts +5 -1
package/package.json
CHANGED
|
@@ -22,6 +22,14 @@ the issue time. Sessions outliving their grant is a security defect, so the
|
|
|
22
22
|
check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
```text
|
|
26
|
+
refactor(billing): extract invoice totals into a pure function
|
|
27
|
+
|
|
28
|
+
Totals were computed inline in three handlers, so each fix had to land three
|
|
29
|
+
times. Behaviour is unchanged (tests untouched); the next commit adds the
|
|
30
|
+
rounding rule on top of this seam.
|
|
31
|
+
```
|
|
32
|
+
|
|
25
33
|
- Do not write a body that only restates the diff. The diff already says what;
|
|
26
34
|
the body is the only place the reason survives.
|
|
27
35
|
- Do not add `Co-Authored-By`, "Generated with" or any AI trailer. This is
|
|
@@ -41,8 +49,10 @@ check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
|
41
49
|
|
|
42
50
|
Do not start unrelated work while trunk CI is red. Fix it first; a red build
|
|
43
51
|
hides every later failure. A push to a red trunk is a hard stop unless the
|
|
44
|
-
commit is
|
|
45
|
-
|
|
52
|
+
commit is `fix(ci): …` (or another `fix`), touches only what the failing log names, and
|
|
53
|
+
stays small (an unpushed diff over 8000 characters is never exempt); Jev must read it as
|
|
54
|
+
only repairing the failing run, which needs the failing log and Jev online. Otherwise the
|
|
55
|
+
push is a hard stop. If `gh`
|
|
46
56
|
is missing or unauthenticated, CI reads as unknown and the push is allowed:
|
|
47
57
|
check CI by other means before relying on it.
|
|
48
58
|
|
|
@@ -72,7 +72,8 @@ function parseArg(args: readonly string[], i: number): Parsed | undefined {
|
|
|
72
72
|
return undefined;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
/** A value that is itself a substitution or variable (not prose that mentions backticks). */
|
|
76
|
+
const OPAQUE_VALUE = /^\s*(?:\$\(|`)|^\$\{?\w+\}?$/;
|
|
76
77
|
|
|
77
78
|
function record(options: Options, parsed: Parsed): void {
|
|
78
79
|
if (parsed.kind === "file") options.file = parsed.value;
|
|
@@ -101,10 +101,15 @@ export function findForbiddenTrailerKeys(text: string): string[] {
|
|
|
101
101
|
return [...text.matchAll(AI_TRAILER_ANYWHERE)].map((m) => m[0].trim());
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const
|
|
105
|
-
paragraph.split("\n").every((line) => /^\s*(?:[-*+]|\d+[.)])\s/.test(line));
|
|
106
|
-
|
|
104
|
+
const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
107
105
|
const wordCount = (text: string): number => text.split(/\s+/).filter((w) => w !== "").length;
|
|
106
|
+
const SHORT_BULLET_WORDS = 5;
|
|
107
|
+
|
|
108
|
+
/** A list of file names or other terse items; bullets that explain themselves are prose. */
|
|
109
|
+
const isTerseList = (paragraph: string): boolean =>
|
|
110
|
+
paragraph
|
|
111
|
+
.split("\n")
|
|
112
|
+
.every((line) => BULLET.test(line) && wordCount(line.replace(BULLET, "")) < SHORT_BULLET_WORDS);
|
|
108
113
|
|
|
109
114
|
const PROSE_TRAILER_WORDS = 5;
|
|
110
115
|
|
|
@@ -124,6 +129,6 @@ export function hasRationaleBody(message: string): boolean {
|
|
|
124
129
|
?.split("\n") ?? [];
|
|
125
130
|
const proseTrailer = trailers.length > 0 && trailerBlockLines.some(isProseTrailer);
|
|
126
131
|
return (
|
|
127
|
-
proseTrailer || body.some((paragraph) => !
|
|
132
|
+
proseTrailer || body.some((paragraph) => !isTerseList(paragraph) && wordCount(paragraph) >= 5)
|
|
128
133
|
);
|
|
129
134
|
}
|
package/src/core/git-intent.ts
CHANGED
|
@@ -58,14 +58,24 @@ export function splitLines(command: string): string[] {
|
|
|
58
58
|
|
|
59
59
|
const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
|
|
60
60
|
|
|
61
|
-
/** Tokens of one line grouped into simple commands (operators separate segments). */
|
|
61
|
+
/** Tokens of one line grouped into simple commands (operators separate segments; redirects and their operands are dropped). */
|
|
62
62
|
export function segments(line: string): string[][] {
|
|
63
63
|
const result: string[][] = [[]];
|
|
64
|
+
let skipOperand = false;
|
|
64
65
|
for (const token of parseShell(line, (name) => `$${name}`)) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
const current = result[result.length - 1];
|
|
67
|
+
if (typeof token === "string" || "pattern" in token) {
|
|
68
|
+
const text = typeof token === "string" ? token : token.pattern;
|
|
69
|
+
if (!skipOperand) current?.push(text);
|
|
70
|
+
skipOperand = false;
|
|
71
|
+
} else if ("op" in token && isRedirect(token.op)) {
|
|
72
|
+
// `2>&1`, `>/dev/null`: the file descriptor before it and the target after it are not arguments.
|
|
73
|
+
if (/^\d+$/.test(current?.at(-1) ?? "")) current?.pop();
|
|
74
|
+
skipOperand = true;
|
|
75
|
+
} else if (!("comment" in token)) {
|
|
76
|
+
result.push([]);
|
|
77
|
+
skipOperand = false;
|
|
78
|
+
}
|
|
69
79
|
}
|
|
70
80
|
return result.filter((s) => s.length > 0);
|
|
71
81
|
}
|
package/src/core/push-command.ts
CHANGED
|
@@ -5,6 +5,10 @@ export type PushTarget = {
|
|
|
5
5
|
/** Destination branch names named by refspecs; empty means "the current branch's default". */
|
|
6
6
|
readonly branches: readonly string[];
|
|
7
7
|
readonly allBranches: boolean;
|
|
8
|
+
/** The refspecs name `HEAD`, so the current branch is pushed too. */
|
|
9
|
+
readonly usesHead: boolean;
|
|
10
|
+
/** `--tags` with no refspec: only tags are pushed, no branch. */
|
|
11
|
+
readonly tagsOnly: boolean;
|
|
8
12
|
/** Where the push runs when `cd`/`git -C` moved it away from the session's directory. */
|
|
9
13
|
readonly dir?: string | undefined;
|
|
10
14
|
};
|
|
@@ -12,17 +16,26 @@ export type PushTarget = {
|
|
|
12
16
|
const VALUE_OPTIONS = new Set(["--repo", "-o", "--push-option", "--receive-pack", "--exec"]);
|
|
13
17
|
const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
|
|
14
18
|
|
|
19
|
+
/** A dry run pushes nothing. */
|
|
20
|
+
const isDryRun = (args: readonly string[]): boolean =>
|
|
21
|
+
args.some((a) => a === "--dry-run" || a === "-n" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a));
|
|
22
|
+
|
|
15
23
|
const destination = (refspec: string): string => {
|
|
16
24
|
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
17
25
|
return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
|
|
18
26
|
};
|
|
19
27
|
|
|
20
|
-
type ParsedArgs = {
|
|
28
|
+
type ParsedArgs = {
|
|
29
|
+
positional: string[];
|
|
30
|
+
all: boolean;
|
|
31
|
+
tags: boolean;
|
|
32
|
+
repo: string | undefined;
|
|
33
|
+
};
|
|
21
34
|
|
|
22
35
|
const VALUE_PREFIX = "--repo=";
|
|
23
36
|
|
|
24
37
|
function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
25
|
-
const parsed: ParsedArgs = { positional: [], all: false, repo: undefined };
|
|
38
|
+
const parsed: ParsedArgs = { positional: [], all: false, tags: false, repo: undefined };
|
|
26
39
|
let valueFor: "repo" | "skip" | undefined;
|
|
27
40
|
for (const a of args) {
|
|
28
41
|
if (valueFor === "repo") parsed.repo = a;
|
|
@@ -31,13 +44,14 @@ function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
|
31
44
|
else if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
|
|
32
45
|
else if (VALUE_OPTIONS.has(a)) valueFor = "skip";
|
|
33
46
|
else if (a === "--all" || a === "--mirror") parsed.all = true;
|
|
47
|
+
else if (a === "--tags") parsed.tags = true;
|
|
34
48
|
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
35
49
|
}
|
|
36
50
|
return parsed;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
|
|
40
|
-
const { positional, all, repo } = positionalArgs(args);
|
|
54
|
+
const { positional, all, tags, repo } = positionalArgs(args);
|
|
41
55
|
// With --repo, every positional argument is a refspec.
|
|
42
56
|
const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
|
|
43
57
|
const destinations = refspecs.map(destination);
|
|
@@ -46,6 +60,8 @@ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget
|
|
|
46
60
|
remote,
|
|
47
61
|
branches: destinations.filter(named),
|
|
48
62
|
allBranches: all || destinations.some(isDynamic),
|
|
63
|
+
usesHead: destinations.includes("HEAD"),
|
|
64
|
+
tagsOnly: tags && refspecs.length === 0 && !all,
|
|
49
65
|
...(dir === undefined ? {} : { dir }),
|
|
50
66
|
};
|
|
51
67
|
};
|
|
@@ -54,11 +70,21 @@ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget
|
|
|
54
70
|
export function pushTargets(command: string): PushTarget[] {
|
|
55
71
|
const resolution = resolveGit(command);
|
|
56
72
|
const targets = resolution.invocations.flatMap((g) =>
|
|
57
|
-
g.sub === "push" ? [toTarget(g.args, g.dir)] : [],
|
|
73
|
+
g.sub === "push" && !isDryRun(g.args) ? [toTarget(g.args, g.dir)] : [],
|
|
58
74
|
);
|
|
59
75
|
// git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
|
|
60
76
|
const opaquePush = resolution.opaque && /\bpush\b/.test(command);
|
|
61
77
|
return opaquePush
|
|
62
|
-
? [
|
|
78
|
+
? [
|
|
79
|
+
...targets,
|
|
80
|
+
{
|
|
81
|
+
remote: undefined,
|
|
82
|
+
branches: [],
|
|
83
|
+
allBranches: true,
|
|
84
|
+
usesHead: false,
|
|
85
|
+
tagsOnly: false,
|
|
86
|
+
dir: undefined,
|
|
87
|
+
},
|
|
88
|
+
]
|
|
63
89
|
: targets;
|
|
64
90
|
}
|
|
@@ -61,15 +61,23 @@ function messageNeeds(message: string): Need[] {
|
|
|
61
61
|
: [{ gate: RATIONALE, why: "the message has no body explaining why the change was made" }];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/** `-a`/`--all`, or a `git add` in the same command, widen the commit beyond what is staged. */
|
|
65
|
+
const stagesEverything = (command: string): boolean =>
|
|
66
|
+
/\bgit\b[^;&|\n]*\bcommit\b[^;&|\n]*\s(?:-[a-zA-Z]*a[a-zA-Z]*|--all|--include)(?:\s|$)|\bgit\s+add\s+(?:-A|--all|-u|\.)(?:\s|$)/.test(
|
|
67
|
+
command,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/** What is about to be committed: the staged changes, or every tracked change when the commit stages them itself. */
|
|
65
71
|
async function pendingDiff(
|
|
66
72
|
exec: Exec,
|
|
67
73
|
cwd: string,
|
|
74
|
+
command: string,
|
|
68
75
|
): Promise<{ stat: string; diff: string } | undefined> {
|
|
69
76
|
try {
|
|
77
|
+
const base = stagesEverything(command) ? ["diff", "HEAD"] : ["diff", "--cached"];
|
|
70
78
|
const [stat, diff] = await Promise.all([
|
|
71
|
-
exec("git", [
|
|
72
|
-
exec("git",
|
|
79
|
+
exec("git", [...base, "--stat"], { cwd, timeout: 10_000 }),
|
|
80
|
+
exec("git", base, { cwd, timeout: 10_000 }),
|
|
73
81
|
]);
|
|
74
82
|
if (stat.code !== 0 || diff.code !== 0 || diff.stdout.trim() === "") return undefined;
|
|
75
83
|
return { stat: stat.stdout, diff: diff.stdout };
|
|
@@ -83,8 +91,9 @@ async function jevNeeds(
|
|
|
83
91
|
ctx: ExtensionContext,
|
|
84
92
|
message: string,
|
|
85
93
|
bodyPresent: boolean,
|
|
94
|
+
command: string,
|
|
86
95
|
): Promise<Need[]> {
|
|
87
|
-
const pending = await pendingDiff(deps.exec, ctx.cwd);
|
|
96
|
+
const pending = await pendingDiff(deps.exec, ctx.cwd, command);
|
|
88
97
|
if (pending === undefined) return [];
|
|
89
98
|
const judged = await judgeCommit(deps.jev(ctx), {
|
|
90
99
|
message,
|
|
@@ -131,10 +140,11 @@ async function needsOf(
|
|
|
131
140
|
deps: CommitGuardDeps,
|
|
132
141
|
ctx: ExtensionContext,
|
|
133
142
|
message: string,
|
|
143
|
+
command: string,
|
|
134
144
|
): Promise<Need[]> {
|
|
135
145
|
const needs = messageNeeds(message);
|
|
136
146
|
const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
|
|
137
|
-
const viaJev = await jevNeeds(deps, ctx, message, bodyPresent);
|
|
147
|
+
const viaJev = await jevNeeds(deps, ctx, message, bodyPresent, command);
|
|
138
148
|
return [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
|
|
139
149
|
}
|
|
140
150
|
|
|
@@ -160,7 +170,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
|
160
170
|
});
|
|
161
171
|
}
|
|
162
172
|
for (const message of known) {
|
|
163
|
-
for (const need of await needsOf(deps, ctx, message)) {
|
|
173
|
+
for (const need of await needsOf(deps, ctx, message, command)) {
|
|
164
174
|
if (!all.some((n) => n.gate === need.gate)) all.push(need);
|
|
165
175
|
}
|
|
166
176
|
}
|
package/src/gates/push-guard.ts
CHANGED
|
@@ -55,7 +55,8 @@ async function pushesTrunk(
|
|
|
55
55
|
for (const t of targets) {
|
|
56
56
|
if (t.allBranches || t.branches.includes(trunk)) return true;
|
|
57
57
|
const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
|
|
58
|
-
if (t.
|
|
58
|
+
if (t.tagsOnly) continue;
|
|
59
|
+
if (t.branches.length === 0 || t.usesHead) {
|
|
59
60
|
// No refspec: the current branch decides. When it cannot be read, assume it is the trunk.
|
|
60
61
|
const branch = await currentBranch(exec, where);
|
|
61
62
|
if (branch === undefined || branch === trunk) return true;
|
|
@@ -65,7 +66,7 @@ async function pushesTrunk(
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** Whether the unpushed work is a `fix` commit whose diff Jev reads as only repairing the red build. */
|
|
68
|
-
async function
|
|
69
|
+
async function judgeRepair(
|
|
69
70
|
deps: PushGuardDeps,
|
|
70
71
|
ctx: ExtensionContext,
|
|
71
72
|
config: DevsysConfig,
|
|
@@ -93,6 +94,20 @@ async function repairsRedTrunk(
|
|
|
93
94
|
return judged.ok && judged.value >= FIX_RELATED_THRESHOLD;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** Fails closed: any exec failure means the push is not shown to repair the red build. */
|
|
98
|
+
async function repairsRedTrunk(
|
|
99
|
+
deps: PushGuardDeps,
|
|
100
|
+
ctx: ExtensionContext,
|
|
101
|
+
config: DevsysConfig,
|
|
102
|
+
runId: number | undefined,
|
|
103
|
+
): Promise<boolean> {
|
|
104
|
+
try {
|
|
105
|
+
return await judgeRepair(deps, ctx, config, runId);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
type HardStop = {
|
|
97
112
|
readonly ctx: ExtensionContext;
|
|
98
113
|
readonly toolCallId: string;
|
package/src/state/ci-watch.ts
CHANGED
|
@@ -23,7 +23,11 @@ export async function watchCi(options: WatchOptions): Promise<CiState> {
|
|
|
23
23
|
branch: options.branch,
|
|
24
24
|
cwd: options.cwd,
|
|
25
25
|
});
|
|
26
|
-
|
|
26
|
+
// `gh` missing or no runs at all (no sha) is unknown, not a run that has yet to appear.
|
|
27
|
+
const stale =
|
|
28
|
+
options.expectSha !== undefined &&
|
|
29
|
+
trunk.headSha !== undefined &&
|
|
30
|
+
trunk.headSha !== options.expectSha;
|
|
27
31
|
const status = stale ? "pending" : trunk.status;
|
|
28
32
|
last = trunk.headSha === undefined ? { status } : { status, sha: trunk.headSha };
|
|
29
33
|
options.onObserved(last);
|