@jwilger/pi-development-system 0.21.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.21.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"
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";
@@ -152,16 +153,40 @@ const stagedThenEdited = (
152
153
  names: { staged: readonly string[]; unstaged: readonly string[] } | undefined,
153
154
  command: string,
154
155
  ): string[] =>
155
- names === undefined || stagesEverything(command)
156
+ names === undefined || stagesAllTracked(command)
156
157
  ? []
157
- : names.staged.filter((name) => names.unstaged.includes(name));
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
+ }
158
175
 
159
176
  async function changedNames(
160
177
  exec: Exec,
161
178
  cwd: string,
162
179
  ): Promise<{ staged: string[]; unstaged: string[] } | undefined> {
163
180
  try {
164
- const base = ["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--name-only", "-z"];
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
+ ];
165
190
  const [staged, unstaged] = await Promise.all([
166
191
  exec("git", [...base, "--cached"], { cwd, timeout: 10_000 }),
167
192
  exec("git", base, { cwd, timeout: 10_000 }),