@jwilger/pi-development-system 0.21.0 → 0.23.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.21.0",
3
+ "version": "0.23.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"
package/src/core/exec.ts CHANGED
@@ -4,3 +4,26 @@ 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
+ * Limit: a process killed from outside (e.g. the OOM killer) also reads as `code: 0` with `killed` unset;
15
+ * pi's API does not expose that, so it cannot be detected here.
16
+ */
17
+ export const timeoutAsFailure = (result: {
18
+ code: number;
19
+ stdout: string;
20
+ stderr: string;
21
+ killed?: boolean;
22
+ }): { code: number; stdout: string; stderr: string } =>
23
+ result.killed === true
24
+ ? {
25
+ code: TIMEOUT_CODE,
26
+ stdout: result.stdout,
27
+ stderr: `${result.stderr}\ntimed out`.trim(),
28
+ }
29
+ : { 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";
@@ -136,32 +137,84 @@ async function reviewNeeds(
136
137
  if (gap !== undefined) return [{ gate: REVIEW, why: gap }];
137
138
  // The review saw the work tree; a plain `git commit` records the index. A file staged and then edited
138
139
  // again would commit the older, unreviewed content.
139
- const stale = stagedThenEdited(await changedNames(deps.exec, ctx.cwd), command);
140
+ const names = await changedNames(deps.exec, ctx.cwd);
141
+ if (names === undefined) {
142
+ return [
143
+ {
144
+ gate: REVIEW,
145
+ why: "cannot read which files are staged or edited, so the staged content cannot be compared with the reviewed work tree",
146
+ },
147
+ ];
148
+ }
149
+ const stale = stagedThenEdited(names, command);
140
150
  return stale.length === 0
141
151
  ? []
142
152
  : [
143
153
  {
144
154
  gate: REVIEW,
145
- why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add\`); stage it again`,
155
+ why: `the staged content of ${stale.join(", ")} differs from the work tree that was reviewed (edited after \`git add\`, or only partly staged); stage it again, or depart if committing in parts is deliberate`,
146
156
  },
147
157
  ];
148
158
  }
149
159
 
150
160
  /** Files staged and also changed since staging; empty when the commit stages everything itself. */
151
161
  const stagedThenEdited = (
152
- names: { staged: readonly string[]; unstaged: readonly string[] } | undefined,
162
+ names: { staged: readonly string[]; unstaged: readonly string[] },
153
163
  command: string,
154
- ): string[] =>
155
- names === undefined || stagesEverything(command)
156
- ? []
157
- : names.staged.filter((name) => names.unstaged.includes(name));
164
+ ): string[] => {
165
+ const invocations = resolveGit(command).invocations;
166
+ if (stagesAllTracked(invocations)) return [];
167
+ // A path that is an argument of the commit or add itself is committed (or re-staged) from the work tree,
168
+ // which was reviewed. Message text is one argument and never equals a path.
169
+ const named = new Set(
170
+ invocations.filter((i) => i.sub === "commit" || i.sub === "add").flatMap((i) => i.args),
171
+ );
172
+ return names.staged.filter((name) => names.unstaged.includes(name) && !named.has(name));
173
+ };
174
+
175
+ const STAGE_ALL = new Set(["-A", "--all", "-u", "--update", "."]);
176
+ const BOOLEAN_SHORT = /[enqsvz]/;
177
+
178
+ /** `-a` inside a short-flag cluster such as `-sa`; a value-taking letter ends the cluster (`-mtab`, `-uall`). */
179
+ function clusterHasA(arg: string): boolean {
180
+ if (!/^-[a-zA-Z]+$/.test(arg)) return false;
181
+ for (const ch of arg.slice(1)) {
182
+ if (ch === "a") return true;
183
+ if (!BOOLEAN_SHORT.test(ch)) return false;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ /**
189
+ * The command re-stages every tracked change of this repository before it commits: `commit -a`, or
190
+ * `git add -A|-u|.` with no pathspec, in the same directory, ahead of the commit. Read from parsed
191
+ * invocations, not message text.
192
+ */
193
+ function stagesAllTracked(invocations: ReturnType<typeof resolveGit>["invocations"]): boolean {
194
+ const firstCommit = invocations.findIndex((i) => i.sub === "commit");
195
+ return invocations.some((i, index) => {
196
+ if (i.dir !== undefined) return false;
197
+ if (i.sub === "commit") return i.args.some((a) => a === "--all" || clusterHasA(a));
198
+ if (i.sub !== "add" || (firstCommit !== -1 && index > firstCommit)) return false;
199
+ const paths = i.args.filter((a) => !a.startsWith("-"));
200
+ return i.args.some((a) => STAGE_ALL.has(a)) && paths.every((a) => a === ".");
201
+ });
202
+ }
158
203
 
159
204
  async function changedNames(
160
205
  exec: Exec,
161
206
  cwd: string,
162
207
  ): Promise<{ staged: string[]; unstaged: string[] } | undefined> {
163
208
  try {
164
- const base = ["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--name-only", "-z"];
209
+ const base = [
210
+ "-c",
211
+ "core.quotePath=false",
212
+ "diff",
213
+ "--no-ext-diff",
214
+ "--name-only",
215
+ "-z",
216
+ "--ignore-submodules=dirty",
217
+ ];
165
218
  const [staged, unstaged] = await Promise.all([
166
219
  exec("git", [...base, "--cached"], { cwd, timeout: 10_000 }),
167
220
  exec("git", base, { cwd, timeout: 10_000 }),