pi-crew 0.11.1 → 0.11.2
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/CHANGELOG.md +39 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +193 -36
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -0
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +11 -2
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/run-dashboard.ts +98 -5
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
4
|
+
import { getCrewEnv } from "../config/env-vars.ts";
|
|
4
5
|
import { createCancellationToken } from "../runtime/process/cancellation-token.ts";
|
|
5
6
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
6
7
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
@@ -22,6 +23,95 @@ export interface PruneRunsOptions {
|
|
|
22
23
|
/** When true, compute the removal list WITHOUT deleting any state/artifacts,
|
|
23
24
|
* running worktree cleanup, or writing an audit. Non-destructive preview. */
|
|
24
25
|
dryRun?: boolean;
|
|
26
|
+
/** DP-01: never remove a finished run younger than this, even beyond
|
|
27
|
+
* top-keep. Evidence/incident runs must survive a session restart (the
|
|
28
|
+
* 2026-09-21 battery lost a morning of evidence to the next session start).
|
|
29
|
+
* 0 disables the floor. Defaults to the PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS
|
|
30
|
+
* value (24h) for auto-prune callers; manual prune passes 0 explicitly. */
|
|
31
|
+
ageFloorMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* DP-01: resolve the auto-prune keep count from PI_CREW_AUTO_PRUNE_KEEP.
|
|
36
|
+
* Invalid/negative/non-finite values fall back to 10 (the historical hard-coded
|
|
37
|
+
* value) with a logged warning — never a crash and never an infinite keep.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAutoPruneKeep(env: (name: string) => string | undefined = getCrewEnv): number {
|
|
40
|
+
const raw = env("PI_CREW_AUTO_PRUNE_KEEP")?.trim();
|
|
41
|
+
if (raw === undefined || raw === "") return 10;
|
|
42
|
+
const parsed = Number.parseInt(raw, 10);
|
|
43
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
44
|
+
logInternalError(
|
|
45
|
+
"prune.auto-keep-invalid",
|
|
46
|
+
new Error(`PI_CREW_AUTO_PRUNE_KEEP=${JSON.stringify(raw)} is not a non-negative integer; using 10`),
|
|
47
|
+
);
|
|
48
|
+
return 10;
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* DP-01: resolve the auto-prune age floor (ms) from
|
|
55
|
+
* PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS (default 24h; 0 disables).
|
|
56
|
+
*/
|
|
57
|
+
export function resolveAutoPruneAgeFloorMs(env: (name: string) => string | undefined = getCrewEnv): number {
|
|
58
|
+
const raw = env("PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS")?.trim();
|
|
59
|
+
if (raw === undefined || raw === "") return 24 * 60 * 60 * 1000;
|
|
60
|
+
const hours = Number.parseFloat(raw);
|
|
61
|
+
if (!Number.isFinite(hours) || hours < 0) {
|
|
62
|
+
logInternalError(
|
|
63
|
+
"prune.auto-age-floor-invalid",
|
|
64
|
+
new Error(`PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS=${JSON.stringify(raw)} is not a non-negative number; using 24`),
|
|
65
|
+
);
|
|
66
|
+
return 24 * 60 * 60 * 1000;
|
|
67
|
+
}
|
|
68
|
+
return hours * 60 * 60 * 1000;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** DP-01: rotate prune.jsonl when it exceeds this size (one generation kept). */
|
|
72
|
+
export const PRUNE_AUDIT_MAX_BYTES = 512 * 1024;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* DP-01: size-based rotation for the prune audit log. Best-effort: a rotation
|
|
76
|
+
* failure must never break the prune path (the audit is observability, not
|
|
77
|
+
* correctness). One generation is enough — the audit is for recent forensics
|
|
78
|
+
* (the 2026-09-21 storm RCA read exactly the current window).
|
|
79
|
+
*/
|
|
80
|
+
function rotatePruneAuditIfNeeded(filePath: string): void {
|
|
81
|
+
try {
|
|
82
|
+
if (!fs.existsSync(filePath)) return;
|
|
83
|
+
if (fs.statSync(filePath).size < PRUNE_AUDIT_MAX_BYTES) return;
|
|
84
|
+
fs.renameSync(filePath, `${filePath}.1`);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
logInternalError("prune.audit-rotate", error, `path=${filePath}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* DP-01: record a pruned FAILED/BLOCKED run's reason in a durable index that
|
|
92
|
+
* survives run-dir pruning. Turn-run dirs are aggressively pruned (keep=10 at
|
|
93
|
+
* session start); without this, a failure reason becomes unrecoverable (live
|
|
94
|
+
* incident goal_20260921111305 — see docs/specs/DP-01.md). Best-effort.
|
|
95
|
+
*/
|
|
96
|
+
function appendPrunedFailureIndex(cwd: string, run: TeamRunManifest): void {
|
|
97
|
+
try {
|
|
98
|
+
const crewRoot = projectCrewRoot(cwd);
|
|
99
|
+
if (!fs.existsSync(crewRoot)) return;
|
|
100
|
+
const filePath = path.join(crewRoot, "state", "pruned-failures.jsonl");
|
|
101
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
102
|
+
const entry = {
|
|
103
|
+
runId: run.runId,
|
|
104
|
+
status: run.status,
|
|
105
|
+
summary: (run.summary ?? "").split("\n")[0]?.slice(0, 500),
|
|
106
|
+
goal: (run.goal ?? "").split("\n")[0]?.slice(0, 200),
|
|
107
|
+
team: run.team,
|
|
108
|
+
createdAt: run.createdAt,
|
|
109
|
+
prunedAt: new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(entry))}\n`, "utf-8");
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logInternalError("prune.failure-index-write", error, `runId=${run.runId}`);
|
|
114
|
+
}
|
|
25
115
|
}
|
|
26
116
|
|
|
27
117
|
/**
|
|
@@ -98,8 +188,19 @@ function isSafeToPrune(cwd: string, run: TeamRunManifest): boolean {
|
|
|
98
188
|
|
|
99
189
|
function appendPruneAudit(cwd: string, payload: Record<string, unknown>): string | undefined {
|
|
100
190
|
try {
|
|
101
|
-
|
|
191
|
+
// RR-020 Fix 2: a no-op prune must NOT materialize the project crew
|
|
192
|
+
// root. `<crewRoot>/audit/` only exists because a real prune removed
|
|
193
|
+
// runs, and a real prune implies the crew root (+ state/runs) exists —
|
|
194
|
+
// so bail out instead of mkdir-ing a `<crewRoot>/audit/` tree on
|
|
195
|
+
// session start for a project that never ran a team.
|
|
196
|
+
const crewRoot = projectCrewRoot(cwd);
|
|
197
|
+
if (!fs.existsSync(crewRoot)) return undefined;
|
|
198
|
+
const filePath = path.join(crewRoot, "audit", "prune.jsonl");
|
|
102
199
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
200
|
+
// DP-01: rotate the audit log before it grows unbounded (it reached
|
|
201
|
+
// 720 KB / 1,436 entries with no cap). Keep the current file under
|
|
202
|
+
// PRUNE_AUDIT_MAX_BYTES; older entries rotate to .1 (one generation).
|
|
203
|
+
rotatePruneAuditIfNeeded(filePath);
|
|
103
204
|
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ ...payload, auditedAt: new Date().toISOString() }))}\n`, "utf-8");
|
|
104
205
|
return filePath;
|
|
105
206
|
} catch (error) {
|
|
@@ -115,7 +216,18 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
|
|
|
115
216
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
116
217
|
const kept = finished.slice(0, keep).map((run) => run.runId);
|
|
117
218
|
const removed: string[] = [];
|
|
118
|
-
|
|
219
|
+
// DP-01: age floor — a finished run younger than the floor is NEVER removed
|
|
220
|
+
// by auto-prune, even beyond top-keep. Manual prune passes 0 (no floor).
|
|
221
|
+
const ageFloorMs = options.ageFloorMs ?? 0;
|
|
222
|
+
const floorCutoff = ageFloorMs > 0 ? Date.now() - ageFloorMs : 0;
|
|
223
|
+
const toRemove = finished.slice(keep).filter((run) => {
|
|
224
|
+
if (floorCutoff === 0) return true;
|
|
225
|
+
const ts = Date.parse(run.updatedAt ?? run.createdAt ?? "");
|
|
226
|
+
// Unparseable timestamps are treated as OLD (removable) to preserve
|
|
227
|
+
// pre-DP-01 behavior for malformed manifests.
|
|
228
|
+
if (!Number.isFinite(ts)) return true;
|
|
229
|
+
return ts < floorCutoff;
|
|
230
|
+
});
|
|
119
231
|
for (let i = 0; i < toRemove.length; i++) {
|
|
120
232
|
if (i % 5 === 0) token.heartbeat(`prune:${i}/${toRemove.length}`);
|
|
121
233
|
const run = toRemove[i];
|
|
@@ -158,6 +270,9 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
|
|
|
158
270
|
}
|
|
159
271
|
fs.rmSync(run.stateRoot, { recursive: true, force: true });
|
|
160
272
|
fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
|
|
273
|
+
// DP-01: preserve the reason of a pruned failed/blocked run in a durable
|
|
274
|
+
// index (the run dir that held it is gone now).
|
|
275
|
+
if (run.status === "failed" || run.status === "blocked") appendPrunedFailureIndex(cwd, run);
|
|
161
276
|
removed.push(run.runId);
|
|
162
277
|
}
|
|
163
278
|
// ST-6: Sweep stale .corrupt-* quarantine files to prevent unbounded growth.
|
|
@@ -165,6 +280,11 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
|
|
|
165
280
|
sweepStaleCorruptFiles(path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir));
|
|
166
281
|
}
|
|
167
282
|
|
|
283
|
+
// RR-020 Fix 2 note: appendPruneAudit itself bails when the crew root does
|
|
284
|
+
// not exist, so a no-op prune on a fresh project writes nothing. When the
|
|
285
|
+
// crew root DOES exist, HEAD parity is preserved — a zero-candidate prune
|
|
286
|
+
// still records its `kept:[] removed:[]` audit line (cold-verify correction:
|
|
287
|
+
// the earlier `finished.length === 0` bail suppressed that line).
|
|
168
288
|
const auditPath = options.dryRun
|
|
169
289
|
? undefined
|
|
170
290
|
: appendPruneAudit(cwd, {
|
|
@@ -186,7 +306,7 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
|
|
|
186
306
|
* @param keep Number of most recent finished runs to retain
|
|
187
307
|
* @returns kept and removed run IDs
|
|
188
308
|
*/
|
|
189
|
-
export function pruneUserLevelRuns(keep: number): PruneRunsResult {
|
|
309
|
+
export function pruneUserLevelRuns(keep: number, options: PruneRunsOptions = {}): PruneRunsResult {
|
|
190
310
|
const crewRoot = userCrewRoot();
|
|
191
311
|
const runsRoot = path.join(crewRoot, DEFAULT_PATHS.state.runsSubdir);
|
|
192
312
|
if (!fs.existsSync(runsRoot)) return { kept: [], removed: [] };
|
|
@@ -195,7 +315,12 @@ export function pruneUserLevelRuns(keep: number): PruneRunsResult {
|
|
|
195
315
|
const MAX_DIRS = 500;
|
|
196
316
|
const finished: Array<{
|
|
197
317
|
runId: string;
|
|
198
|
-
updatedAt
|
|
318
|
+
updatedAt?: string;
|
|
319
|
+
createdAt?: string;
|
|
320
|
+
status?: string;
|
|
321
|
+
summary?: string;
|
|
322
|
+
goal?: string;
|
|
323
|
+
team?: string;
|
|
199
324
|
stateRoot: string;
|
|
200
325
|
artifactsRoot: string;
|
|
201
326
|
}> = [];
|
|
@@ -240,18 +365,43 @@ export function pruneUserLevelRuns(keep: number): PruneRunsResult {
|
|
|
240
365
|
finished.push({
|
|
241
366
|
runId: manifest.runId,
|
|
242
367
|
updatedAt: manifest.updatedAt,
|
|
368
|
+
createdAt: manifest.createdAt,
|
|
369
|
+
status: manifest.status,
|
|
370
|
+
summary: manifest.summary,
|
|
371
|
+
goal: manifest.goal,
|
|
372
|
+
team: manifest.team,
|
|
243
373
|
stateRoot: manifest.stateRoot,
|
|
244
374
|
artifactsRoot: manifest.artifactsRoot,
|
|
245
375
|
});
|
|
246
376
|
}
|
|
247
377
|
|
|
248
|
-
// Sort newest first, keep top N, remove the rest
|
|
378
|
+
// Sort newest first, keep top N, remove the rest.
|
|
249
379
|
finished.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
250
380
|
const kept = finished.slice(0, keep).map((r) => r.runId);
|
|
251
381
|
const removed: string[] = [];
|
|
382
|
+
// DP-01: same age floor as the project-level prune (user-level runs are
|
|
383
|
+
// equally easy to lose — the 2026-09-21 incident evidence lived here too).
|
|
384
|
+
const ageFloorMs = options.ageFloorMs ?? 0;
|
|
385
|
+
const floorCutoff = ageFloorMs > 0 ? Date.now() - ageFloorMs : 0;
|
|
252
386
|
for (const run of finished.slice(keep)) {
|
|
387
|
+
if (floorCutoff > 0) {
|
|
388
|
+
const ts = Date.parse(run.updatedAt ?? run.createdAt ?? "");
|
|
389
|
+
if (Number.isFinite(ts) && ts >= floorCutoff) continue; // too young — protect
|
|
390
|
+
}
|
|
253
391
|
fs.rmSync(run.stateRoot, { recursive: true, force: true });
|
|
254
392
|
fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
|
|
393
|
+
// DP-01: preserve a pruned failed/blocked run's reason (the user-level
|
|
394
|
+
// index lives under the user crew root).
|
|
395
|
+
if (run.status === "failed" || run.status === "blocked") {
|
|
396
|
+
appendPrunedFailureIndex(crewRoot, {
|
|
397
|
+
runId: run.runId,
|
|
398
|
+
status: run.status,
|
|
399
|
+
summary: run.summary,
|
|
400
|
+
goal: run.goal,
|
|
401
|
+
team: run.team,
|
|
402
|
+
createdAt: run.createdAt,
|
|
403
|
+
} as TeamRunManifest);
|
|
404
|
+
}
|
|
255
405
|
removed.push(run.runId);
|
|
256
406
|
}
|
|
257
407
|
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* status (16): status, list, get, events, artifacts, summary, graph, search,
|
|
11
11
|
* health, worktrees, checkpoint, cache, explain, onboard, recommend, help
|
|
12
12
|
* control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
|
|
13
|
-
* manage (
|
|
14
|
-
* workflow-create/get/list/save/delete, import, imports, export
|
|
13
|
+
* manage (17): create, update, delete, init, config, validate, autonomy, settings,
|
|
14
|
+
* workflow-create/get/list/save/delete, import, imports, export, compare
|
|
15
15
|
* automate(6): schedule, scheduled, anchor, auto-summarize, auto_boomerang, api
|
|
16
16
|
*/
|
|
17
17
|
import type { TeamDomain } from "../../../schema/team-tool-schema.ts";
|
|
@@ -80,6 +80,7 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
|
|
|
80
80
|
import: "manage",
|
|
81
81
|
imports: "manage",
|
|
82
82
|
export: "manage",
|
|
83
|
+
compare: "manage",
|
|
83
84
|
|
|
84
85
|
// automate domain (6)
|
|
85
86
|
schedule: "automate",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Actions: create, update, delete, init, config, validate, autonomy, settings,
|
|
5
5
|
* workflow-create, workflow-get, workflow-list, workflow-save, workflow-delete,
|
|
6
|
-
* import, imports, export.
|
|
6
|
+
* import, imports, export, compare.
|
|
7
7
|
*
|
|
8
8
|
* Inline cases (init, autonomy, config, validate) are moved verbatim from the
|
|
9
9
|
* former handleTeamTool switch.
|
|
@@ -18,7 +18,7 @@ import { formatValidationReport, validateResources } from "../../validate-resour
|
|
|
18
18
|
import { autonomousPatchFromConfig, configPatchFromConfig, formatAutonomyStatus } from "../config-patch.ts";
|
|
19
19
|
import { configRecord, result, type TeamContext } from "../context.ts";
|
|
20
20
|
import { handleSettings } from "../handle-settings.ts";
|
|
21
|
-
import { handleExport, handleImport, handleImports } from "../lifecycle-actions.ts";
|
|
21
|
+
import { handleCompare, handleExport, handleImport, handleImports } from "../lifecycle-actions.ts";
|
|
22
22
|
import {
|
|
23
23
|
handleWorkflowCreate,
|
|
24
24
|
handleWorkflowDelete,
|
|
@@ -51,6 +51,7 @@ export const MANAGE_DOMAIN_ACTIONS = [
|
|
|
51
51
|
"import",
|
|
52
52
|
"imports",
|
|
53
53
|
"export",
|
|
54
|
+
"compare",
|
|
54
55
|
] as const;
|
|
55
56
|
type ManageDomainAction = (typeof MANAGE_DOMAIN_ACTIONS)[number];
|
|
56
57
|
|
|
@@ -161,6 +162,8 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
161
162
|
}
|
|
162
163
|
case "export":
|
|
163
164
|
return handleExport(params, ctx);
|
|
165
|
+
case "compare":
|
|
166
|
+
return handleCompare(params, ctx);
|
|
164
167
|
case "import":
|
|
165
168
|
return handleImport(params, ctx);
|
|
166
169
|
case "imports":
|
|
@@ -23,6 +23,7 @@ import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
|
23
23
|
import { createRunPaths, saveRunManifestAsync } from "../../state/stores/state-store.ts";
|
|
24
24
|
import type { GoalLoopState, GoalLoopStatus, TeamRunManifest } from "../../state/types.ts";
|
|
25
25
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
26
|
+
import { projectCrewRoot } from "../../utils/paths.ts";
|
|
26
27
|
import { result, type TeamContext } from "./context.ts";
|
|
27
28
|
import { paramRequired } from "./param-error.ts";
|
|
28
29
|
|
|
@@ -200,7 +201,9 @@ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof
|
|
|
200
201
|
goalId,
|
|
201
202
|
objective,
|
|
202
203
|
maxTurns,
|
|
203
|
-
|
|
204
|
+
// RR-020 Fix 4: resolve the layout (`<crewRoot>/state/goals/...`) instead
|
|
205
|
+
// of hardcoding `.crew` — GoalStore writes to projectCrewRoot(cwd).
|
|
206
|
+
statePath: `${projectCrewRoot(cwd)}/state/goals/${goalId}.json`,
|
|
204
207
|
},
|
|
205
208
|
});
|
|
206
209
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadConfig, updateConfig } from "../../config/config.ts";
|
|
2
2
|
import { suggestConfigKey } from "../../config/suggestions.ts";
|
|
3
|
+
import { collectSensitiveConfigPaths } from "../../schema/sensitive-config-paths.ts";
|
|
3
4
|
import { discoverPiThemes, formatThemesListing, setPiTheme } from "../../ui/theme-discovery.ts";
|
|
4
5
|
import { configPatchFromConfig } from "../team-tool/config-patch.ts";
|
|
5
6
|
import type { TeamContext } from "../team-tool/context.ts";
|
|
@@ -214,6 +215,12 @@ const KNOWN_KEYS = new Set([
|
|
|
214
215
|
"notifications.enabled",
|
|
215
216
|
"notifications.severityFilter",
|
|
216
217
|
"notifications.dedupWindowMs",
|
|
218
|
+
"notifications.quietHours",
|
|
219
|
+
"notifications.webhook",
|
|
220
|
+
"notifications.webhook.url",
|
|
221
|
+
"notifications.webhook.enabled",
|
|
222
|
+
"notifications.webhook.secret",
|
|
223
|
+
"notifications.webhook.allowLocalhost",
|
|
217
224
|
"notifications.batchWindowMs",
|
|
218
225
|
"notifications.quietHours",
|
|
219
226
|
"notifications.sinkRetentionDays",
|
|
@@ -459,10 +466,19 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
459
466
|
if (suggestion) note = `\n(did you mean '${suggestion}'?)`;
|
|
460
467
|
else note = "\n(unknown key — may not take effect)";
|
|
461
468
|
}
|
|
462
|
-
|
|
469
|
+
// US-030 security review: a value whose dotted path is schema-marked
|
|
470
|
+
// sensitive (webhook.secret, otlp.headers, …) must not be echoed into
|
|
471
|
+
// the session transcript — any in-session agent can read tool output.
|
|
472
|
+
const sensitive = collectSensitiveConfigPaths().some((path) => key === path || key.startsWith(`${path}.`));
|
|
473
|
+
const rendered = sensitive
|
|
474
|
+
? value === undefined || value === null
|
|
475
|
+
? "<not set>"
|
|
476
|
+
: "***(redacted — sensitive)"
|
|
477
|
+
: formatValue(value, key);
|
|
478
|
+
return result(`${key} = ${rendered}${note}`, {
|
|
463
479
|
...OK,
|
|
464
480
|
key,
|
|
465
|
-
value,
|
|
481
|
+
value: sensitive ? undefined : value,
|
|
466
482
|
} as never);
|
|
467
483
|
}
|
|
468
484
|
|
|
@@ -516,6 +532,7 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
516
532
|
// Check if project config would sanitize this key
|
|
517
533
|
if (scope === "project") {
|
|
518
534
|
const sensitiveKeys = [
|
|
535
|
+
"notifications.webhook",
|
|
519
536
|
"executeWorkers",
|
|
520
537
|
"asyncByDefault",
|
|
521
538
|
"runtime.mode",
|
|
@@ -9,6 +9,7 @@ import { hasStaleAsyncProcess, isActiveRunStatus, isLikelyOrphanedActiveRun } fr
|
|
|
9
9
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
10
10
|
import { loadTasksWithRecovery } from "../../state/stores/state-store.ts";
|
|
11
11
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
12
|
+
import { findRunStateDir } from "../../utils/paths.ts";
|
|
12
13
|
import { listRuns } from "../run-index.ts";
|
|
13
14
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
14
15
|
import { result, type TeamContext } from "./context.ts";
|
|
@@ -115,7 +116,8 @@ export function detectStuckTasks(run: { runId: string; stateRoot: string }, now:
|
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
118
|
* Scan a directory for "pi-crew-*" subdirs that contain
|
|
118
|
-
*
|
|
119
|
+
* `.crew/state/runs/` OR `.pi/teams/state/runs/` with at least one valid run
|
|
120
|
+
* manifest (both supported layouts — RR-020 Fix 3).
|
|
119
121
|
* Read-only — does NOT mutate anything.
|
|
120
122
|
* Pattern adapted from stale-reconciler.ts:reconcileOrphanedTempWorkspaces().
|
|
121
123
|
*/
|
|
@@ -128,12 +130,19 @@ export function scanZombieTempWorkspaces(tmpDir: string, now: number): ZombieWor
|
|
|
128
130
|
for (const entry of entries) {
|
|
129
131
|
if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-")) continue;
|
|
130
132
|
const workspaceDir = path.join(tmpDir, entry.name);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
// RR-020 Fix 3: both layouts (`.crew` and `.pi/teams`) are recognised —
|
|
134
|
+
// checking only `.crew` made a `.pi/teams` workspace invisible as a
|
|
135
|
+
// zombie (and eligible for debris deletion).
|
|
136
|
+
const stateRunsDir = findRunStateDir(workspaceDir);
|
|
137
|
+
if (!stateRunsDir) continue;
|
|
133
138
|
|
|
134
139
|
let runCount = 0;
|
|
135
140
|
try {
|
|
136
|
-
|
|
141
|
+
// Cold-verify F-2: Dirent.isDirectory() is FALSE for a symlink-to-dir,
|
|
142
|
+
// so a planted `runs/<runId>` symlink is skipped (no out-of-tree read).
|
|
143
|
+
for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
|
|
144
|
+
if (!entry.isDirectory()) continue;
|
|
145
|
+
const runDir = entry.name;
|
|
137
146
|
const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
|
|
138
147
|
if (fs.existsSync(manifestPath)) {
|
|
139
148
|
try {
|
|
@@ -194,11 +203,16 @@ export function collectTempWorkspaceRuns(
|
|
|
194
203
|
const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
|
|
195
204
|
for (const entry of entries) {
|
|
196
205
|
if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-")) continue;
|
|
197
|
-
|
|
198
|
-
|
|
206
|
+
// RR-020 Fix 3: both layouts (`.crew` and `.pi/teams`).
|
|
207
|
+
const stateRunsDir = findRunStateDir(path.join(tmpDir, entry.name));
|
|
208
|
+
if (!stateRunsDir) continue;
|
|
199
209
|
|
|
200
210
|
try {
|
|
201
|
-
|
|
211
|
+
// Cold-verify F-2: symlinked `runs/<runId>` entries are not directories
|
|
212
|
+
// per Dirent (withFileTypes does not follow) — skipped.
|
|
213
|
+
for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
|
|
214
|
+
if (!entry.isDirectory()) continue;
|
|
215
|
+
const runDir = entry.name;
|
|
202
216
|
const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
|
|
203
217
|
if (!fs.existsSync(manifestPath)) continue;
|
|
204
218
|
try {
|
|
@@ -13,6 +13,7 @@ import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
|
13
13
|
import { sleep } from "../../utils/sleep.ts";
|
|
14
14
|
import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
|
|
15
15
|
import { listImportedRuns } from "../import-index.ts";
|
|
16
|
+
import { runCompareBundle } from "../run-compare.ts";
|
|
16
17
|
import { exportRunBundle } from "../run-export.ts";
|
|
17
18
|
import { importRunBundle } from "../run-import.ts";
|
|
18
19
|
import { pruneFinishedRuns } from "../run-maintenance.ts";
|
|
@@ -144,6 +145,49 @@ export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
144
145
|
);
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
// US-021 (2026-09-22): compare two runs (before/after a fix, two attempts at
|
|
149
|
+
// the same goal). Pure diff logic lives in run-compare.ts; this handler owns
|
|
150
|
+
// param validation, ownership security (mirrors handleExport) and the
|
|
151
|
+
// no-partial-artifact guarantee (both runs load BEFORE anything is written).
|
|
152
|
+
export function handleCompare(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
153
|
+
const raw = params.runIds ?? [];
|
|
154
|
+
const runIds = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string" && id !== "") : [];
|
|
155
|
+
if (runIds.length !== 2)
|
|
156
|
+
return result(
|
|
157
|
+
paramRequired("compare", "runIds", "{ action: 'compare', runIds: ['team_a', 'team_b'] }"),
|
|
158
|
+
{ action: "compare", status: "error" },
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
const a = loadRunManifestById(ctx.cwd, runIds[0]);
|
|
162
|
+
if (!a) return result(`Run '${runIds[0]}' not found.${RUN_NOT_FOUND_HINT}`, { action: "compare", status: "error" }, true);
|
|
163
|
+
const b = loadRunManifestById(ctx.cwd, runIds[1]);
|
|
164
|
+
if (!b) return result(`Run '${runIds[1]}' not found.${RUN_NOT_FOUND_HINT}`, { action: "compare", status: "error" }, true);
|
|
165
|
+
|
|
166
|
+
// SECURITY: Ownership check — mirrors handleExport. A comparison reveals
|
|
167
|
+
// another session's task statuses, costs and event types; foreign-run
|
|
168
|
+
// comparison therefore requires confirm: true (explicit user intent).
|
|
169
|
+
for (const loaded of [a, b]) {
|
|
170
|
+
const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
|
|
171
|
+
if (foreignRun && !params.confirm)
|
|
172
|
+
return result(
|
|
173
|
+
`Run ${loaded.manifest.runId} belongs to another session. Use confirm: true to compare anyway.`,
|
|
174
|
+
{ action: "compare", status: "error", runId: loaded.manifest.runId },
|
|
175
|
+
true,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { comparison, markdownPath } = runCompareBundle(a.manifest, a.tasks, b.manifest, b.tasks);
|
|
180
|
+
return result(
|
|
181
|
+
[
|
|
182
|
+
`Compared ${a.manifest.runId} vs ${b.manifest.runId}: ${comparison.identical ? "identical" : "different"}.`,
|
|
183
|
+
`Tasks: ${comparison.tasks.length} compared, ${comparison.tasksOnlyInA.length} only in a, ${comparison.tasksOnlyInB.length} only in b.`,
|
|
184
|
+
`Tokens Δ: ${comparison.usage.tokensDelta >= 0 ? "+" : ""}${comparison.usage.tokensDelta}, Cost Δ: ${comparison.usage.costDelta >= 0 ? "+" : ""}${comparison.usage.costDelta.toFixed(4)}`,
|
|
185
|
+
`Markdown: ${markdownPath}`,
|
|
186
|
+
].join("\n"),
|
|
187
|
+
{ action: "compare", status: "ok", runId: a.manifest.runId },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
147
191
|
// Note: handlePrune has no ownership check — intentionally cross-session.
|
|
148
192
|
// Prune is a maintenance-level operation that removes ALL finished runs
|
|
149
193
|
// regardless of which session created them. Requires confirm: true.
|
|
@@ -425,7 +469,11 @@ function handleProjectCleanup(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
425
469
|
lines.push(` - ERROR: could not resolve ${crewRoot} (skipped)`);
|
|
426
470
|
return result(lines.join("\n"), { action: "cleanup", status: "ok", scope }, false);
|
|
427
471
|
}
|
|
428
|
-
|
|
472
|
+
// RR-020 A0-4 (round-4 sweep): `"/teams"` hardcoded the POSIX separator,
|
|
473
|
+
// so on Windows the `.pi\teams` layout never matched and cleanup REFUSED
|
|
474
|
+
// to remove it (fail-closed, but the feature was broken there).
|
|
475
|
+
// `path.sep + "teams"` is identical on POSIX and correct on Windows.
|
|
476
|
+
if (!resolved.endsWith(path.sep + ".crew") && !resolved.endsWith(path.sep + "teams") && path.basename(resolved) !== ".crew") {
|
|
429
477
|
lines.push(` - ERROR: refused to remove ${resolved} (does not look like a .crew dir) — skipped`);
|
|
430
478
|
} else {
|
|
431
479
|
if (!dryRun) {
|
|
@@ -5,6 +5,7 @@ import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workfl
|
|
|
5
5
|
import { validateWorkflowForTeam } from "../../workflows/validate-workflow.ts";
|
|
6
6
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
7
7
|
import { result, type TeamContext } from "./context.ts";
|
|
8
|
+
import { resolveRoutingHint } from "./routing-hint.ts";
|
|
8
9
|
|
|
9
10
|
export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
10
11
|
const teamName = params.team ?? "default";
|
|
@@ -21,6 +22,12 @@ export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTea
|
|
|
21
22
|
true,
|
|
22
23
|
);
|
|
23
24
|
const goal = params.goal ?? params.task ?? "(not provided)";
|
|
25
|
+
// DP-04: small-goal routing hint (suggest-only by default; never overrides
|
|
26
|
+
// an explicit team/workflow choice). See routing-hint.ts for the rationale.
|
|
27
|
+
const routingHint = resolveRoutingHint({
|
|
28
|
+
goal,
|
|
29
|
+
explicitOverride: params.team !== undefined || params.workflow !== undefined,
|
|
30
|
+
});
|
|
24
31
|
// ROADMAP T2.2: single-agent composition mode (cliff hedge).
|
|
25
32
|
if (params.singleAgent) {
|
|
26
33
|
const composed = composeSingleAgentPrompt(workflow, goal);
|
|
@@ -46,5 +53,8 @@ export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTea
|
|
|
46
53
|
`${index + 1}. ${step.id} [${step.role}]${step.dependsOn?.length ? ` after ${step.dependsOn.join(", ")}` : ""}`,
|
|
47
54
|
),
|
|
48
55
|
];
|
|
56
|
+
if (routingHint.suggested && routingHint.message) {
|
|
57
|
+
lines.push("", routingHint.message);
|
|
58
|
+
}
|
|
49
59
|
return result(lines.join("\n"), { action: "plan", status: "ok" });
|
|
50
60
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DP-04 (2026-09-22): small-goal routing hint.
|
|
3
|
+
*
|
|
4
|
+
* Measured: a trivial goal through the default team burns 7.6–9k tokens just on
|
|
5
|
+
* the 3 context-setup steps (assess → execute → verify). The engine ALREADY
|
|
6
|
+
* supports `singleAgent: true` (handlePlan → composeSingleAgentPrompt); nothing
|
|
7
|
+
* suggested it for small goals.
|
|
8
|
+
*
|
|
9
|
+
* This module decides the hint. It is deliberately PURE and explainable — no
|
|
10
|
+
* ML, no hidden state — so the routing decision can be asserted in tests and
|
|
11
|
+
* audited from the plan output. Default mode is "suggest" (visible nudge, the
|
|
12
|
+
* caller/agent still decides), NOT a silent switch: this project has been
|
|
13
|
+
* burned by silent routing changes before (the auto-boomerang aliasing trap),
|
|
14
|
+
* so the decision must be visible.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type SmallGoalRoutingMode = "off" | "suggest" | "auto";
|
|
18
|
+
|
|
19
|
+
/** Goals at or below this many whitespace tokens are considered "small". */
|
|
20
|
+
export const SMALL_GOAL_TOKEN_THRESHOLD = 40;
|
|
21
|
+
|
|
22
|
+
export interface RoutingHintInput {
|
|
23
|
+
goal: string;
|
|
24
|
+
/** True when the caller supplied an explicit team and/or workflow override. */
|
|
25
|
+
explicitOverride: boolean;
|
|
26
|
+
/** Configured mode; defaults to "suggest". */
|
|
27
|
+
mode?: SmallGoalRoutingMode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoutingHint {
|
|
31
|
+
/** Whether a cheaper single-agent composition is advised. */
|
|
32
|
+
suggested: boolean;
|
|
33
|
+
/** Machine-readable reason (empty when not suggested). */
|
|
34
|
+
reason?: string;
|
|
35
|
+
/** Human-readable, shown in the plan output. */
|
|
36
|
+
message?: string;
|
|
37
|
+
/** Rough token saving estimate for the plan text (informational). */
|
|
38
|
+
estimatedSavings?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Cheap, deterministic token count (whitespace split) — no tokenizer dep. */
|
|
42
|
+
export function goalTokenCount(goal: string): number {
|
|
43
|
+
const trimmed = goal.trim();
|
|
44
|
+
if (trimmed.length === 0) return 0;
|
|
45
|
+
return trimmed.split(/\s+/).length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveRoutingHint(input: RoutingHintInput): RoutingHint {
|
|
49
|
+
const mode = input.mode ?? "suggest";
|
|
50
|
+
if (mode === "off") return { suggested: false };
|
|
51
|
+
// Never second-guess an explicit team/workflow choice.
|
|
52
|
+
if (input.explicitOverride) return { suggested: false };
|
|
53
|
+
const tokens = goalTokenCount(input.goal);
|
|
54
|
+
if (tokens === 0 || tokens > SMALL_GOAL_TOKEN_THRESHOLD) return { suggested: false };
|
|
55
|
+
return {
|
|
56
|
+
suggested: true,
|
|
57
|
+
reason: `goal is small (${tokens} tokens ≤ ${SMALL_GOAL_TOKEN_THRESHOLD})`,
|
|
58
|
+
message:
|
|
59
|
+
`Hint: this goal is small (${tokens} tokens). The default team runs 3 context-setup phases ` +
|
|
60
|
+
`(~8k tokens overhead) before real work — consider \`singleAgent: true\` (or a 2-step chain) to skip them.`,
|
|
61
|
+
estimatedSavings: "~5-6k tokens",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.ts";
|
|
|
2
2
|
import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
|
|
3
3
|
import { extractCommandTrace } from "../../runtime/command-trace.ts";
|
|
4
4
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
5
|
+
import { deadletterStatusLine } from "../../runtime/deadletter.ts";
|
|
5
6
|
import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
|
|
6
7
|
import { computePhaseProgress } from "../../runtime/phase-progress.ts";
|
|
7
8
|
import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
|
|
@@ -150,6 +151,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
150
151
|
}
|
|
151
152
|
const counts = new Map<string, number>();
|
|
152
153
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
154
|
+
const deadletterLine = deadletterStatusLine(manifest);
|
|
153
155
|
const phaseProgress = computePhaseProgress(tasks);
|
|
154
156
|
// PERF (2026-08-24): intentionally NOT passing `limit` here — readEventsCursor's
|
|
155
157
|
// limit is a HEAD cap (oldest-first slice for streaming pagination), not a tail
|
|
@@ -267,6 +269,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
267
269
|
)
|
|
268
270
|
: ["- (none)"]),
|
|
269
271
|
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
272
|
+
// US-003 AC-3: surface exhausted-retry failures — only when entries exist.
|
|
273
|
+
...(deadletterLine ? [deadletterLine] : []),
|
|
270
274
|
"Effectiveness:",
|
|
271
275
|
`- observable=${effectiveness.observable}/${Math.max(1, effectiveness.completed)} completed tasks`,
|
|
272
276
|
`- workerExecution=${effectiveness.workerExecution} guard=${effectiveness.guardMode} severity=${effectiveness.severity}`,
|