pi-orchestrator-config 4.3.14 → 4.4.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/README.md +5 -3
- package/agents/test-runner.md +1 -1
- package/extensions/orchestrator/async-agents.ts +1 -30
- package/extensions/orchestrator/enforcement-helpers.ts +9 -4
- package/extensions/orchestrator/pi-config-review-state.ts +1 -1
- package/extensions/orchestrator/task-lifecycle.ts +10 -2
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/pitasks/index.ts +70 -9
- package/extensions/pitasks/task-store.ts +44 -5
- package/extensions/pitasks/task-tools.ts +8 -0
- package/extensions/pitasks/task-widget.ts +57 -27
- package/extensions/pitasks/types.ts +6 -0
- package/package.json +2 -1
- package/rules/10-agent-routing.md +6 -3
- package/rules/20-code-review-loop.md +9 -9
- package/scripts/pidash-session-state.ts +106 -0
package/README.md
CHANGED
|
@@ -263,14 +263,16 @@ model contract. It does not filter discovery results or claim that the endpoint
|
|
|
263
263
|
|
|
264
264
|
`review_loop_enforcement` controls automatic review-agent dispatch and commit enforcement.
|
|
265
265
|
|
|
266
|
-
- `true` automatically dispatches 6 agents in parallel (5 reviewers + `test-
|
|
266
|
+
- `true` automatically dispatches 6 agents in parallel (5 reviewers + `test-runner`) after code changes and requires a clean review before commits.
|
|
267
267
|
- `false` disables automatic review-agent and test-agent dispatch. Manual reviews and test runs are optional. No review state, cycle, or result is required before commit.
|
|
268
268
|
|
|
269
269
|
When `review_loop_enforcement` is enabled, the loop stops once all reviewers approve with 0 findings and tests pass
|
|
270
|
-
(`tests_passed: true` in `pi-config-review-state.jsonl`)
|
|
270
|
+
(`tests_passed: true` in `pi-config-review-state.jsonl`). `tests_passed` is set only by a detected
|
|
271
|
+
successful test command; completion of async `test-runner` or `test-automator` agents does not set it.
|
|
272
|
+
OR after `review_loop_max_cycles` total cycles (default `3`,
|
|
271
273
|
valid integers `1`-`10`; env: digit string `"1"`-`"10"` only (after trim)) — whichever comes first. Each cycle
|
|
272
274
|
always completes fix/explain (5a) before the cap check; the cap only blocks re-dispatch
|
|
273
|
-
(step 2 / all 6 agents, including test-
|
|
275
|
+
(step 2 / all 6 agents, including test-runner), not responding to findings.
|
|
274
276
|
At cap, report **Not fixed** (explained why not → outstanding) vs **Fixed**
|
|
275
277
|
(verification blocked by the cap — cannot re-dispatch to confirm clean). Invalid values (including non-digit
|
|
276
278
|
forms like `"10.0"` / `"1e1"`) fall through to the next resolution layer / default `3`. Disable the review loop via
|
package/agents/test-runner.md
CHANGED
|
@@ -32,7 +32,7 @@ You are a specialized test execution agent. Run tests and provide concise failur
|
|
|
32
32
|
Failed Test 1: test_name (file:line)
|
|
33
33
|
Expected: [brief description]
|
|
34
34
|
Actual: [brief description]
|
|
35
|
-
Fix location: path/to/file
|
|
35
|
+
Fix location: path/to/file:line
|
|
36
36
|
Suggested approach: [one line]
|
|
37
37
|
|
|
38
38
|
Returning control for fixes.
|
|
@@ -16,7 +16,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
16
16
|
import type { AgentConfig, AgentDiscoveryResult } from "./agents.js";
|
|
17
17
|
import { resolveAgentModelProvider } from "./resolve-agent-model.js";
|
|
18
18
|
import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
|
|
19
|
-
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState
|
|
19
|
+
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState } from "./pi-config-review-state.js";
|
|
20
20
|
import {
|
|
21
21
|
getMainBranch,
|
|
22
22
|
} from "./git-helpers.js";
|
|
@@ -337,9 +337,6 @@ export function registerAsyncAgents(
|
|
|
337
337
|
const findings = countFindings(typeof data.output === "string" ? data.output : "");
|
|
338
338
|
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { zombieSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
339
339
|
}
|
|
340
|
-
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
341
|
-
try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { zombieSideEffectsOk = false; log.error(`markTests failed for ${job.agent}: ${e?.message}`); }
|
|
342
|
-
}
|
|
343
340
|
if (zombieSideEffectsOk) job.sideEffectsApplied = true;
|
|
344
341
|
try { fs.unlinkSync(resultFilePath); } catch {}
|
|
345
342
|
} catch {
|
|
@@ -405,9 +402,6 @@ export function registerAsyncAgents(
|
|
|
405
402
|
const findings = countFindings(typeof job.output === "string" ? job.output : "");
|
|
406
403
|
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
407
404
|
}
|
|
408
|
-
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
409
|
-
try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: markTests failed for ${job.agent}: ${e?.message}`); }
|
|
410
|
-
}
|
|
411
405
|
if (sideEffectsOk) job.sideEffectsApplied = true;
|
|
412
406
|
}
|
|
413
407
|
// Delivery: retry for groups where all members are done
|
|
@@ -548,18 +542,6 @@ export function registerAsyncAgents(
|
|
|
548
542
|
} catch (e: any) { j.sideEffectsApplied = false; log.error(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); continue; }
|
|
549
543
|
}
|
|
550
544
|
|
|
551
|
-
// Track test agent completions for grouped test-automator/test-runner
|
|
552
|
-
for (const j of groupJobs) {
|
|
553
|
-
if (j.agent !== "test-automator" && j.agent !== "test-runner") continue;
|
|
554
|
-
try {
|
|
555
|
-
if (j.status === "complete") {
|
|
556
|
-
markTestsPassed(jobCwd(j));
|
|
557
|
-
} else if (j.status === "failed") {
|
|
558
|
-
markTestsFailed(jobCwd(j));
|
|
559
|
-
}
|
|
560
|
-
} catch (e: any) { j.sideEffectsApplied = false; log.error(`markTests failed for ${j.agent}: ${e?.message}`); continue; }
|
|
561
|
-
}
|
|
562
|
-
|
|
563
545
|
// Mark side-effects applied per-job (only if not already marked false by a failed side-effect above)
|
|
564
546
|
for (const j of groupJobs) {
|
|
565
547
|
if (j.sideEffectsApplied !== false) j.sideEffectsApplied = true;
|
|
@@ -738,17 +720,6 @@ export function registerAsyncAgents(
|
|
|
738
720
|
}
|
|
739
721
|
}
|
|
740
722
|
|
|
741
|
-
// Track test agent completion for review state test tracking
|
|
742
|
-
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
743
|
-
try {
|
|
744
|
-
if (data.success) {
|
|
745
|
-
markTestsPassed(jobCwd(job));
|
|
746
|
-
} else {
|
|
747
|
-
markTestsFailed(jobCwd(job));
|
|
748
|
-
}
|
|
749
|
-
} catch (e: any) { processResultSideEffectsOk = false; log.error(`markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
|
|
750
|
-
}
|
|
751
|
-
|
|
752
723
|
if (processResultSideEffectsOk) job.sideEffectsApplied = true;
|
|
753
724
|
|
|
754
725
|
// Always delete result file after ingestion — data is in memory (job.output).
|
|
@@ -812,7 +812,7 @@ function bodyAlreadySigned(text: string): boolean {
|
|
|
812
812
|
|
|
813
813
|
/** Unresolved model placeholders are stale template footers, not signatures. */
|
|
814
814
|
function replaceUnresolvedModelFooters(payload: string): string {
|
|
815
|
-
const unresolvedFooter = /(?:\n|\\n){2}---(?:\n|\\n)\*Assisted-by: PI \((?:\$PI_MODEL|\$\{PI_MODEL
|
|
815
|
+
const unresolvedFooter = /(?:\n|\\n){2}---(?:\n|\\n)\*Assisted-by: PI \((?:\$PI_MODEL|\$\{PI_MODEL(?::-unknown)?\})\)\*/g;
|
|
816
816
|
const replaced = payload.replace(unresolvedFooter, "");
|
|
817
817
|
if (replaced !== payload)
|
|
818
818
|
enfLog.debug("injectGhBodySignature replaced unresolved PI_MODEL footer");
|
|
@@ -939,7 +939,9 @@ export function injectGhBodySignature(command: string, signature: string): strin
|
|
|
939
939
|
|
|
940
940
|
/**
|
|
941
941
|
* Detect common test runner commands — require command-start position
|
|
942
|
-
* (after &&, |, ;, or line start) to avoid false positives from install/grep/cat commands.
|
|
942
|
+
* (after &&, |, ;, a newline, or line start) to avoid false positives from install/grep/cat commands.
|
|
943
|
+
* `pre-commit` counts only when its `run` subcommand is executed; setup and information
|
|
944
|
+
* commands must not mark tests as passed.
|
|
943
945
|
* NOTE: For compound commands (e.g., pytest && other_cmd), if the non-test part fails,
|
|
944
946
|
* isError=true marks tests as failed even though pytest passed. This is the conservative/safe
|
|
945
947
|
* direction — re-run the test command standalone to mark tests_passed.
|
|
@@ -947,11 +949,14 @@ export function injectGhBodySignature(command: string, signature: string): strin
|
|
|
947
949
|
* match — we exclude tox with explicit -e to avoid marking lint/docs runs as test passes.
|
|
948
950
|
*/
|
|
949
951
|
export function isTestRunnerCommand(command: string): boolean {
|
|
950
|
-
|
|
952
|
+
const result = /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?(?:pytest|vitest|jest|mocha)\b/.test(command)
|
|
951
953
|
|| /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?tox\b(?!\s*-e)(?!\s+--(?:help|version|list))/.test(command)
|
|
952
954
|
|| /(?:^|[;&|]\s*)go\s+test\b/.test(command)
|
|
953
955
|
|| /(?:^|[;&|]\s*)npm\s+test\b/.test(command)
|
|
954
|
-
|| /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command)
|
|
956
|
+
|| /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command)
|
|
957
|
+
|| /(?:^|[;&|\n]\s*)pre-commit\s+run\b/.test(command);
|
|
958
|
+
enfLog.debug("isTestRunnerCommand", result);
|
|
959
|
+
return result;
|
|
955
960
|
}
|
|
956
961
|
|
|
957
962
|
/** True for release bump branches: chore/bump-version-<digit>... */
|
|
@@ -289,7 +289,7 @@ export function markTestsPassed(cwd: string): void {
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
/** Mark that tests have failed. No-op when review status is not being tracked.
|
|
292
|
-
* Called when a detected test command exits non-zero
|
|
292
|
+
* Called when a detected test command exits non-zero.
|
|
293
293
|
* Resets to false on any file edit via markNeedsReview(). */
|
|
294
294
|
export function markTestsFailed(cwd: string): void {
|
|
295
295
|
withStateLock(cwd, (state) => {
|
|
@@ -50,7 +50,11 @@ export async function autoCompleteTask(taskId: string, cwd: string, sessionId?:
|
|
|
50
50
|
try {
|
|
51
51
|
const task = store.get(taskId);
|
|
52
52
|
if (!task) return false;
|
|
53
|
-
if (task.status !== "completed") {
|
|
53
|
+
if (task.status !== "completed") {
|
|
54
|
+
store.update(taskId, { status: "completed" });
|
|
55
|
+
(await import("../pitasks/index.js")).setTaskTelemetryActive(taskId, false);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
54
58
|
return false;
|
|
55
59
|
} catch (e: any) {
|
|
56
60
|
log.debug("autoCompleteTask failed for task", taskId, e?.message?.slice(0, 100));
|
|
@@ -89,7 +93,11 @@ export async function autoMarkInProgress(taskId: string, cwd: string, sessionId?
|
|
|
89
93
|
try {
|
|
90
94
|
const task = store.get(taskId);
|
|
91
95
|
if (!task) return false;
|
|
92
|
-
if (task.status === "pending") {
|
|
96
|
+
if (task.status === "pending") {
|
|
97
|
+
store.update(taskId, { status: "in_progress" });
|
|
98
|
+
(await import("../pitasks/index.js")).setTaskTelemetryActive(taskId);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
93
101
|
return false;
|
|
94
102
|
} catch (e: any) {
|
|
95
103
|
log.debug("autoMarkInProgress failed for task", taskId, e?.message?.slice(0, 100));
|
|
@@ -32,7 +32,12 @@ const log = createLogger("pitasks");
|
|
|
32
32
|
|
|
33
33
|
// Module-level refs — survive closure replacement on /reload
|
|
34
34
|
let currentUiCtx: any = null;
|
|
35
|
+
let currentStore: TaskStore | null = null;
|
|
35
36
|
let currentWidget: TaskWidget | null = null;
|
|
37
|
+
export function setTaskTelemetryActive(taskId: string, active = true): void {
|
|
38
|
+
log.debug("task_telemetry_active", { taskId, active });
|
|
39
|
+
currentWidget?.setActiveTask(taskId, active);
|
|
40
|
+
}
|
|
36
41
|
|
|
37
42
|
/** Exported store instance — set during extension init. */
|
|
38
43
|
export let taskStore: TaskStore;
|
|
@@ -54,7 +59,10 @@ export function listTasks(): any[] {
|
|
|
54
59
|
|
|
55
60
|
/** Update a task — for use by other extensions. */
|
|
56
61
|
export function updateTask(id: string, fields: any): any {
|
|
57
|
-
|
|
62
|
+
log.debug("task_updated", { taskId: id, fieldNames: Object.keys(fields) });
|
|
63
|
+
const result = taskStore.update(id, fields);
|
|
64
|
+
if (result.changedFields.includes("status")) setTaskTelemetryActive(id, fields.status === "in_progress");
|
|
65
|
+
return result;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
/** Delete a task — for use by other extensions. */
|
|
@@ -66,58 +74,93 @@ export function deleteTask(id: string): boolean {
|
|
|
66
74
|
|
|
67
75
|
/** Create a task on a specific session's store. Used by coms for sender-side task creation. */
|
|
68
76
|
export function createTaskForSession(sessionId: string, subject: string, description: string, createdBy?: any, metadata?: Record<string, any>, targetCwd?: string, activeForm?: string): any {
|
|
77
|
+
log.debug("session_task_created", { sessionId });
|
|
69
78
|
const base = targetCwd || process.cwd();
|
|
70
79
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
71
80
|
const store = new TaskStore(storePath);
|
|
72
|
-
|
|
81
|
+
try {
|
|
82
|
+
return store.create(subject, description, createdBy || { type: "local", origin: "system", session: "", project: "" }, activeForm, metadata);
|
|
83
|
+
} finally {
|
|
84
|
+
store.close();
|
|
85
|
+
}
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/** Create multiple tasks on a specific session's store. Used by coms for bulk task creation. */
|
|
76
89
|
export function createTasksForSession(sessionId: string, tasks: Array<{ subject: string; description: string; createdBy: any; blockedBy?: string[]; activeForm?: string; metadata?: Record<string, any> }>, targetCwd?: string): any[] {
|
|
90
|
+
log.debug("session_tasks_created", { sessionId, count: tasks.length });
|
|
77
91
|
const base = targetCwd || process.cwd();
|
|
78
92
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
79
93
|
const store = new TaskStore(storePath);
|
|
80
|
-
|
|
94
|
+
try {
|
|
95
|
+
return store.createTasks(tasks);
|
|
96
|
+
} finally {
|
|
97
|
+
store.close();
|
|
98
|
+
}
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
/** Get a task from a specific session's store. */
|
|
84
102
|
export function getTaskForSession(sessionId: string, taskId: string, targetCwd?: string): any {
|
|
103
|
+
log.debug("session_task_read", { sessionId, taskId });
|
|
85
104
|
const base = targetCwd || process.cwd();
|
|
86
105
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
87
106
|
const store = new TaskStore(storePath);
|
|
88
|
-
|
|
107
|
+
try {
|
|
108
|
+
return store.get(taskId);
|
|
109
|
+
} finally {
|
|
110
|
+
store.close();
|
|
111
|
+
}
|
|
89
112
|
}
|
|
90
113
|
|
|
91
114
|
/** List tasks from a specific session's store. */
|
|
92
115
|
export function listTasksForSession(sessionId: string, targetCwd?: string): any[] {
|
|
116
|
+
log.debug("session_tasks_listed", { sessionId });
|
|
93
117
|
const base = targetCwd || process.cwd();
|
|
94
118
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
95
119
|
const store = new TaskStore(storePath);
|
|
96
|
-
|
|
120
|
+
try {
|
|
121
|
+
return store.list();
|
|
122
|
+
} finally {
|
|
123
|
+
store.close();
|
|
124
|
+
}
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/** Update a task on a specific session's store. */
|
|
100
128
|
export function updateTaskForSession(sessionId: string, taskId: string, fields: any, targetCwd?: string): any {
|
|
129
|
+
log.debug("session_task_updated", { sessionId, taskId, fieldNames: Object.keys(fields) });
|
|
101
130
|
const base = targetCwd || process.cwd();
|
|
102
131
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
103
132
|
const store = new TaskStore(storePath);
|
|
104
|
-
|
|
133
|
+
try {
|
|
134
|
+
return store.update(taskId, fields);
|
|
135
|
+
} finally {
|
|
136
|
+
store.close();
|
|
137
|
+
}
|
|
105
138
|
}
|
|
106
139
|
|
|
107
140
|
/** Update multiple tasks on a specific session's store. Used by coms for bulk updates. */
|
|
108
141
|
export function updateTasksForSession(sessionId: string, updates: Array<{ id: string; fields: Record<string, any> }>, targetCwd?: string): any[] {
|
|
142
|
+
log.debug("session_tasks_updated", { sessionId, count: updates.length });
|
|
109
143
|
const base = targetCwd || process.cwd();
|
|
110
144
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
111
145
|
const store = new TaskStore(storePath);
|
|
112
|
-
|
|
146
|
+
try {
|
|
147
|
+
return store.updateTasks(updates);
|
|
148
|
+
} finally {
|
|
149
|
+
store.close();
|
|
150
|
+
}
|
|
113
151
|
}
|
|
114
152
|
|
|
115
153
|
/** Delete a task from a specific session's store. */
|
|
116
154
|
export function deleteTaskForSession(sessionId: string, taskId: string, targetCwd?: string): boolean {
|
|
155
|
+
log.debug("session_task_deleted", { sessionId, taskId });
|
|
117
156
|
const base = targetCwd || process.cwd();
|
|
118
157
|
const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
|
|
119
158
|
const store = new TaskStore(storePath);
|
|
120
|
-
|
|
159
|
+
try {
|
|
160
|
+
return store.delete(taskId);
|
|
161
|
+
} finally {
|
|
162
|
+
store.close();
|
|
163
|
+
}
|
|
121
164
|
}
|
|
122
165
|
|
|
123
166
|
export default function (pi: ExtensionAPI) {
|
|
@@ -139,9 +182,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
139
182
|
return join(process.cwd(), ".pi", "tasks", "tasks.json");
|
|
140
183
|
}
|
|
141
184
|
|
|
185
|
+
const resumedActiveTaskIds = currentWidget?.getActiveTaskIds() ?? [];
|
|
186
|
+
currentStore?.setOnChange(() => {});
|
|
187
|
+
currentStore?.close();
|
|
188
|
+
currentWidget?.dispose();
|
|
142
189
|
let store = new TaskStore(resolveStorePath());
|
|
143
190
|
taskStore = store;
|
|
191
|
+
currentStore = store;
|
|
144
192
|
const widget = new TaskWidget(store);
|
|
193
|
+
for (const taskId of resumedActiveTaskIds) widget.setActiveTask(taskId);
|
|
145
194
|
currentWidget = widget;
|
|
146
195
|
const instanceId = Math.random().toString(36).slice(2, 8);
|
|
147
196
|
(globalThis as any).__pitasks_active_instance = instanceId;
|
|
@@ -154,8 +203,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
203
|
if (taskScope === "session" && !piTasks) {
|
|
155
204
|
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
156
205
|
const path = resolveStorePath(sessionId);
|
|
206
|
+
store.setOnChange(() => {});
|
|
207
|
+
store.close();
|
|
157
208
|
store = new TaskStore(path);
|
|
158
209
|
taskStore = store;
|
|
210
|
+
currentStore = store;
|
|
159
211
|
widget.setStore(store);
|
|
160
212
|
store.setOnChange(() => { widget.update(); });
|
|
161
213
|
log.debug("store upgraded", path);
|
|
@@ -281,6 +333,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
333
|
shuttingDown = true;
|
|
282
334
|
if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
|
|
283
335
|
if (reminderTimer) { clearInterval(reminderTimer); reminderTimer = null; }
|
|
336
|
+
widget.deactivate();
|
|
337
|
+
store.setOnChange(() => {});
|
|
338
|
+
store.close();
|
|
339
|
+
if (currentWidget === widget) {
|
|
340
|
+
currentWidget = null;
|
|
341
|
+
currentStore = null;
|
|
342
|
+
}
|
|
284
343
|
});
|
|
285
344
|
|
|
286
345
|
pi.on("turn_start", async (_event: any, ctx: any) => {
|
|
@@ -314,7 +373,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
314
373
|
shuttingDown = false;
|
|
315
374
|
agentBusy = false;
|
|
316
375
|
log.debug("agentBusy", "session_start reset");
|
|
317
|
-
widget.
|
|
376
|
+
widget.reactivate(ctx.ui);
|
|
377
|
+
store.reopen();
|
|
378
|
+
store.setOnChange(() => { widget.update(); });
|
|
318
379
|
currentUiCtx = ctx.ui;
|
|
319
380
|
const reason = (event as any).reason;
|
|
320
381
|
log.debug("session_start", reason);
|
|
@@ -9,6 +9,9 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileS
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, isAbsolute, join } from "node:path";
|
|
11
11
|
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
|
12
|
+
import { createLogger } from "../shared/logger.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("pitasks");
|
|
12
15
|
|
|
13
16
|
function sortById(a: Task, b: Task): number {
|
|
14
17
|
return Number(a.id) - Number(b.id);
|
|
@@ -61,6 +64,7 @@ function isProcessRunning(pid: number): boolean {
|
|
|
61
64
|
|
|
62
65
|
function normalizeTask(t: any): Task {
|
|
63
66
|
const now = Date.now();
|
|
67
|
+
log.debug("task_normalized", { taskId: t.id, hasTelemetry: !!t.telemetry });
|
|
64
68
|
return {
|
|
65
69
|
...t,
|
|
66
70
|
createdBy: t.createdBy && typeof t.createdBy === "object" ? t.createdBy :
|
|
@@ -75,6 +79,13 @@ function normalizeTask(t: any): Task {
|
|
|
75
79
|
blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy : [],
|
|
76
80
|
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
|
|
77
81
|
updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : now,
|
|
82
|
+
telemetry: t.telemetry && typeof t.telemetry === "object" &&
|
|
83
|
+
typeof t.telemetry.startedAt === "number" && typeof t.telemetry.inputTokens === "number" && typeof t.telemetry.outputTokens === "number" ? {
|
|
84
|
+
startedAt: t.telemetry.startedAt,
|
|
85
|
+
...(typeof t.telemetry.endedAt === "number" ? { endedAt: t.telemetry.endedAt } : {}),
|
|
86
|
+
inputTokens: t.telemetry.inputTokens,
|
|
87
|
+
outputTokens: t.telemetry.outputTokens,
|
|
88
|
+
} : undefined,
|
|
78
89
|
statusHistory: t.statusHistory && typeof t.statusHistory === "object" ? {
|
|
79
90
|
pending_at: typeof t.statusHistory.pending_at === "number" ? new Date(t.statusHistory.pending_at).toISOString() : (t.statusHistory.pending_at || new Date(typeof t.createdAt === "number" ? t.createdAt : now).toISOString()),
|
|
80
91
|
in_progress_at: typeof t.statusHistory.in_progress_at === "number" ? new Date(t.statusHistory.in_progress_at).toISOString() : (t.statusHistory.in_progress_at || null),
|
|
@@ -145,6 +156,10 @@ export class TaskStore {
|
|
|
145
156
|
if (this._watcher) { this._watcher.close(); this._watcher = null; }
|
|
146
157
|
}
|
|
147
158
|
|
|
159
|
+
reopen(): void {
|
|
160
|
+
if (!this._watcher) this._startWatcher();
|
|
161
|
+
}
|
|
162
|
+
|
|
148
163
|
private load(): void {
|
|
149
164
|
if (!this.filePath) return;
|
|
150
165
|
if (!existsSync(this.filePath)) return;
|
|
@@ -258,11 +273,13 @@ export class TaskStore {
|
|
|
258
273
|
activeForm?: string;
|
|
259
274
|
owner?: string;
|
|
260
275
|
metadata?: Record<string, any>;
|
|
276
|
+
telemetry?: Task["telemetry"];
|
|
261
277
|
addBlocks?: string[];
|
|
262
278
|
addBlockedBy?: string[];
|
|
263
279
|
}): { task: Task | undefined; changedFields: string[]; warnings: string[] } {
|
|
264
280
|
return this.withLock(() => {
|
|
265
281
|
const task = this.tasks.get(id);
|
|
282
|
+
log.debug("task_updated", { taskId: id, status: fields.status, hasTelemetry: fields.telemetry !== undefined });
|
|
266
283
|
if (!task) return { task: undefined, changedFields: [], warnings: [] };
|
|
267
284
|
const changedFields: string[] = [];
|
|
268
285
|
const warnings: string[] = [];
|
|
@@ -282,8 +299,17 @@ export class TaskStore {
|
|
|
282
299
|
changedFields.push("status");
|
|
283
300
|
const now = Date.now();
|
|
284
301
|
const isoNow = new Date(now).toISOString();
|
|
285
|
-
if (fields.status === "in_progress")
|
|
286
|
-
|
|
302
|
+
if (fields.status === "in_progress") {
|
|
303
|
+
task.statusHistory.in_progress_at = isoNow;
|
|
304
|
+
if (!task.telemetry || task.telemetry.endedAt !== undefined) {
|
|
305
|
+
task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (fields.status === "completed") {
|
|
309
|
+
task.statusHistory.completed_at = isoNow;
|
|
310
|
+
if (!task.telemetry) task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
|
|
311
|
+
if (!task.telemetry.endedAt) task.telemetry.endedAt = now;
|
|
312
|
+
}
|
|
287
313
|
if (fields.status === "pending") task.statusHistory.pending_at = isoNow;
|
|
288
314
|
}
|
|
289
315
|
if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); }
|
|
@@ -298,6 +324,7 @@ export class TaskStore {
|
|
|
298
324
|
}
|
|
299
325
|
changedFields.push("metadata");
|
|
300
326
|
}
|
|
327
|
+
if (fields.telemetry !== undefined) { task.telemetry = fields.telemetry; changedFields.push("telemetry"); }
|
|
301
328
|
|
|
302
329
|
if (fields.addBlocks?.length) {
|
|
303
330
|
for (const targetId of fields.addBlocks) {
|
|
@@ -330,6 +357,7 @@ export class TaskStore {
|
|
|
330
357
|
|
|
331
358
|
updateTasks(updates: Array<{ id: string; fields: Record<string, any> }>): Array<{ id: string; success: boolean; changedFields?: string[] }> {
|
|
332
359
|
return this.withLock(() => {
|
|
360
|
+
log.debug("tasks_updated", { count: updates.length, statusUpdates: updates.filter(update => update.fields.status !== undefined).length });
|
|
333
361
|
const results: Array<{ id: string; success: boolean; changedFields?: string[] }> = [];
|
|
334
362
|
for (const { id, fields } of updates) {
|
|
335
363
|
const task = this.tasks.get(id);
|
|
@@ -350,9 +378,19 @@ export class TaskStore {
|
|
|
350
378
|
if (fields.status !== undefined) {
|
|
351
379
|
task.status = fields.status as TaskStatus;
|
|
352
380
|
changedFields.push("status");
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
if (fields.status === "
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
const isoNow = new Date(now).toISOString();
|
|
383
|
+
if (fields.status === "in_progress") {
|
|
384
|
+
task.statusHistory.in_progress_at = isoNow;
|
|
385
|
+
if (!task.telemetry || task.telemetry.endedAt !== undefined) {
|
|
386
|
+
task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (fields.status === "completed") {
|
|
390
|
+
task.statusHistory.completed_at = isoNow;
|
|
391
|
+
if (!task.telemetry) task.telemetry = { startedAt: now, inputTokens: 0, outputTokens: 0 };
|
|
392
|
+
if (!task.telemetry.endedAt) task.telemetry.endedAt = now;
|
|
393
|
+
}
|
|
356
394
|
if (fields.status === "pending") task.statusHistory.pending_at = isoNow;
|
|
357
395
|
}
|
|
358
396
|
if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); }
|
|
@@ -367,6 +405,7 @@ export class TaskStore {
|
|
|
367
405
|
}
|
|
368
406
|
changedFields.push("metadata");
|
|
369
407
|
}
|
|
408
|
+
if (fields.telemetry !== undefined) { task.telemetry = fields.telemetry; changedFields.push("telemetry"); }
|
|
370
409
|
|
|
371
410
|
if (fields.addBlocks?.length) {
|
|
372
411
|
for (const targetId of fields.addBlocks) {
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
|
+
import { createLogger } from "../shared/logger.js";
|
|
8
9
|
import type { TaskStore } from "./task-store.js";
|
|
9
10
|
import type { TaskWidget } from "./task-widget.js";
|
|
10
11
|
|
|
12
|
+
const log = createLogger("pitasks");
|
|
13
|
+
|
|
11
14
|
function textResult(msg: string) {
|
|
12
15
|
return { content: [{ type: "text" as const, text: msg }], details: undefined };
|
|
13
16
|
}
|
|
@@ -355,6 +358,7 @@ Set up task dependencies:
|
|
|
355
358
|
}), { description: "Array of task updates" }),
|
|
356
359
|
}),
|
|
357
360
|
async execute(_callId, params) {
|
|
361
|
+
log.debug("tasks_bulk_updated", { count: params.updates.length });
|
|
358
362
|
const store = getStore();
|
|
359
363
|
const updates = params.updates.map(u => ({
|
|
360
364
|
id: u.taskId,
|
|
@@ -367,6 +371,10 @@ Set up task dependencies:
|
|
|
367
371
|
},
|
|
368
372
|
}));
|
|
369
373
|
const results = store.updateTasks(updates);
|
|
374
|
+
for (const update of updates) {
|
|
375
|
+
if (update.fields.status === "in_progress") widget.setActiveTask(update.id);
|
|
376
|
+
else if (update.fields.status !== undefined) widget.setActiveTask(update.id, false);
|
|
377
|
+
}
|
|
370
378
|
widget.update();
|
|
371
379
|
const lines = results.map(r => r.success ? `#${r.id}: ${r.changedFields?.join(", ") || "no changes"}` : `#${r.id}: not found`);
|
|
372
380
|
return textResult(`Updated ${results.filter(r => r.success).length} task(s):\n${lines.join("\n")}`);
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* Copied from @tintinweb/pi-tasks (MIT license).
|
|
4
4
|
*/
|
|
5
5
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
import { createLogger } from "../shared/logger.js";
|
|
6
7
|
import type { TaskStore } from "./task-store.js";
|
|
7
8
|
|
|
9
|
+
const log = createLogger("pitasks");
|
|
10
|
+
|
|
8
11
|
const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
|
|
9
12
|
const DEFAULT_MAX_VISIBLE_TASKS = 10;
|
|
10
13
|
|
|
@@ -36,29 +39,61 @@ export class TaskWidget {
|
|
|
36
39
|
private metrics = new Map<string, { startedAt: number; inputTokens: number; outputTokens: number }>();
|
|
37
40
|
private tui: any;
|
|
38
41
|
private widgetRegistered = false;
|
|
42
|
+
private disposed = false;
|
|
39
43
|
|
|
40
|
-
constructor(private store: TaskStore, private config: Record<string, any> = {}) {
|
|
44
|
+
constructor(private store: TaskStore, private config: Record<string, any> = {}) {
|
|
45
|
+
log.debug("widget_created", { taskCount: store.list().length });
|
|
46
|
+
}
|
|
41
47
|
|
|
42
|
-
setStore(store: TaskStore): void {
|
|
48
|
+
setStore(store: TaskStore): void {
|
|
49
|
+
this.store = store;
|
|
50
|
+
this.activeTaskIds.clear();
|
|
51
|
+
this.metrics.clear();
|
|
52
|
+
log.debug("widget_store_set", { taskCount: store.list().length });
|
|
53
|
+
}
|
|
43
54
|
setUICtx(ctx: any): void { this.uiCtx = ctx; }
|
|
55
|
+
getActiveTaskIds(): string[] { return [...this.activeTaskIds]; }
|
|
56
|
+
|
|
57
|
+
reactivate(ctx: any): void {
|
|
58
|
+
this.disposed = false;
|
|
59
|
+
this.uiCtx = ctx;
|
|
60
|
+
log.debug("widget_reactivated", { taskCount: this.store.list().length });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
deactivate(): void {
|
|
64
|
+
log.debug("widget_deactivated", { hadWidget: this.widgetRegistered, hadTimer: !!this.widgetInterval });
|
|
65
|
+
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
66
|
+
if (this.uiCtx) this.uiCtx.setWidget("tasks", undefined);
|
|
67
|
+
this.widgetRegistered = false;
|
|
68
|
+
this.tui = undefined;
|
|
69
|
+
this.uiCtx = undefined;
|
|
70
|
+
}
|
|
44
71
|
|
|
45
72
|
setActiveTask(taskId: string, active = true): void {
|
|
46
|
-
|
|
73
|
+
const task = this.store.get(taskId);
|
|
74
|
+
log.debug("task_telemetry_active", { taskId, active, status: task?.status });
|
|
75
|
+
if (taskId && active && task) {
|
|
47
76
|
this.activeTaskIds.add(taskId);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
77
|
+
const telemetry = task.telemetry ?? { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 };
|
|
78
|
+
if (!task.telemetry) this.store.update(taskId, { telemetry });
|
|
79
|
+
this.metrics.set(taskId, telemetry);
|
|
51
80
|
this.ensureTimer();
|
|
52
81
|
} else if (taskId) {
|
|
53
82
|
this.activeTaskIds.delete(taskId);
|
|
83
|
+
if (task?.telemetry && !task.telemetry.endedAt) this.store.update(taskId, { telemetry: { ...task.telemetry, endedAt: Date.now() } });
|
|
54
84
|
}
|
|
55
85
|
this.update();
|
|
56
86
|
}
|
|
57
87
|
|
|
58
88
|
addTokenUsage(inputTokens: number, outputTokens: number): void {
|
|
89
|
+
log.debug("task_telemetry_usage", { activeTaskCount: this.activeTaskIds.size, inputTokens, outputTokens });
|
|
59
90
|
for (const id of this.activeTaskIds) {
|
|
60
91
|
const m = this.metrics.get(id);
|
|
61
|
-
if (m) {
|
|
92
|
+
if (m) {
|
|
93
|
+
m.inputTokens += inputTokens;
|
|
94
|
+
m.outputTokens += outputTokens;
|
|
95
|
+
this.store.update(id, { telemetry: m });
|
|
96
|
+
}
|
|
62
97
|
}
|
|
63
98
|
}
|
|
64
99
|
|
|
@@ -69,6 +104,7 @@ export class TaskWidget {
|
|
|
69
104
|
}
|
|
70
105
|
|
|
71
106
|
private renderWidget(tui: any, theme: any): string[] {
|
|
107
|
+
log.debug("task_widget_render", { taskCount: this.store.list().length });
|
|
72
108
|
try { return this.buildWidgetLines(tui, theme); } catch { return []; }
|
|
73
109
|
}
|
|
74
110
|
|
|
@@ -114,29 +150,23 @@ export class TaskWidget {
|
|
|
114
150
|
if (openBlockers.length > 0) suffix = theme.fg("dim", ` › blocked by ${openBlockers.map((id: string) => "#" + id).join(", ")}`);
|
|
115
151
|
}
|
|
116
152
|
|
|
153
|
+
const m = task.telemetry ?? this.metrics.get(task.id);
|
|
154
|
+
const stats = m ? (() => {
|
|
155
|
+
const elapsed = formatDuration(Math.max(0, (m.endedAt ?? Date.now()) - m.startedAt));
|
|
156
|
+
const tokenParts = [m.inputTokens > 0 && `↑ ${formatTokens(m.inputTokens)}`, m.outputTokens > 0 && `↓ ${formatTokens(m.outputTokens)}`].filter(Boolean);
|
|
157
|
+
return ` ${theme.fg("dim", tokenParts.length ? `(${elapsed} · ${tokenParts.join(" ")})` : `(${elapsed})`)}`;
|
|
158
|
+
})() : "";
|
|
159
|
+
|
|
117
160
|
let text: string;
|
|
118
161
|
if (task.status === "in_progress") {
|
|
119
162
|
const form = task.activeForm || task.subject;
|
|
120
163
|
const agentId = task.metadata?.agentId;
|
|
121
164
|
const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : "";
|
|
122
|
-
const m = this.metrics.get(task.id);
|
|
123
|
-
let stats = "";
|
|
124
|
-
if (m) {
|
|
125
|
-
const elapsed = formatDuration(Date.now() - m.startedAt);
|
|
126
|
-
const tokenParts: string[] = [];
|
|
127
|
-
if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
|
128
|
-
if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
|
129
|
-
stats = tokenParts.length > 0
|
|
130
|
-
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
|
131
|
-
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
|
132
|
-
}
|
|
133
165
|
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
|
|
134
166
|
} else if (task.status === "completed") {
|
|
135
|
-
text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}`;
|
|
167
|
+
text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}${stats}`;
|
|
136
168
|
} else {
|
|
137
|
-
|
|
138
|
-
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`) : "";
|
|
139
|
-
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}`;
|
|
169
|
+
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${stats}`;
|
|
140
170
|
}
|
|
141
171
|
lines.push(truncate(text + suffix));
|
|
142
172
|
}
|
|
@@ -146,7 +176,8 @@ export class TaskWidget {
|
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
update(): void {
|
|
149
|
-
|
|
179
|
+
log.debug("widget_update", { disposed: this.disposed, hasUi: !!this.uiCtx, taskCount: this.store.list().length });
|
|
180
|
+
if (this.disposed || !this.uiCtx) return;
|
|
150
181
|
const tasks = this.store.list();
|
|
151
182
|
if (tasks.length === 0) {
|
|
152
183
|
if (this.widgetRegistered) { this.uiCtx.setWidget("tasks", undefined); this.widgetRegistered = false; }
|
|
@@ -173,9 +204,8 @@ export class TaskWidget {
|
|
|
173
204
|
}
|
|
174
205
|
|
|
175
206
|
dispose(): void {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
this.
|
|
179
|
-
this.tui = undefined;
|
|
207
|
+
log.debug("widget_disposed", { hadWidget: this.widgetRegistered, hadTimer: !!this.widgetInterval });
|
|
208
|
+
this.disposed = true;
|
|
209
|
+
this.deactivate();
|
|
180
210
|
}
|
|
181
211
|
}
|
|
@@ -23,6 +23,12 @@ export interface Task {
|
|
|
23
23
|
};
|
|
24
24
|
createdAt: number;
|
|
25
25
|
updatedAt: number;
|
|
26
|
+
telemetry?: {
|
|
27
|
+
startedAt: number;
|
|
28
|
+
endedAt?: number;
|
|
29
|
+
inputTokens: number;
|
|
30
|
+
outputTokens: number;
|
|
31
|
+
};
|
|
26
32
|
statusHistory: {
|
|
27
33
|
pending_at: string;
|
|
28
34
|
in_progress_at: string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-orchestrator-config",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"extensions/pidiff/pidiff-ui/dist/",
|
|
40
40
|
"extensions/pitasks/",
|
|
41
41
|
"scripts/pidash-server.ts",
|
|
42
|
+
"scripts/pidash-session-state.ts",
|
|
42
43
|
"scripts/pidiff-server.ts",
|
|
43
44
|
"scripts/pidiff-git-ignore.ts",
|
|
44
45
|
"scripts/daemon-shared.ts",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
| Jenkins/CI/Groovy | `jenkins-expert` |
|
|
16
16
|
| Git operations (local) | `git-expert` |
|
|
17
17
|
| GitHub (PRs, issues, releases, workflows) | `github-expert` |
|
|
18
|
-
|
|
|
18
|
+
| Running existing tests / analyzing failures | `test-runner` |
|
|
19
|
+
| Creating or modifying tests / test strategy | `test-automator` |
|
|
19
20
|
| Debugging | `debugger` |
|
|
20
21
|
| API docs | `api-documenter` |
|
|
21
22
|
| External repo security audit | `security-auditor` |
|
|
@@ -26,8 +27,10 @@
|
|
|
26
27
|
|
|
27
28
|
Route based on the task intent, not just the tool being used.
|
|
28
29
|
|
|
29
|
-
- Running Python
|
|
30
|
-
-
|
|
30
|
+
- Running existing tests (including Python) or analyzing failures? → `test-runner`
|
|
31
|
+
- Fixing Python production code after test failures? → `python-expert`
|
|
32
|
+
- Creating or changing Python tests? → `test-automator`
|
|
33
|
+
- Editing Python production files? → `python-expert` (even with sed/awk)
|
|
31
34
|
- Creating a PR? → `github-expert` (not git-expert)
|
|
32
35
|
- External library docs? → `docs-fetcher` (not direct fetch)
|
|
33
36
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Mandatory review loop
|
|
6
6
|
|
|
7
|
-
After ANY code change, send to ALL 6 agents (5 reviewers + test-
|
|
7
|
+
After ANY code change, send to ALL 6 agents (5 reviewers + test-runner) IN PARALLEL. **Never skip the first review.**
|
|
8
8
|
|
|
9
9
|
## Commit enforcement
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ After ANY code change, send to ALL 6 agents (5 reviewers + test-automator) IN PA
|
|
|
12
12
|
When `review_loop_enforcement` is enabled, the enforcement rule in `enforcement.ts` blocks `git commit` unless:
|
|
13
13
|
|
|
14
14
|
- Review status is `clean` (all reviewers returned 0 findings)
|
|
15
|
-
- `tests_passed: true` (
|
|
15
|
+
- `tests_passed: true` (a detected test command, including one run by `test-runner`, succeeded)
|
|
16
16
|
|
|
17
17
|
**You MUST run the review loop BEFORE attempting `git commit`.** The enforcement rule will reject the commit otherwise.
|
|
18
18
|
Do NOT try to work around it — run the actual review agents to reach `clean` status.
|
|
@@ -52,7 +52,7 @@ next resolution layer / default `3` — see `dev-docs/project-settings.md` for t
|
|
|
52
52
|
Disable the review loop via `review_loop_enforcement: false` — not via max_cycles.
|
|
53
53
|
|
|
54
54
|
Cap check is after 5a; it only blocks `go to 2` (re-dispatch of step 2 / all 6 agents,
|
|
55
|
-
including test-
|
|
55
|
+
including test-runner), not completing the current cycle's fix/explain.
|
|
56
56
|
|
|
57
57
|
**After 5a completes on the max cycle, two outcomes** (report them; commit may still be
|
|
58
58
|
allowed via the max-cycle path — see step 6):
|
|
@@ -71,7 +71,7 @@ cycle3: dispatch → findings → 5a (fix|explain) → cycle >= max → stop (no
|
|
|
71
71
|
|
|
72
72
|
```text
|
|
73
73
|
1. Specialist writes/fixes code
|
|
74
|
-
2. Send ALL 6 agents IN PARALLEL (async): 5 reviewers + test-
|
|
74
|
+
2. Send ALL 6 agents IN PARALLEL (async): 5 reviewers + test-runner
|
|
75
75
|
**On cycle 2+: MANDATORY — include prior cycle findings + responses in each reviewer's prompt (see Step 5a format).**
|
|
76
76
|
Reviewers without this context will blindly repeat the same findings. This is not optional.
|
|
77
77
|
3. Wait for all 6 to complete
|
|
@@ -94,7 +94,7 @@ The enforcement rule blocks commits until this is satisfied — if it blocks you
|
|
|
94
94
|
**After a cap stop:** report the two-outcome result (**Not fixed** → outstanding, **Fixed** → verification
|
|
95
95
|
blocked); commit is allowed when status is `has_findings` or `clean`, no reviewers pending, and
|
|
96
96
|
`cycle >= review_loop_max_cycles` (no `tests_passed` requirement); do **not**
|
|
97
|
-
return to step 2 (re-dispatch of all 6 agents, including test-
|
|
97
|
+
return to step 2 (re-dispatch of all 6 agents, including test-runner).
|
|
98
98
|
{{/IF}}
|
|
99
99
|
|
|
100
100
|
## Review Agents
|
|
@@ -108,7 +108,7 @@ Six agents run in parallel for comprehensive coverage:
|
|
|
108
108
|
| `code-reviewer-security` | Bugs, logic errors, and security vulnerabilities |
|
|
109
109
|
| `code-reviewer-docs` | Documentation quality, completeness, and accuracy |
|
|
110
110
|
| `code-reviewer-spec` | Code/PR/issue spec alignment and compliance |
|
|
111
|
-
| `test-
|
|
111
|
+
| `test-runner` | Run project tests (pytest, node tests, pre-commit) |
|
|
112
112
|
|
|
113
113
|
**All 6 MUST be invoked as async subagents (`async: true`) in the same assistant turn.
|
|
114
114
|
Do NOT block waiting for results — continue working while they run.**
|
|
@@ -171,7 +171,7 @@ Findings that were fixed in code → verify the fix, do not re-raise if correct.
|
|
|
171
171
|
|
|
172
172
|
## Key Rules
|
|
173
173
|
|
|
174
|
-
Never skip code review — all 6 agents always run (5 reviewers + test-
|
|
174
|
+
Never skip code review — all 6 agents always run (5 reviewers + test-runner).
|
|
175
175
|
Exception: on `chore/bump-version` branches (release version bumps), review dirty-tracking and commit blocking are skipped.
|
|
176
176
|
{{IF:review_loop_enforcement}}
|
|
177
177
|
When `review_loop_enforcement` is enabled: loop, respond to each finding (fix or explain), and re-run all 6 from step 2
|
|
@@ -197,12 +197,12 @@ Tests still run and `tests_passed` is still recorded; commit is not blocked by r
|
|
|
197
197
|
|
|
198
198
|
- **Bash hook detection:** When any agent runs a test command (`pytest`, `npm test`, `npx tsx --test`, `tox`, `go test`, `vitest`, `jest`, `mocha`),
|
|
199
199
|
the enforcement hook detects it and auto-marks `tests_passed` based on exit code.
|
|
200
|
-
- **
|
|
200
|
+
- **Async test agents:** `test-runner` and `test-automator` completion never changes `tests_passed`; agent process completion does not prove its test command passed.
|
|
201
201
|
- **Reset on edit:** Any file edit triggers `markNeedsReview()` (except on `chore/bump-version` branches), which resets `tests_passed: false` —
|
|
202
202
|
preventing stale results.
|
|
203
203
|
|
|
204
204
|
**Duplicate test run avoidance:** If a specialist already ran tests before the review loop, `tests_passed` may already be `true`.
|
|
205
|
-
Any file edit resets it, so test-
|
|
205
|
+
Any file edit resets it, so test-runner in the parallel batch always validates the latest code.
|
|
206
206
|
|
|
207
207
|
## Baseline Test Comparison (Step 5)
|
|
208
208
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SessionInfo } from "../extensions/shared/types.ts";
|
|
2
|
+
import { initialActivityState } from "../extensions/pidash/activity-state.ts";
|
|
3
|
+
import { createLogger } from "../extensions/shared/logger.ts";
|
|
4
|
+
|
|
5
|
+
const log = createLogger("pidash");
|
|
6
|
+
|
|
7
|
+
export interface PiClient {
|
|
8
|
+
ws: any;
|
|
9
|
+
session: SessionInfo;
|
|
10
|
+
eventBuffer: string[];
|
|
11
|
+
replaying: boolean;
|
|
12
|
+
connectionGeneration: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function registeredSession(parsed: any): SessionInfo {
|
|
16
|
+
const activity = parsed.activity === "working" || parsed.activity === "waiting_for_input" ? parsed.activity : "idle";
|
|
17
|
+
const streaming = typeof parsed.streaming === "boolean" ? parsed.streaming : false;
|
|
18
|
+
return {
|
|
19
|
+
sessionId: parsed.sessionId || `${parsed.pid}:${parsed.cwd}`,
|
|
20
|
+
pid: parsed.pid,
|
|
21
|
+
cwd: parsed.cwd || "",
|
|
22
|
+
branch: parsed.branch || "",
|
|
23
|
+
model: parsed.model || "",
|
|
24
|
+
startedAt: parsed.startedAt || new Date().toISOString(),
|
|
25
|
+
lastActivity: Date.now(),
|
|
26
|
+
active: true,
|
|
27
|
+
sessionFile: parsed.sessionFile || "",
|
|
28
|
+
gitDirty: parsed.gitDirty || false,
|
|
29
|
+
gitChanges: parsed.gitChanges || 0,
|
|
30
|
+
container: parsed.container || false,
|
|
31
|
+
contextWindow: parsed.contextWindow || 0,
|
|
32
|
+
diffPort: parsed.diffPort || null,
|
|
33
|
+
thinkingLevel: parsed.thinkingLevel || "medium",
|
|
34
|
+
name: parsed.name || undefined,
|
|
35
|
+
comsName: parsed.comsName || undefined,
|
|
36
|
+
comsPurpose: parsed.comsPurpose || undefined,
|
|
37
|
+
comsProject: parsed.comsProject || undefined,
|
|
38
|
+
...initialActivityState(),
|
|
39
|
+
activity,
|
|
40
|
+
activitySequence: Number.isSafeInteger(parsed.activitySequence) ? parsed.activitySequence : 0,
|
|
41
|
+
activityBeforePrompt: parsed.activityBeforePrompt === "working" || parsed.activityBeforePrompt === "waiting_for_input" || parsed.activityBeforePrompt === "idle" ? parsed.activityBeforePrompt : undefined,
|
|
42
|
+
streaming,
|
|
43
|
+
working: activity === "working",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerPidashSession(
|
|
48
|
+
sessions: Map<string, PiClient>,
|
|
49
|
+
ws: any,
|
|
50
|
+
parsed: any,
|
|
51
|
+
broadcast: (event: object) => void,
|
|
52
|
+
): PiClient {
|
|
53
|
+
const session = registeredSession(parsed);
|
|
54
|
+
const existing = sessions.get(session.sessionId);
|
|
55
|
+
let client: PiClient;
|
|
56
|
+
if (existing) {
|
|
57
|
+
if (!session.name && existing.session.name) session.name = existing.session.name;
|
|
58
|
+
if (!session.comsName && existing.session.comsName) session.comsName = existing.session.comsName;
|
|
59
|
+
if (!session.comsPurpose && existing.session.comsPurpose) session.comsPurpose = existing.session.comsPurpose;
|
|
60
|
+
if (!session.comsProject && existing.session.comsProject) session.comsProject = existing.session.comsProject;
|
|
61
|
+
existing.ws = ws;
|
|
62
|
+
existing.session = session;
|
|
63
|
+
existing.eventBuffer = [];
|
|
64
|
+
existing.replaying = true;
|
|
65
|
+
existing.connectionGeneration++;
|
|
66
|
+
client = existing;
|
|
67
|
+
} else {
|
|
68
|
+
client = { ws, session, eventBuffer: [], replaying: true, connectionGeneration: 1 };
|
|
69
|
+
}
|
|
70
|
+
sessions.set(session.sessionId, client);
|
|
71
|
+
log.info(`session registered: session=${session.sessionId} streaming=${session.streaming}`);
|
|
72
|
+
broadcast({ type: "session_added", session });
|
|
73
|
+
return client;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function disconnectPidashSession(
|
|
77
|
+
client: PiClient,
|
|
78
|
+
ws: any,
|
|
79
|
+
source: "close" | "error",
|
|
80
|
+
broadcast: (event: object) => void,
|
|
81
|
+
): void {
|
|
82
|
+
if (client.ws !== ws) {
|
|
83
|
+
log.debug(`stale connection ${source} ignored: session=${client.session.sessionId}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
client.session.active = false;
|
|
87
|
+
client.session.activity = "idle";
|
|
88
|
+
client.session.activityBeforePrompt = undefined;
|
|
89
|
+
client.session.streaming = false;
|
|
90
|
+
client.session.working = false;
|
|
91
|
+
client.ws = null;
|
|
92
|
+
log.info(`session disconnected: session=${client.session.sessionId} source=${source}`);
|
|
93
|
+
broadcast({ type: "session_updated", session: client.session });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createPidashSessionState(
|
|
97
|
+
broadcast: (event: object) => void,
|
|
98
|
+
sessions = new Map<string, PiClient>(),
|
|
99
|
+
) {
|
|
100
|
+
log.debug(`session state created: sessions=${sessions.size}`);
|
|
101
|
+
return {
|
|
102
|
+
sessions,
|
|
103
|
+
register: (ws: any, parsed: any) => registerPidashSession(sessions, ws, parsed, broadcast),
|
|
104
|
+
disconnect: (client: PiClient, ws: any, source: "close" | "error") => disconnectPidashSession(client, ws, source, broadcast),
|
|
105
|
+
};
|
|
106
|
+
}
|