@ilya-lesikov/pi-pi 0.4.0 → 0.6.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.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +73 -31
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1191 -360
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2631 -392
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -1,17 +1,27 @@
1
1
  import { unregisterAgentDefinitions } from "./agents/registry.js";
2
- import { runAfterImplement } from "./commands.js";
2
+ import { loadRepoAfterImplementCommands, runAfterImplement } from "./commands.js";
3
+ import { resolvePreset } from "./config.js";
3
4
  import { nextPhase, validateExitCriteria } from "./phases/machine.js";
4
5
  import { spawnPlanners } from "./phases/planning.js";
5
6
  import { Orchestrator } from "./orchestrator.js";
6
- import { saveTask } from "./state.js";
7
+ import { groupFilesByRepo } from "./repo-utils.js";
8
+ import { getEffectiveMode, saveTask } from "./state.js";
9
+ import { getLogger } from "./log.js";
10
+ import { handleSpawnResult } from "./spawn-cleanup.js";
11
+
12
+ function isEnabled(value: { enabled?: boolean }): boolean {
13
+ return value.enabled !== false;
14
+ }
7
15
 
8
16
  export async function transitionToNextPhase(
9
17
  orchestrator: Orchestrator,
10
18
  ctx: any,
19
+ plannerPreset?: string,
11
20
  ): Promise<{ ok: boolean; error?: string }> {
12
21
  if (!orchestrator.active) return { ok: false, error: "No active task." };
13
-
22
+ const log = getLogger();
14
23
  const currentPhase = orchestrator.active.state.phase;
24
+ log.info({ s: "phase", from: currentPhase, plannerPreset: plannerPreset ?? null }, "transition requested");
15
25
  if (currentPhase === "done") return { ok: false, error: "Task is already done." };
16
26
 
17
27
  const exitCheck = validateExitCriteria(orchestrator.active.dir, orchestrator.active.type, currentPhase);
@@ -30,7 +40,26 @@ export async function transitionToNextPhase(
30
40
  if (!next) return { ok: false, error: "No next phase available." };
31
41
 
32
42
  if (currentPhase === "implement") {
33
- const afterResults = runAfterImplement(orchestrator.config, orchestrator.cwd);
43
+ const repos = orchestrator.active.state.repos ?? [];
44
+ const grouped = groupFilesByRepo(repos, [...orchestrator.active.modifiedFiles]);
45
+ const afterResults: Array<{ ok: boolean; command: string; output: string }> = [];
46
+ for (const [repoPath] of grouped) {
47
+ if (!repoPath) continue;
48
+ const repo = repos.find((r) => r.path === repoPath);
49
+ if (!repo) continue;
50
+ if (repo.isRoot) {
51
+ afterResults.push(...runAfterImplement(
52
+ orchestrator.config.commands.afterImplement,
53
+ orchestrator.config.performance.commands.afterImplement,
54
+ orchestrator.cwd,
55
+ ));
56
+ continue;
57
+ }
58
+ if (!orchestrator.config.general.loadExtraRepoConfigs) continue;
59
+ const extraCommands = loadRepoAfterImplementCommands(repoPath);
60
+ if (!extraCommands || Object.keys(extraCommands).length === 0) continue;
61
+ afterResults.push(...runAfterImplement(extraCommands, orchestrator.config.performance.commands.afterImplement, repoPath));
62
+ }
34
63
  const failures = afterResults.filter((r) => !r.ok);
35
64
  if (failures.length > 0) {
36
65
  const failureText = failures.map((f) => `${f.command}: ${f.output}`).join("\n");
@@ -39,13 +68,17 @@ export async function transitionToNextPhase(
39
68
  }
40
69
 
41
70
  orchestrator.active.state.phase = next;
42
- if (next !== "done") {
43
- orchestrator.active.state.reviewCycle = null;
44
- orchestrator.active.state.reviewPass = 0;
45
- orchestrator.active.state.reviewPassByKind = {};
46
- orchestrator.active.reviewPass = 0;
47
- }
71
+ orchestrator.active.state.reviewCycle = null;
72
+ orchestrator.active.state.reviewPass = 0;
73
+ orchestrator.active.state.reviewApprovedClean = false;
74
+ orchestrator.active.reviewPass = 0;
48
75
  if (next === "plan") {
76
+ const autonomousPlannerPreset =
77
+ getEffectiveMode(orchestrator.active.state) === "autonomous"
78
+ ? orchestrator.active.state.autonomousConfig?.phases.plan?.plannerPreset
79
+ : undefined;
80
+ orchestrator.active.state.activePlannerPreset =
81
+ plannerPreset ?? autonomousPlannerPreset ?? orchestrator.config.agents.subagents.presetGroups.planners.default;
49
82
  orchestrator.active.state.step = "spawn_planners";
50
83
  } else if (next === "implement") {
51
84
  orchestrator.active.state.step = "llm_work";
@@ -59,49 +92,58 @@ export async function transitionToNextPhase(
59
92
  if (next === "done") {
60
93
  const name = orchestrator.active.description;
61
94
  const type = orchestrator.active.type;
62
- orchestrator.taskDoneCompactionPending = true;
63
- orchestrator.taskDoneCompactionSummary = `Task "${name}" (${type}) completed.`;
64
95
 
65
96
  orchestrator.abortAllSubagents();
66
97
  unregisterAgentDefinitions(orchestrator.pi);
67
98
  await orchestrator.cleanupActive();
68
99
  orchestrator.updateStatus(ctx);
69
- ctx.compact();
100
+ orchestrator.lastCtx = ctx;
101
+ // Route the task-done compaction through the controller as a "done" target.
102
+ void orchestrator.transitionController.requestTransition({
103
+ kind: "done",
104
+ summary: `Task "${name}" (${type}) completed.`,
105
+ });
70
106
  ctx.ui.notify("Task completed!", "info");
71
107
  return { ok: true };
72
108
  }
73
109
 
74
110
  orchestrator.updateStatus(ctx);
111
+ const phaseSummary = `Phase "${currentPhase}" completed. Now entering "${next}" phase.`;
75
112
 
76
113
  if (next === "plan") {
77
114
  orchestrator.active.state.step = "await_planners";
78
115
  saveTask(orchestrator.active.dir, orchestrator.active.state);
79
116
  }
80
117
 
81
- orchestrator.compactAndTransition(ctx, orchestrator.active.dir, orchestrator.active.state.phase);
82
-
83
- if (next === "plan") {
84
- orchestrator.pendingSubagentSpawns = Object.values(orchestrator.config.planners).filter((v) => v.enabled).length;
118
+ const onReady = next === "plan" ? () => {
119
+ if (!orchestrator.active) return;
120
+ const plannerVariants = resolvePreset(orchestrator.config, "planners", orchestrator.active.state.activePlannerPreset);
121
+ orchestrator.pendingSubagentSpawns = Object.values(plannerVariants).filter((v) => isEnabled(v)).length;
85
122
  orchestrator.failedPlannerVariants = [];
86
- spawnPlanners(orchestrator.pi, orchestrator.cwd, orchestrator.active.dir, orchestrator.active.taskId, orchestrator.config).then((result) => {
87
- orchestrator.failedPlannerVariants = result.failedVariants;
88
- if (result.spawned === 0) orchestrator.pendingSubagentSpawns = 0;
89
- for (const id of result.agentIds ?? []) {
90
- orchestrator.spawnedAgentIds.delete(id);
91
- }
92
- orchestrator.pendingSubagentSpawns = 0;
93
- }).catch((err) => {
94
- orchestrator.pendingSubagentSpawns = 0;
95
- console.error(`[pi-pi] spawnPlanners failed: ${err.message}`);
96
- });
97
- }
123
+ handleSpawnResult(
124
+ orchestrator,
125
+ spawnPlanners(
126
+ orchestrator.pi,
127
+ orchestrator.cwd,
128
+ orchestrator.active.dir,
129
+ orchestrator.active.taskId,
130
+ orchestrator.config,
131
+ orchestrator.transitionController.phaseSend,
132
+ plannerVariants,
133
+ orchestrator.active?.state.repos ?? [],
134
+ ),
135
+ { kind: "planner", logScope: "planner", logMessage: "spawnPlanners failed", onSettled: (result) => { if (!result?.spawned) orchestrator.checkPlannerCompletion(); } },
136
+ );
137
+ } : undefined;
138
+
139
+ orchestrator.compactAndTransition(ctx, orchestrator.active.dir, orchestrator.active.state.phase, onReady, phaseSummary);
98
140
 
99
141
  return { ok: true };
100
142
  }
101
143
 
102
144
  export function registerCommandHandlers(orchestrator: Orchestrator): void {
103
145
  const pi = orchestrator.pi;
104
- orchestrator.transitionToNextPhase = (ctx: any) => transitionToNextPhase(orchestrator, ctx);
146
+ orchestrator.transitionToNextPhase = (ctx: any, plannerPreset?: string) => transitionToNextPhase(orchestrator, ctx, plannerPreset);
105
147
 
106
148
  pi.registerCommand("pp", {
107
149
  description: "Open pi-pi task menu",
@@ -109,7 +151,7 @@ export function registerCommandHandlers(orchestrator: Orchestrator): void {
109
151
  const { showPpMenu } = await import("./pp-menu.js");
110
152
  const text = await showPpMenu(orchestrator, ctx, "command");
111
153
  if (text) {
112
- pi.sendUserMessage(`[PI-PI] ${text}`, { deliverAs: "followUp" });
154
+ orchestrator.safeSendUserMessage(`[PI-PI] ${text}`);
113
155
  }
114
156
  },
115
157
  });
@@ -1,9 +1,15 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
3
  import { join } from "path";
4
4
  import { execFileSync } from "child_process";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
- import { autoCommit } from "./commands.js";
6
+ import {
7
+ autoCommit,
8
+ loadRepoAfterEditCommands,
9
+ loadRepoAfterImplementCommands,
10
+ runAfterEdit,
11
+ runAfterImplement,
12
+ } from "./commands.js";
7
13
 
8
14
  const tempDirs: string[] = [];
9
15
 
@@ -18,6 +24,12 @@ function makeRepo(): string {
18
24
  return cwd;
19
25
  }
20
26
 
27
+ function makeDir(): string {
28
+ const cwd = mkdtempSync(join(tmpdir(), "pi-pi-commands-"));
29
+ tempDirs.push(cwd);
30
+ return cwd;
31
+ }
32
+
21
33
  afterEach(() => {
22
34
  while (tempDirs.length > 0) {
23
35
  const dir = tempDirs.pop();
@@ -26,6 +38,14 @@ afterEach(() => {
26
38
  });
27
39
 
28
40
  describe("autoCommit", () => {
41
+ it("returns ok when there are no files", () => {
42
+ const cwd = makeRepo();
43
+
44
+ const result = autoCommit([], "ignored", cwd);
45
+
46
+ expect(result).toEqual({ ok: true });
47
+ });
48
+
29
49
  it("falls back to checkpoint when message is empty", () => {
30
50
  const cwd = makeRepo();
31
51
  const file = "a.ts";
@@ -40,6 +60,29 @@ describe("autoCommit", () => {
40
60
  expect(subject).toBe("checkpoint");
41
61
  });
42
62
 
63
+ it("extracts the commit hash on a normal branch", () => {
64
+ const cwd = makeRepo();
65
+ writeFileSync(join(cwd, "h.ts"), "export const h = 1;\n", "utf-8");
66
+
67
+ const result = autoCommit(["h.ts"], "first", cwd);
68
+
69
+ expect(result.ok).toBe(true);
70
+ expect(result.commitHash).toMatch(/^[a-f0-9]{7,}$/);
71
+ });
72
+
73
+ it("extracts the commit hash in detached HEAD state", () => {
74
+ const cwd = makeRepo();
75
+ writeFileSync(join(cwd, "base.ts"), "export const base = 1;\n", "utf-8");
76
+ autoCommit(["base.ts"], "base", cwd);
77
+ execFileSync("git", ["checkout", "--detach"], { cwd, stdio: "pipe" });
78
+
79
+ writeFileSync(join(cwd, "detached.ts"), "export const d = 1;\n", "utf-8");
80
+ const result = autoCommit(["detached.ts"], "on detached head", cwd);
81
+
82
+ expect(result.ok).toBe(true);
83
+ expect(result.commitHash).toMatch(/^[a-f0-9]{7,}$/);
84
+ });
85
+
43
86
  it("uses message as-is for commit", () => {
44
87
  const cwd = makeRepo();
45
88
  const file = "b.ts";
@@ -78,4 +121,214 @@ describe("autoCommit", () => {
78
121
  const committedFiles = execFileSync("git", ["show", "--name-only", "--pretty="], { cwd, encoding: "utf-8", stdio: "pipe" });
79
122
  expect(committedFiles.split("\n")).toContain(file);
80
123
  });
124
+
125
+ it("commits renamed files when new path is provided", () => {
126
+ const cwd = makeRepo();
127
+ writeFileSync(join(cwd, "old.ts"), "export const oldValue = 1;\n", "utf-8");
128
+ autoCommit(["old.ts"], "seed", cwd);
129
+
130
+ execFileSync("git", ["mv", "old.ts", "new.ts"], { cwd, stdio: "pipe" });
131
+
132
+ const result = autoCommit(["new.ts"], "rename old to new", cwd);
133
+
134
+ expect(result.ok).toBe(true);
135
+ const status = execFileSync("git", ["show", "--name-status", "--pretty="], { cwd, encoding: "utf-8", stdio: "pipe" });
136
+ expect(status).toContain("R100\told.ts\tnew.ts");
137
+ });
138
+
139
+ it("returns error when commit fails", () => {
140
+ const cwd = makeRepo();
141
+
142
+ const result = autoCommit(["missing.ts"], "should fail", cwd);
143
+
144
+ expect(result.ok).toBe(false);
145
+ expect(result.error).toBeTruthy();
146
+ });
147
+ });
148
+
149
+ describe("runAfterEdit", () => {
150
+ it("runs commands only when glob matches", () => {
151
+ const cwd = makeDir();
152
+ const outFile = join(cwd, "out.txt");
153
+ const commands = {
154
+ "cmd-1": { run: `node -e "require('fs').appendFileSync(process.argv[1], 'ts\\n')" ${JSON.stringify(outFile)}`, globs: ["**/*.ts"] },
155
+ "cmd-2": { run: `node -e "require('fs').appendFileSync(process.argv[1], 'js\\n')" ${JSON.stringify(outFile)}`, globs: ["**/*.js"] },
156
+ };
157
+
158
+ const results = runAfterEdit("src/file.ts", commands, 5000, cwd);
159
+
160
+ expect(results).toHaveLength(1);
161
+ expect(results[0]?.ok).toBe(true);
162
+ expect(results[0]?.command).toContain("appendFileSync");
163
+ const out = execFileSync("node", ["-e", `process.stdout.write(require('fs').readFileSync(${JSON.stringify(outFile)}, 'utf-8'))`], { encoding: "utf-8" });
164
+ expect(out).toBe("ts\n");
165
+ });
166
+
167
+ it("substitutes file and dir variables", () => {
168
+ const cwd = makeDir();
169
+ const commands = {
170
+ "cmd-1": { run: "node -e \"process.stdout.write(process.argv.slice(1).join('|'))\" ${file} ${dir}", globs: ["**/*.ts"] },
171
+ };
172
+
173
+ const results = runAfterEdit("src/nested/file.ts", commands, 5000, cwd);
174
+
175
+ expect(results).toHaveLength(1);
176
+ expect(results[0]?.ok).toBe(true);
177
+ expect(results[0]?.output).toBe("src/nested/file.ts|src/nested");
178
+ });
179
+
180
+ it("returns failure result on timeout", () => {
181
+ const cwd = makeDir();
182
+ const commands = { "cmd-1": { run: "node -e \"setTimeout(() => {}, 200)\"", globs: ["**/*.ts"] } };
183
+
184
+ const results = runAfterEdit("src/slow.ts", commands, 20, cwd);
185
+
186
+ expect(results).toHaveLength(1);
187
+ expect(results[0]?.ok).toBe(false);
188
+ expect(results[0]?.output.length).toBeGreaterThan(0);
189
+ });
190
+
191
+ it("propagates stderr output on failure", () => {
192
+ const cwd = makeDir();
193
+ const commands = { "cmd-1": { run: "node -e \"process.stderr.write('boom'); process.exit(2)\"", globs: ["**/*.ts"] } };
194
+
195
+ const results = runAfterEdit("src/fail.ts", commands, 5000, cwd);
196
+
197
+ expect(results).toHaveLength(1);
198
+ expect(results[0]?.ok).toBe(false);
199
+ expect(results[0]?.output).toContain("boom");
200
+ });
201
+
202
+ it("returns mixed results when some commands fail", () => {
203
+ const cwd = makeDir();
204
+ const commands = {
205
+ "cmd-1": { run: "node -e \"process.stdout.write('ok1')\"", globs: ["**/*.ts"] },
206
+ "cmd-2": { run: "node -e \"process.stderr.write('nope'); process.exit(1)\"", globs: ["**/*.ts"] },
207
+ "cmd-3": { run: "node -e \"process.stdout.write('ok2')\"", globs: ["**/*.ts"] },
208
+ };
209
+
210
+ const results = runAfterEdit("src/mixed.ts", commands, 5000, cwd);
211
+
212
+ expect(results).toHaveLength(3);
213
+ expect(results.map((r) => r.ok)).toEqual([true, false, true]);
214
+ expect(results[1]?.output).toContain("nope");
215
+ });
216
+ });
217
+
218
+ describe("runAfterImplement", () => {
219
+ it("runs commands and captures output", () => {
220
+ const cwd = makeDir();
221
+
222
+ const results = runAfterImplement({ "cmd-1": { run: "node -e \"process.stdout.write('done')\"" } }, 5000, cwd);
223
+
224
+ expect(results).toEqual([{ ok: true, command: "node -e \"process.stdout.write('done')\"", output: "done" }]);
225
+ });
226
+
227
+ it("propagates failure output", () => {
228
+ const cwd = makeDir();
229
+
230
+ const results = runAfterImplement({ "cmd-1": { run: "node -e \"process.stderr.write('broken'); process.exit(1)\"" } }, 5000, cwd);
231
+
232
+ expect(results).toHaveLength(1);
233
+ expect(results[0]?.ok).toBe(false);
234
+ expect(results[0]?.output).toContain("broken");
235
+ });
236
+ });
237
+
238
+ describe("loadRepoAfterEditCommands", () => {
239
+ it("loads commands from repo .pp/config.json", () => {
240
+ const cwd = makeDir();
241
+ mkdirSync(join(cwd, ".pp"), { recursive: true });
242
+ writeFileSync(
243
+ join(cwd, ".pp", "config.json"),
244
+ JSON.stringify({ commands: { afterEdit: { "cmd-1": { run: "npm test", globs: ["**/*.ts"] } } } }),
245
+ "utf-8",
246
+ );
247
+
248
+ const commands = loadRepoAfterEditCommands(cwd);
249
+
250
+ expect(commands).toEqual({ "cmd-1": { run: "npm test", globs: ["**/*.ts"] } });
251
+ });
252
+
253
+ it("returns null when config is missing", () => {
254
+ const cwd = makeDir();
255
+
256
+ const commands = loadRepoAfterEditCommands(cwd);
257
+
258
+ expect(commands).toBeNull();
259
+ });
260
+
261
+ it("filters invalid entries", () => {
262
+ const cwd = makeDir();
263
+ mkdirSync(join(cwd, ".pp"), { recursive: true });
264
+ writeFileSync(
265
+ join(cwd, ".pp", "config.json"),
266
+ JSON.stringify({
267
+ commands: {
268
+ afterEdit: {
269
+ badNull: null,
270
+ badEmpty: { run: "" },
271
+ badType: { run: 1 },
272
+ validOne: { run: "valid-one", globs: ["**/*.ts", 1, null] },
273
+ validTwo: { run: "valid-two" },
274
+ },
275
+ },
276
+ }),
277
+ "utf-8",
278
+ );
279
+
280
+ const commands = loadRepoAfterEditCommands(cwd);
281
+
282
+ expect(commands).toEqual({
283
+ validOne: { run: "valid-one", globs: ["**/*.ts"] },
284
+ validTwo: { run: "valid-two" },
285
+ });
286
+ });
287
+ });
288
+
289
+ describe("loadRepoAfterImplementCommands", () => {
290
+ it("loads commands from repo .pp/config.json", () => {
291
+ const cwd = makeDir();
292
+ mkdirSync(join(cwd, ".pp"), { recursive: true });
293
+ writeFileSync(
294
+ join(cwd, ".pp", "config.json"),
295
+ JSON.stringify({ commands: { afterImplement: { "cmd-1": { run: "npm run check" } } } }),
296
+ "utf-8",
297
+ );
298
+
299
+ const commands = loadRepoAfterImplementCommands(cwd);
300
+
301
+ expect(commands).toEqual({ "cmd-1": { run: "npm run check" } });
302
+ });
303
+
304
+ it("returns null when config is missing", () => {
305
+ const cwd = makeDir();
306
+
307
+ const commands = loadRepoAfterImplementCommands(cwd);
308
+
309
+ expect(commands).toBeNull();
310
+ });
311
+
312
+ it("filters invalid entries", () => {
313
+ const cwd = makeDir();
314
+ mkdirSync(join(cwd, ".pp"), { recursive: true });
315
+ writeFileSync(
316
+ join(cwd, ".pp", "config.json"),
317
+ JSON.stringify({
318
+ commands: {
319
+ afterImplement: {
320
+ badNull: null,
321
+ badEmpty: { run: "" },
322
+ badType: { run: 1 },
323
+ valid: { run: "valid" },
324
+ },
325
+ },
326
+ }),
327
+ "utf-8",
328
+ );
329
+
330
+ const commands = loadRepoAfterImplementCommands(cwd);
331
+
332
+ expect(commands).toEqual({ valid: { run: "valid" } });
333
+ });
81
334
  });
@@ -1,7 +1,8 @@
1
1
  import { execSync, execFileSync } from "child_process";
2
- import { dirname } from "path";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { dirname, join } from "path";
3
4
  import { minimatch } from "minimatch";
4
- import type { PiPiConfig, AfterEditCommand } from "./config.js";
5
+ import type { AfterEditCommandConfig, AfterImplementCommandConfig } from "./config.js";
5
6
 
6
7
  interface CommandResult {
7
8
  ok: boolean;
@@ -9,6 +10,10 @@ interface CommandResult {
9
10
  output: string;
10
11
  }
11
12
 
13
+ function isEnabled(command: { enabled?: boolean }): boolean {
14
+ return command.enabled !== false;
15
+ }
16
+
12
17
  function shellEscape(arg: string): string {
13
18
  return "'" + arg.replace(/'/g, "'\\''") + "'";
14
19
  }
@@ -22,12 +27,18 @@ function substituteVars(command: string, file?: string): string {
22
27
  return result;
23
28
  }
24
29
 
25
- export function runAfterEdit(file: string, config: PiPiConfig, cwd: string): CommandResult[] {
30
+ export function runAfterEdit(
31
+ file: string,
32
+ commands: Record<string, AfterEditCommandConfig>,
33
+ timeout: number,
34
+ cwd: string,
35
+ ): CommandResult[] {
26
36
  const results: CommandResult[] = [];
27
- const timeout = config.timeouts.afterEdit;
28
37
 
29
- for (const cmd of config.commands.afterEdit) {
30
- const matches = !cmd.glob || cmd.glob.length === 0 || cmd.glob.some((g) => minimatch(file, g, { matchBase: true }));
38
+ for (const cmd of Object.values(commands)) {
39
+ if (!isEnabled(cmd)) continue;
40
+ const globs = cmd.globs ?? [];
41
+ const matches = globs.length === 0 || globs.some((g) => minimatch(file, g, { matchBase: true }));
31
42
  if (!matches) continue;
32
43
 
33
44
  const command = substituteVars(cmd.run, file);
@@ -42,11 +53,20 @@ export function runAfterEdit(file: string, config: PiPiConfig, cwd: string): Com
42
53
  return results;
43
54
  }
44
55
 
45
- export function runAfterImplement(config: PiPiConfig, cwd: string): CommandResult[] {
56
+ // Executes shell commands from config via execSync. For the root repo these come
57
+ // from the user's own .pp/config.json; for sub-repos they are loaded only when
58
+ // general.loadExtraRepoConfigs is explicitly enabled (off by default), so running
59
+ // them is a deliberate, opt-in trust decision in the same security domain as the
60
+ // repos the user registered.
61
+ export function runAfterImplement(
62
+ commands: Record<string, AfterImplementCommandConfig>,
63
+ timeout: number,
64
+ cwd: string,
65
+ ): CommandResult[] {
46
66
  const results: CommandResult[] = [];
47
- const timeout = config.timeouts.afterImplement;
48
67
 
49
- for (const cmd of config.commands.afterImplement) {
68
+ for (const cmd of Object.values(commands)) {
69
+ if (!isEnabled(cmd)) continue;
50
70
  const command = substituteVars(cmd.run);
51
71
  try {
52
72
  const output = execSync(command, { cwd, encoding: "utf-8", timeout, stdio: "pipe" });
@@ -59,6 +79,84 @@ export function runAfterImplement(config: PiPiConfig, cwd: string): CommandResul
59
79
  return results;
60
80
  }
61
81
 
82
+ function loadRepoConfig(repoPath: string): Record<string, any> | null {
83
+ const configPath = join(repoPath, ".pp", "config.json");
84
+ if (!existsSync(configPath)) return null;
85
+ try {
86
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
87
+ return raw && typeof raw === "object" ? raw : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export function loadRepoAfterEditCommands(repoPath: string): Record<string, AfterEditCommandConfig> | null {
94
+ const raw = loadRepoConfig(repoPath);
95
+ if (!raw) return null;
96
+ const commands = raw?.commands?.afterEdit;
97
+ if (!commands || typeof commands !== "object") return null;
98
+
99
+ if (Array.isArray(commands)) {
100
+ const valid: Record<string, AfterEditCommandConfig> = {};
101
+ for (const [index, cmd] of commands.entries()) {
102
+ if (!cmd || typeof cmd !== "object") continue;
103
+ const run = (cmd as any).run;
104
+ if (typeof run !== "string" || run.length === 0) continue;
105
+ const globsRaw = (cmd as any).globs ?? (cmd as any).glob;
106
+ const globs = Array.isArray(globsRaw) ? globsRaw.filter((g): g is string => typeof g === "string") : undefined;
107
+ valid[`cmd-${index + 1}`] = { run, ...(globs && globs.length > 0 ? { globs } : {}) };
108
+ }
109
+ return Object.keys(valid).length > 0 ? valid : null;
110
+ }
111
+
112
+ const valid: Record<string, AfterEditCommandConfig> = {};
113
+ for (const [id, cmd] of Object.entries(commands as Record<string, unknown>)) {
114
+ if (!cmd || typeof cmd !== "object") continue;
115
+ const run = (cmd as any).run;
116
+ if (typeof run !== "string" || run.length === 0) continue;
117
+ const enabledRaw = (cmd as any).enabled;
118
+ const globsRaw = (cmd as any).globs ?? (cmd as any).glob;
119
+ const globs = Array.isArray(globsRaw) ? globsRaw.filter((g): g is string => typeof g === "string") : undefined;
120
+ valid[id] = {
121
+ run,
122
+ ...(globs && globs.length > 0 ? { globs } : {}),
123
+ ...(typeof enabledRaw === "boolean" ? { enabled: enabledRaw } : {}),
124
+ };
125
+ }
126
+ return valid;
127
+ }
128
+
129
+ export function loadRepoAfterImplementCommands(repoPath: string): Record<string, AfterImplementCommandConfig> | null {
130
+ const raw = loadRepoConfig(repoPath);
131
+ if (!raw) return null;
132
+ const commands = raw?.commands?.afterImplement;
133
+ if (!commands || typeof commands !== "object") return null;
134
+
135
+ if (Array.isArray(commands)) {
136
+ const valid: Record<string, AfterImplementCommandConfig> = {};
137
+ for (const [index, cmd] of commands.entries()) {
138
+ if (!cmd || typeof cmd !== "object") continue;
139
+ const run = (cmd as any).run;
140
+ if (typeof run !== "string" || run.length === 0) continue;
141
+ valid[`cmd-${index + 1}`] = { run };
142
+ }
143
+ return Object.keys(valid).length > 0 ? valid : null;
144
+ }
145
+
146
+ const valid: Record<string, AfterImplementCommandConfig> = {};
147
+ for (const [id, cmd] of Object.entries(commands as Record<string, unknown>)) {
148
+ if (!cmd || typeof cmd !== "object") continue;
149
+ const run = (cmd as any).run;
150
+ if (typeof run !== "string" || run.length === 0) continue;
151
+ const enabledRaw = (cmd as any).enabled;
152
+ valid[id] = {
153
+ run,
154
+ ...(typeof enabledRaw === "boolean" ? { enabled: enabledRaw } : {}),
155
+ };
156
+ }
157
+ return valid;
158
+ }
159
+
62
160
  export function autoCommit(files: string[], message: string, cwd: string): { ok: boolean; commitHash?: string; error?: string } {
63
161
  if (files.length === 0) return { ok: true };
64
162
 
@@ -67,7 +165,7 @@ export function autoCommit(files: string[], message: string, cwd: string): { ok:
67
165
  try {
68
166
  execFileSync("git", ["add", "--", ...files], { cwd, encoding: "utf-8", stdio: "pipe" });
69
167
  const output = execFileSync("git", ["commit", "-m", cleanMessage], { cwd, encoding: "utf-8", stdio: "pipe" });
70
- const hashMatch = output.match(/\[[\w-]+ (?:\([^)]+\) )?([a-f0-9]+)\]/);
168
+ const hashMatch = output.match(/\[[^\]]* ([a-f0-9]{7,40})\]/);
71
169
  return { ok: true, commitHash: hashMatch?.[1] };
72
170
  } catch (err: any) {
73
171
  return { ok: false, error: err.stderr?.toString() || err.message };