@jwilger/pi-development-system 0.20.0 → 0.22.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.
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
4
4
  import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
5
5
  import { applyPromptSection } from "../src/context/system-prompt.ts";
6
+ import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
6
7
  import { detectProfiles } from "../src/core/profile.ts";
7
8
  import { createApprovalStore } from "../src/gates/approvals.ts";
8
9
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
@@ -77,24 +78,26 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
77
78
  return messages === undefined ? undefined : { messages };
78
79
  });
79
80
 
81
+ const exec: Exec = async (command, args, options) =>
82
+ timeoutAsFailure(await pi.exec(command, args, options));
80
83
  registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
81
84
  registerCommitGuard({
82
85
  pi,
83
86
  state,
84
87
  jev: (ctx) => jevHolder.forContext(ctx),
85
- exec: (command, args, options) => pi.exec(command, args, options),
88
+ exec,
86
89
  });
87
90
  registerCiCommand({
88
91
  pi,
89
92
  state,
90
- exec: (command, args, options) => pi.exec(command, args, options),
93
+ exec,
91
94
  });
92
95
  registerPushGuard({
93
96
  pi,
94
97
  state,
95
98
  approvals,
96
99
  jev: (ctx) => jevHolder.forContext(ctx),
97
- exec: (command, args, options) => pi.exec(command, args, options),
100
+ exec,
98
101
  });
99
102
  registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
100
103
  registerTestEvidence({ pi, state });
@@ -107,8 +110,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
107
110
  const reviewDeps = {
108
111
  state,
109
112
  jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
110
- exec: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) =>
111
- pi.exec(command, args, options),
113
+ exec,
112
114
  } satisfies Parameters<typeof createReviewStartTool>[0];
113
115
  pi.registerTool(createReviewStartTool(reviewDeps));
114
116
  pi.registerTool(createReviewRecordTool(reviewDeps));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -50,8 +50,8 @@ The verdict must agree with the findings.
50
50
  - **false-positive**: refuted; ignored.
51
51
 
52
52
  Jev may raise a finding's severity when it is at least 0.8 confident; it never
53
- lowers a blocking or should-fix finding (it only suggests, in the reply, and you
54
- re-check it yourself). A round is clean when it has no blocking or should-fix findings.
53
+ lowers a finding to a severity that does not count (it only suggests, in the
54
+ reply, and you re-check it yourself). A round is clean when it has no blocking or should-fix findings.
55
55
  The default is three consecutive clean rounds (`review.required_clean_rounds`).
56
56
  Only real findings reset the count; a changed diff alone does not, so a small fix
57
57
  commit keeps the clean rounds already earned unless findings come back.
package/src/core/exec.ts CHANGED
@@ -4,3 +4,24 @@ export type Exec = (
4
4
  args: string[],
5
5
  options?: { cwd?: string; timeout?: number },
6
6
  ) => Promise<{ code: number; stdout: string; stderr: string }>;
7
+
8
+ /** Exit code reported for a command that was killed for running too long (as `timeout(1)` does). */
9
+ export const TIMEOUT_CODE = 124;
10
+
11
+ /**
12
+ * pi reports a killed command as `code: 0, killed: true`, so a timed-out `git diff` would look like an
13
+ * empty diff. A guard must never read that as "nothing changed": make the kill a failure.
14
+ */
15
+ export const timeoutAsFailure = (result: {
16
+ code: number;
17
+ stdout: string;
18
+ stderr: string;
19
+ killed?: boolean;
20
+ }): { code: number; stdout: string; stderr: string } =>
21
+ result.killed === true
22
+ ? {
23
+ code: TIMEOUT_CODE,
24
+ stdout: result.stdout,
25
+ stderr: `${result.stderr}\ntimed out`.trim(),
26
+ }
27
+ : { code: result.code, stdout: result.stdout, stderr: result.stderr };
@@ -56,6 +56,12 @@ export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): st
56
56
  }
57
57
  }
58
58
 
