@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.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +73 -31
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1191 -360
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +295 -148
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -35
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -29
- package/extensions/orchestrator/phases/review-task.ts +3 -3
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2631 -392
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
orchestrator.pendingSubagentSpawns = Object.values(
|
|
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
|
-
|
|
87
|
-
orchestrator
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
orchestrator.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { dirname, join } from "path";
|
|
3
4
|
import { minimatch } from "minimatch";
|
|
4
|
-
import type {
|
|
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(
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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(/\[[
|
|
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 };
|