pi-crew 0.11.0 → 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 +155 -9
- package/README.md +161 -1037
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -90644
- 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 +39 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +371 -34
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -2
- package/src/config/config.ts +47 -13
- package/src/config/defaults.ts +0 -1
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -5
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/crew-vibes/config.ts +0 -21
- package/src/extension/crew-vibes/index.ts +0 -2
- package/src/extension/crew-vibes/render.ts +1 -50
- package/src/extension/help.ts +21 -12
- 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/register.ts +7 -2
- 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 +36 -19
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -1
- 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/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +6 -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 +33 -4
- 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-display.ts +1 -1
- 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 +18 -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/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +70 -64
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +55 -14
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/crew-editor.ts +13 -3
- package/src/ui/inline-panel/index.ts +60 -4
- package/src/ui/keybinding-map.ts +251 -35
- package/src/ui/live-conversation-overlay.ts +180 -47
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +32 -16
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -29
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +193 -79
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/settings-overlay.ts +81 -39
- package/src/ui/spinner.ts +26 -2
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +128 -9
- package/src/ui/tool-renderers/brief-mode.ts +10 -67
- package/src/ui/tool-renderers/index.ts +374 -523
- package/src/ui/transcript-viewer.ts +30 -12
- package/src/ui/widget/index.ts +32 -52
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -402
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +201 -128
- package/src/ui/widget/widget-types.ts +0 -2
- 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/utils/visual.ts +0 -4
- 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
- package/src/extension/crew-vibes/figures.ts +0 -22
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
|
@@ -5,6 +5,8 @@ import * as path from "node:path";
|
|
|
5
5
|
import { readEvents, type TeamEvent } from "../state/event-log/event-log.ts";
|
|
6
6
|
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
7
7
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
8
|
+
import { aggregateUsage, formatCost, formatTokens } from "../state/usage.ts";
|
|
9
|
+
import { formatDuration } from "../ui/format-helpers.ts";
|
|
8
10
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
9
11
|
|
|
10
12
|
/** Replace absolute paths containing home directory with ~/ */
|
|
@@ -36,14 +38,30 @@ export interface ExportedRunBundle {
|
|
|
36
38
|
artifactPaths: string[];
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Export a run bundle (JSON + markdown report).
|
|
43
|
+
*
|
|
44
|
+
* US-022 (2026-09-22): `now` is injectable so repeated exports of the same run
|
|
45
|
+
* are byte-identical. `exportedAt` sits INSIDE the hashed payload, so a
|
|
46
|
+
* wall-clock stamp made both the JSON and its sha256 (and the markdown
|
|
47
|
+
* `Exported:` line) drift on every export — harmless for import (run-import
|
|
48
|
+
* recomputes the hash minus `sha256`) but fatal for byte-diffing and for
|
|
49
|
+
* reproducing a bundle in tests. Mirrors `createRunManifest`'s `now?: () => Date`.
|
|
50
|
+
*/
|
|
51
|
+
export function exportRunBundle(
|
|
52
|
+
manifest: TeamRunManifest,
|
|
53
|
+
tasks: TeamTaskState[],
|
|
54
|
+
now?: () => Date,
|
|
55
|
+
): { jsonPath: string; markdownPath: string } {
|
|
40
56
|
const events = readEvents(manifest.eventsPath);
|
|
41
57
|
const safeManifest = redactHomePaths(manifest);
|
|
42
58
|
const safeTasks = redactHomePaths(tasks);
|
|
43
59
|
const safeEvents = redactHomePaths(events);
|
|
60
|
+
// US-022: aggregate usage once (tasks already in hand) for the Cost section.
|
|
61
|
+
const usageTotal = aggregateUsage(safeTasks as TeamTaskState[]);
|
|
44
62
|
const bundle: ExportedRunBundle = {
|
|
45
63
|
schemaVersion: 1,
|
|
46
|
-
exportedAt: new Date().toISOString(),
|
|
64
|
+
exportedAt: (now ? now() : new Date()).toISOString(),
|
|
47
65
|
manifest: safeManifest as TeamRunManifest,
|
|
48
66
|
tasks: safeTasks as TeamTaskState[],
|
|
49
67
|
events: safeEvents as TeamEvent[],
|
|
@@ -71,10 +89,24 @@ export function exportRunBundle(manifest: TeamRunManifest, tasks: TeamTaskState[
|
|
|
71
89
|
`Workflow: ${safeManifest.workflow ?? "(none)"}`,
|
|
72
90
|
`Goal: ${safeManifest.goal}`,
|
|
73
91
|
"",
|
|
92
|
+
"## Cost",
|
|
93
|
+
...(usageTotal
|
|
94
|
+
? [
|
|
95
|
+
`- tokens: ${formatTokens((usageTotal.input ?? 0) + (usageTotal.output ?? 0))} (in ${formatTokens(usageTotal.input ?? 0)} / out ${formatTokens(usageTotal.output ?? 0)})`,
|
|
96
|
+
`- cost: ${formatCost(usageTotal.cost)}`,
|
|
97
|
+
]
|
|
98
|
+
: ["- (no usage recorded)"]),
|
|
99
|
+
"",
|
|
74
100
|
"## Tasks",
|
|
75
|
-
...safeTasks.map(
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
...safeTasks.map((task) => {
|
|
102
|
+
// US-022: surface model + duration inline (data was already in hand).
|
|
103
|
+
const duration =
|
|
104
|
+
task.startedAt && task.finishedAt
|
|
105
|
+
? ` [${formatDuration(new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime())}]`
|
|
106
|
+
: "";
|
|
107
|
+
const model = task.model ? ` <${task.model}>` : "";
|
|
108
|
+
return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${model}${duration}${task.error ? ` - ${task.error}` : ""}`;
|
|
109
|
+
}),
|
|
78
110
|
"",
|
|
79
111
|
"## Artifacts",
|
|
80
112
|
...(safeManifest.artifacts.length
|
|
@@ -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",
|
|
@@ -245,11 +252,19 @@ const KNOWN_KEYS = new Set([
|
|
|
245
252
|
// ui
|
|
246
253
|
"ui.widgetPlacement",
|
|
247
254
|
"ui.widgetMaxLines",
|
|
255
|
+
// M1-9/P1-8: inlinePanel is a schema key (config-schema.ts
|
|
256
|
+
// PiTeamsUiConfigSchema) and ui.autoCloseDashboardMs is parsed by
|
|
257
|
+
// parseUiConfig — keep this list mirroring the schema's ui block so
|
|
258
|
+
// `team-settings schema` lists what the schema actually accepts.
|
|
259
|
+
// (ui.widgetRowStyle was REMOVED 2026-09-16: the RAIL dock renders one
|
|
260
|
+
// layout; the key no longer has any reader.)
|
|
261
|
+
"ui.inlinePanel",
|
|
248
262
|
"ui.dashboardPlacement",
|
|
249
263
|
"ui.dashboardWidth",
|
|
250
264
|
"ui.dashboardLiveRefreshMs",
|
|
251
265
|
"ui.autoOpenDashboard",
|
|
252
266
|
"ui.autoOpenDashboardForForegroundRuns",
|
|
267
|
+
"ui.autoCloseDashboardMs",
|
|
253
268
|
"ui.showModel",
|
|
254
269
|
"ui.showTokens",
|
|
255
270
|
"ui.showTools",
|
|
@@ -440,17 +455,30 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
440
455
|
const key = args.slice(4).trim();
|
|
441
456
|
if (!key) return result("Usage: team-settings get <key>\nUse 'team-settings schema' to see all known keys.", { ...ERR }, true);
|
|
442
457
|
const value = getNested(effective, key);
|
|
443
|
-
// Try to provide helpful note for unknown keys
|
|
458
|
+
// Try to provide helpful note for unknown keys.
|
|
459
|
+
// M1-9/P1-8: `ui.*` is exempt exactly like the `set` path below (:497) —
|
|
460
|
+
// every ui.* key is accepted by the schema's ui block, so `get
|
|
461
|
+
// ui.<key>` must not answer "(unknown key — may not take
|
|
462
|
+
// effect)" for a key the config layer actually honours.
|
|
444
463
|
let note = "";
|
|
445
|
-
if (!KNOWN_KEYS.has(key) && !key.startsWith("agents.overrides.")) {
|
|
464
|
+
if (!KNOWN_KEYS.has(key) && !key.startsWith("agents.overrides.") && !key.startsWith("ui.")) {
|
|
446
465
|
const suggestion = suggestConfigKey(key, KNOWN_SORTED);
|
|
447
466
|
if (suggestion) note = `\n(did you mean '${suggestion}'?)`;
|
|
448
467
|
else note = "\n(unknown key — may not take effect)";
|
|
449
468
|
}
|
|
450
|
-
|
|
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}`, {
|
|
451
479
|
...OK,
|
|
452
480
|
key,
|
|
453
|
-
value,
|
|
481
|
+
value: sensitive ? undefined : value,
|
|
454
482
|
} as never);
|
|
455
483
|
}
|
|
456
484
|
|
|
@@ -504,6 +532,7 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
504
532
|
// Check if project config would sanitize this key
|
|
505
533
|
if (scope === "project") {
|
|
506
534
|
const sensitiveKeys = [
|
|
535
|
+
"notifications.webhook",
|
|
507
536
|
"executeWorkers",
|
|
508
537
|
"asyncByDefault",
|
|
509
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
|
}
|