59
+ const ESCAPES: Record<string, string> = { t: "\t", n: "\n", '"': '"', "\\": "\\" };
60
+
61
+ /** Undo git's C-style quoting of a path (`\"`, `\\`, `\t`, `\n`; octal bytes were already turned off with quotePath=false). */
62
+ const unquote = (path: string): string =>
63
+ path.replace(/\\([tn"\\])/g, (_m, c: string) => ESCAPES[c] ?? c);
64
+
59
65
  /** A unified diff cut into one text per file, keyed by the file's new path. */
60
66
  export function splitDiffByFile(diff: string): Record<string, string> {
61
67
  const out: Record<string, string> = {};
@@ -69,7 +75,7 @@ export function splitDiffByFile(diff: string): Record<string, string> {
69
75
  const header = /^diff --git "?a\/.*?"? "?b\/(.*?)"?$/.exec(line);
70
76
  if (header !== null) {
71
77
  flush();
72
- path = header[1] ?? line;
78
+ path = unquote(header[1] ?? line);
73
79
  lines = [];
74
80
  }
75
81
  lines.push(line);
@@ -13,6 +13,7 @@ import {
13
13
  parseConventionalCommit,
14
14
  } from "../core/commit-message.ts";
15
15
  import type { Exec } from "../core/exec.ts";
16
+ import { resolveGit } from "../core/git-invocations.ts";
16
17
  import { reviewGap } from "../core/review-flow.ts";
17
18
  import { type GateId, isParseError, parseGateId } from "../core/types.ts";
18
19
  import type { Jev } from "../jev/client.ts";
@@ -119,7 +120,11 @@ async function jevNeeds(
119
120
  }
120
121
 
121
122
  /** While work is in flight, committing needs the slice review satisfied on the diff as it is now. */
122
- async function reviewNeeds(deps: CommitGuardDeps, ctx: ExtensionContext): Promise<Need[]> {
123
+ async function reviewNeeds(
124
+ deps: CommitGuardDeps,
125
+ ctx: ExtensionContext,
126
+ command: string,
127
+ ): Promise<Need[]> {
123
128
  const { phase, activeSlice } = deps.state.get();
124
129
  if ((phase !== "implementing" && phase !== "reviewing") || activeSlice === undefined) return [];
125
130
  const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
@@ -129,7 +134,69 @@ async function reviewNeeds(deps: CommitGuardDeps, ctx: ExtensionContext): Promis
129
134
  activeSlice,
130
135
  snap.ok ? { digest: snap.value.digest, files: snap.value.files } : { digest: "unknown" },
131
136
  );
132
- return gap === undefined ? [] : [{ gate: REVIEW, why: gap }];
137
+ if (gap !== undefined) return [{ gate: REVIEW, why: gap }];
138
+ // The review saw the work tree; a plain `git commit` records the index. A file staged and then edited
139
+ // again would commit the older, unreviewed content.
140
+ const stale = stagedThenEdited(await changedNames(deps.exec, ctx.cwd), command);
141
+ return stale.length === 0
142
+ ? []
143
+ : [
144
+ {
145
+ gate: REVIEW,
146
+ why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add\`); stage it again`,
147
+ },
148
+ ];
149
+ }
150
+
151
+ /** Files staged and also changed since staging; empty when the commit stages everything itself. */
152
+ const stagedThenEdited = (
153
+ names: { staged: readonly string[]; unstaged: readonly string[] } | undefined,
154
+ command: string,
155
+ ): string[] =>
156
+ names === undefined || stagesAllTracked(command)
157
+ ? []
158
+ : // A path named in the command is committed (or re-staged) from the work tree, which was reviewed.
159
+ names.staged.filter((name) => names.unstaged.includes(name) && !command.includes(name));
160
+
161
+ const STAGE_ALL = new Set(["-A", "--all", "-u", "--update", "."]);
162
+
163
+ /** The command re-stages every tracked change itself: `commit -a`, or `git add -A|-u|.`. Read from parsed invocations, not message text. */
164
+ function stagesAllTracked(command: string): boolean {
165
+ return resolveGit(command).invocations.some(
166
+ (i) =>
167
+ (i.sub === "commit" &&
168
+ i.args.some(
169
+ (a) =>
170
+ a === "--all" || a === "--include" || (/^-[a-zA-Z]{1,4}$/.test(a) && a.includes("a")),
171
+ )) ||
172
+ (i.sub === "add" && i.args.some((a) => STAGE_ALL.has(a))),
173
+ );
174
+ }
175
+
176
+ async function changedNames(
177
+ exec: Exec,
178
+ cwd: string,
179
+ ): Promise<{ staged: string[]; unstaged: string[] } | undefined> {
180
+ try {
181
+ const base = [
182
+ "-c",
183
+ "core.quotePath=false",
184
+ "diff",
185
+ "--no-ext-diff",
186
+ "--name-only",
187
+ "-z",
188
+ "--ignore-submodules=dirty",
189
+ ];
190
+ const [staged, unstaged] = await Promise.all([
191
+ exec("git", [...base, "--cached"], { cwd, timeout: 10_000 }),
192
+ exec("git", base, { cwd, timeout: 10_000 }),
193
+ ]);
194
+ if (staged.code !== 0 || unstaged.code !== 0) return undefined;
195
+ const split = (text: string) => text.split("\0").filter((n) => n !== "");
196
+ return { staged: split(staged.stdout), unstaged: split(unstaged.stdout) };
197
+ } catch {
198
+ return undefined;
199
+ }
133
200
  }
134
201
 
135
202
  const reviewReason = (need: Need): string =>
@@ -187,7 +254,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
187
254
  if (forbidden.length > 0) return { block: true, reason: forbiddenReason(forbidden) };
188
255
 
189
256
  const known = messages.flatMap((m) => (m === undefined ? [] : [m]));
190
- const all: Need[] = await reviewNeeds(deps, ctx);
257
+ const all: Need[] = await reviewNeeds(deps, ctx, command);
191
258
  // A -F file that cannot be read now (written by this same command, or stdin) cannot be checked.
192
259
  if (extractions.some((e, i) => e.kind === "file" && messages[i] === undefined)) {
193
260
  all.push({
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lstat, readlink } from "node:fs/promises";
3
+ import { join } from "node:path";
2
4
  import type { Exec } from "../core/exec.ts";
3
5
  import { err, ok, type Result } from "../core/result.ts";
4
6
  import { splitDiffByFile } from "../core/review-flow.ts";
@@ -35,13 +37,18 @@ export async function snapshotDiff(
35
37
  const [diff, stat, others] = await Promise.all([
36
38
  exec("git", [...plain, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
37
39
  exec("git", [...plain, "--stat", range], { cwd, timeout: 15_000 }),
38
- range === "HEAD"
39
- ? exec("git", ["ls-files", "--others", "--exclude-standard"], { cwd, timeout: 15_000 })
40
+ // Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
41
+ !range.includes("..")
42
+ ? exec(
43
+ "git",
44
+ ["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"],
45
+ { cwd, timeout: 15_000 },
46
+ )
40
47
  : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
41
48
  ]);
42
49
  if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
43
50
  if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
44
- const listed = others.stdout.split("\n").filter((p) => p !== "");
51
+ const listed = others.stdout.split("\0").filter((p) => p !== "");
45
52
  // A nested repository is listed as `dir/` and has no blob to hash.
46
53
  const nested = listed.filter((p) => p.endsWith("/"));
47
54
  const untracked = listed.filter((p) => !p.endsWith("/"));
@@ -58,15 +65,27 @@ export async function snapshotDiff(
58
65
  return err("could not read the diff: no per-file sections were found");
59
66
  }
60
67
  if (untracked.length > 0) {
61
- const hashes = await exec("git", ["hash-object", "--", ...untracked], {
62
- cwd,
63
- timeout: 15_000,
64
- });
65
- const lines = hashes.stdout.split("\n").filter((l) => l !== "");
66
- if (hashes.code !== 0 || lines.length !== untracked.length) {
67
- return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
68
+ const links = new Set<string>();
69
+ for (const path of untracked) {
70
+ const target = await linkTarget(cwd, path);
71
+ if (target !== undefined) {
72
+ files[path] = `new:${blobOf(target)}`;
73
+ links.add(path);
74
+ }
75
+ }
76
+ // `git hash-object <path>` follows symlinks (and fails on dangling ones), so links are hashed above.
77
+ const regular = untracked.filter((p) => !links.has(p));
78
+ if (regular.length > 0) {
79
+ const hashes = await exec("git", ["hash-object", "--", ...regular], {
80
+ cwd,
81
+ timeout: 15_000,
82
+ });
83
+ const lines = hashes.stdout.split("\n").filter((l) => l !== "");
84
+ if (hashes.code !== 0 || lines.length !== regular.length) {
85
+ return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
86
+ }
87
+ for (const [i, path] of regular.entries()) files[path] = `new:${lines[i]}`;
68
88
  }
69
- for (const [i, path] of untracked.entries()) files[path] = `new:${lines[i]}`;
70
89
  }
71
90
  const names = Object.keys(files).sort();
72
91
  const untrackedStat = [
@@ -91,3 +110,20 @@ const fileDigest = (section: string): string => {
91
110
  const blob = NEW_FILE.exec(section)?.[1];
92
111
  return blob === undefined ? digestOf(section) : `new:${blob}`;
93
112
  };
113
+
114
+ /** The text of a symlink, or undefined when `path` is not one (or cannot be inspected). */
115
+ async function linkTarget(cwd: string, path: string): Promise<string | undefined> {
116
+ try {
117
+ const full = join(cwd, path);
118
+ return (await lstat(full)).isSymbolicLink() ? await readlink(full) : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /** Git's blob id for some content: what `git add` stores for a symlink is its target text. */
125
+ const blobOf = (content: string): string =>
126
+ createHash("sha1")
127
+ .update(`blob ${Buffer.byteLength(content)}\0`)
128
+ .update(content)
129
+ .digest("hex");