pi-crew 0.9.65 → 0.9.67
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 +79 -0
- package/README.md +2 -3
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/dist/index.mjs +4526 -4100
- package/package.json +7 -4
- package/skills/real-test-pi-crew/SKILL.md +10 -9
- package/src/config/config.ts +19 -3
- package/src/config/role-tools.ts +2 -2
- package/src/config/types.ts +2 -0
- package/src/extension/knowledge-injection.ts +17 -0
- package/src/extension/pi-api.ts +0 -16
- package/src/extension/team-tool/api/agent-control.ts +358 -0
- package/src/extension/team-tool/api/handler-context.ts +57 -0
- package/src/extension/team-tool/api/heartbeat.ts +75 -0
- package/src/extension/team-tool/api/mailbox.ts +242 -0
- package/src/extension/team-tool/api/plan-approval.ts +190 -0
- package/src/extension/team-tool/api/read.ts +443 -0
- package/src/extension/team-tool/api/task-claims.ts +207 -0
- package/src/extension/team-tool/api.ts +56 -1301
- package/src/extension/team-tool/cancel.ts +49 -4
- package/src/extension/team-tool/dispatch/manage.ts +7 -4
- package/src/extension/team-tool/explain.ts +3 -1
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +4 -4
- package/src/extension/team-tool/lifecycle-actions.ts +9 -6
- package/src/extension/team-tool/parallel-dispatch.ts +2 -2
- package/src/extension/team-tool/respond.ts +11 -3
- package/src/extension/team-tool/run-intent.ts +357 -0
- package/src/extension/team-tool/run.ts +8 -285
- package/src/extension/team-tool/status.ts +56 -21
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +10 -8
- package/src/prompt/scratchpad-lifecycle.ts +80 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
- package/src/runtime/child-pi/child-pi.ts +22 -144
- package/src/runtime/child-pi/mock-fixtures.ts +171 -0
- package/src/runtime/crew-agent-records.ts +18 -1
- package/src/runtime/output/output-validator.ts +34 -6
- package/src/runtime/scheduling/scheduler.ts +67 -19
- package/src/runtime/scratchpad/README.md +6 -0
- package/src/runtime/scratchpad/guest.ts +54 -5
- package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
- package/src/runtime/supervisor-contact.ts +0 -16
- package/src/runtime/task-runner/child-executor.ts +1 -0
- package/src/runtime/task-runner/state-helpers.ts +9 -1
- package/src/runtime/team-runner.ts +39 -2
- package/src/state/contracts.ts +3 -0
- package/src/state/event-log/event-log.ts +19 -27
- package/src/state/gitignore-manager.ts +61 -7
- package/src/utils/glob-match.ts +29 -0
- package/types/dwf.d.ts +1 -1
- package/src/types/new-api-types.ts +0 -35
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "../../runtime/process/cancellation.ts";
|
|
11
11
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
12
12
|
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
13
|
-
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
13
|
+
import { appendEvent, appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
14
14
|
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
15
15
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
16
16
|
import { locateRunCwd } from "../team-tool.ts";
|
|
@@ -21,6 +21,28 @@ import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
|
|
|
21
21
|
import { paramRequired } from "./param-error.ts";
|
|
22
22
|
import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
|
|
23
23
|
|
|
24
|
+
/** Retryable terminal statuses (a task in one of these can be re-queued). */
|
|
25
|
+
const RETRYABLE_STATUSES: ReadonlySet<string> = new Set(["failed", "cancelled"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pure pre-lock decision for `action='retry'`: a run whose manifest status is
|
|
29
|
+
* "completed" (terminal success) and has no retryable tasks has nothing to
|
|
30
|
+
* retry. Returns true so the caller short-circuits with a clear message
|
|
31
|
+
* BEFORE acquiring the run lock — avoiding a misleading
|
|
32
|
+
* "run.lock is locked by another operation" error from a stale lock file left
|
|
33
|
+
* behind by a completed async run (finding #4, real-test-2026-08-10-full-9-tier).
|
|
34
|
+
*
|
|
35
|
+
* Exported for unit testing (handleRetry itself needs filesystem state).
|
|
36
|
+
*/
|
|
37
|
+
export function retryShortCircuitsCompleted(
|
|
38
|
+
runStatus: string,
|
|
39
|
+
tasks: ReadonlyArray<{ id: string; status: string }>,
|
|
40
|
+
targetTaskId?: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (runStatus !== "completed") return false;
|
|
43
|
+
return !tasks.some((task) => (targetTaskId ? task.id === targetTaskId : true) && RETRYABLE_STATUSES.has(task.status));
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
export interface AbortOwnedResult {
|
|
25
47
|
abortedIds: string[];
|
|
26
48
|
missingIds: string[];
|
|
@@ -126,6 +148,18 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
|
|
|
126
148
|
|
|
127
149
|
const targetTaskId = typeof params.taskId === "string" ? params.taskId : undefined;
|
|
128
150
|
|
|
151
|
+
// Pre-lock terminal-status check: a completed run has nothing to retry.
|
|
152
|
+
// Short-circuit BEFORE acquiring the run lock so a stale lock file left by a
|
|
153
|
+
// completed async run does not surface a misleading "run.lock is locked by
|
|
154
|
+
// another operation" error (finding #4 in real-test-2026-08-10-full-9-tier).
|
|
155
|
+
if (retryShortCircuitsCompleted(loaded.manifest.status, loaded.tasks, targetTaskId)) {
|
|
156
|
+
return result(
|
|
157
|
+
`Run ${loaded.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
|
|
158
|
+
{ action: "retry", status: "error", runId: loaded.manifest.runId },
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
129
163
|
return withRunLockSync(loaded.manifest, () => {
|
|
130
164
|
const retryableStatuses: ReadonlySet<string> = new Set(["failed", "cancelled"]);
|
|
131
165
|
|
|
@@ -164,12 +198,21 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
|
|
|
164
198
|
|
|
165
199
|
const retriedTaskIds = [...retriedIds];
|
|
166
200
|
for (const taskId of retriedTaskIds) {
|
|
167
|
-
|
|
201
|
+
// H1 (2026-08-10): inside a sync run-lock callback — cannot await;
|
|
202
|
+
// fire-and-forget async. task.retried is informational; the queued
|
|
203
|
+
// status is the authoritative record in tasks.json.
|
|
204
|
+
void appendEventAsync(loaded.manifest.eventsPath, {
|
|
168
205
|
type: "task.retried",
|
|
169
206
|
runId: loaded.manifest.runId,
|
|
170
207
|
taskId,
|
|
171
208
|
message: `Task ${taskId} queued for retry.`,
|
|
172
|
-
})
|
|
209
|
+
}).catch((error) =>
|
|
210
|
+
logInternalError(
|
|
211
|
+
"cancel.retry-event",
|
|
212
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
213
|
+
`runId=${loaded.manifest.runId}`,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
if (deps) invalidateSnapshot(loaded.manifest.runId, runCwd, deps);
|
|
@@ -233,7 +276,9 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
233
276
|
if (asyncPid !== undefined && asyncPid > 0) {
|
|
234
277
|
try {
|
|
235
278
|
killProcessPid(asyncPid);
|
|
236
|
-
|
|
279
|
+
// H1 (2026-08-10): informational event in async context — await the
|
|
280
|
+
// async lock path (non-blocking event loop, ~1ms vs ~14ms sync).
|
|
281
|
+
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
237
282
|
type: "async.kill_requested",
|
|
238
283
|
runId: loaded.manifest.runId,
|
|
239
284
|
message: "Sent SIGTERM to background runner process.",
|
|
@@ -130,10 +130,13 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
130
130
|
unsetPaths,
|
|
131
131
|
});
|
|
132
132
|
return result(
|
|
133
|
-
[
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
[
|
|
134
|
+
saved.written ? "Updated pi-crew config." : "Config unchanged (no effective changes).",
|
|
135
|
+
`Path: ${saved.path}`,
|
|
136
|
+
"Effective config:",
|
|
137
|
+
JSON.stringify(saved.config, null, 2),
|
|
138
|
+
].join("\n"),
|
|
139
|
+
{ action: "config", status: "ok", written: saved.written },
|
|
137
140
|
);
|
|
138
141
|
} catch (error) {
|
|
139
142
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
4
4
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
5
|
+
import { locateRunCwd } from "../team-tool.ts";
|
|
5
6
|
import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -215,7 +216,8 @@ export function handleExplain(
|
|
|
215
216
|
return result("explain requires runId", { action: "explain", status: "error" }, true);
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
const
|
|
219
|
+
const runCwd = locateRunCwd(params.runId, cwd);
|
|
220
|
+
const loaded = runCwd ? loadRunManifestById(runCwd, params.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
219
221
|
if (!loaded) {
|
|
220
222
|
return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "explain", status: "error" }, true);
|
|
221
223
|
}
|
|
@@ -24,7 +24,7 @@ import { GoalStore } from "../../runtime/goal-workflow/goal-state-store.ts";
|
|
|
24
24
|
import { snapshotManifests } from "../../runtime/verification/verification-integrity.ts";
|
|
25
25
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
26
26
|
import { atomicWriteJson } from "../../state/atomic-write.ts";
|
|
27
|
-
import {
|
|
27
|
+
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
28
28
|
import { createRunPaths, saveRunManifestAsync } from "../../state/stores/state-store.ts";
|
|
29
29
|
import type { GoalLoopState, TeamRunManifest } from "../../state/types.ts";
|
|
30
30
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
@@ -269,7 +269,7 @@ export async function startGoalWrappedRun(
|
|
|
269
269
|
runKind: "goal-loop",
|
|
270
270
|
};
|
|
271
271
|
await saveRunManifestAsync(goalLoopManifest);
|
|
272
|
-
|
|
272
|
+
await appendEventAsync(paths.eventsPath, {
|
|
273
273
|
type: "goal.loop_start",
|
|
274
274
|
runId: goalId,
|
|
275
275
|
data: {
|
|
@@ -19,7 +19,7 @@ import { GoalStore } from "../../runtime/goal-workflow/goal-state-store.ts";
|
|
|
19
19
|
import { snapshotManifests } from "../../runtime/verification/verification-integrity.ts";
|
|
20
20
|
import { isWorkspaceBusy } from "../../runtime/workspace-lock.ts";
|
|
21
21
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
22
|
-
import {
|
|
22
|
+
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";
|
|
@@ -193,7 +193,7 @@ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof
|
|
|
193
193
|
runKind: "goal-loop",
|
|
194
194
|
};
|
|
195
195
|
await saveRunManifestAsync(goalLoopManifest);
|
|
196
|
-
|
|
196
|
+
await appendEventAsync(paths.eventsPath, {
|
|
197
197
|
type: "goal.loop_start",
|
|
198
198
|
runId: goalId,
|
|
199
199
|
data: {
|
|
@@ -438,7 +438,7 @@ async function handleResume(input: GoalSubActionInput): Promise<ReturnType<typeo
|
|
|
438
438
|
if (hint) {
|
|
439
439
|
withHint = store.patch(goalId, { nextTurnFeedback: hint }, eventsPath) ?? updated;
|
|
440
440
|
}
|
|
441
|
-
|
|
441
|
+
await appendEventAsync(eventsPath, {
|
|
442
442
|
type: "goal.resumed",
|
|
443
443
|
runId: goalId,
|
|
444
444
|
data: { goalId, fromState: existing.state, hint: hint?.slice(0, 200) },
|
|
@@ -487,7 +487,7 @@ async function handleResume(input: GoalSubActionInput): Promise<ReturnType<typeo
|
|
|
487
487
|
// 'goal resume' (which requires paused/stuck). Leaving it at 'running' with no process made
|
|
488
488
|
// the goal un-resumable — the user had to pause-then-resume as a workaround.
|
|
489
489
|
store.compareAndSetStatus(goalId, "running", existing.state, eventsPath);
|
|
490
|
-
|
|
490
|
+
await appendEventAsync(eventsPath, {
|
|
491
491
|
type: "goal.resume_spawn_failed",
|
|
492
492
|
runId: goalId,
|
|
493
493
|
data: { goalId, error: msg, rolledBackTo: existing.state },
|
|
@@ -5,7 +5,7 @@ import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
|
|
|
5
5
|
import { killProcessPid } from "../../runtime/child-pi/child-pi.ts";
|
|
6
6
|
import { terminateLiveAgentsForRun } from "../../runtime/live-session/live-agent-manager.ts";
|
|
7
7
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
8
|
-
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
8
|
+
import { appendEvent, appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
9
9
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
10
10
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
11
11
|
import { projectCrewRoot, userCrewRoot, userPiRoot } from "../../utils/paths.ts";
|
|
@@ -16,6 +16,7 @@ import { listImportedRuns } from "../import-index.ts";
|
|
|
16
16
|
import { exportRunBundle } from "../run-export.ts";
|
|
17
17
|
import { importRunBundle } from "../run-import.ts";
|
|
18
18
|
import { pruneFinishedRuns } from "../run-maintenance.ts";
|
|
19
|
+
import { locateRunCwd } from "../team-tool.ts";
|
|
19
20
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
20
21
|
import { configRecord, result, type TeamContext } from "./context.ts";
|
|
21
22
|
import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
|
|
@@ -29,7 +30,9 @@ export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
29
30
|
{ action: "worktrees", status: "error" },
|
|
30
31
|
true,
|
|
31
32
|
);
|
|
32
|
-
const
|
|
33
|
+
const runCwd = locateRunCwd(params.runId, ctx.cwd);
|
|
34
|
+
if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
|
|
35
|
+
const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
33
36
|
if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
|
|
34
37
|
const withWorktrees = loaded.tasks.filter((task) => task.worktree);
|
|
35
38
|
const lines = [
|
|
@@ -125,7 +128,7 @@ export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
const exported = exportRunBundle(loaded.manifest, loaded.tasks);
|
|
128
|
-
|
|
131
|
+
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
129
132
|
type: "run.exported",
|
|
130
133
|
runId: loaded.manifest.runId,
|
|
131
134
|
data: exported,
|
|
@@ -253,7 +256,7 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
253
256
|
true,
|
|
254
257
|
);
|
|
255
258
|
const intent = intentFromConfig(params.config);
|
|
256
|
-
|
|
259
|
+
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
257
260
|
type: "run.forget_requested",
|
|
258
261
|
runId: loaded.manifest.runId,
|
|
259
262
|
message: "Run state and artifacts are being forgotten.",
|
|
@@ -273,7 +276,7 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
273
276
|
if (asyncPid !== undefined && asyncPid > 0) {
|
|
274
277
|
try {
|
|
275
278
|
killProcessPid(asyncPid);
|
|
276
|
-
|
|
279
|
+
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
277
280
|
type: "async.kill_requested",
|
|
278
281
|
runId: loaded.manifest.runId,
|
|
279
282
|
message: "Sent SIGTERM to background runner process (forget).",
|
|
@@ -652,7 +655,7 @@ async function handleRunCleanup(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
652
655
|
signal: ctx.signal,
|
|
653
656
|
});
|
|
654
657
|
const intent = intentFromConfig(params.config);
|
|
655
|
-
|
|
658
|
+
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
656
659
|
type: "worktree.cleanup",
|
|
657
660
|
runId: loaded.manifest.runId,
|
|
658
661
|
data: {
|
|
@@ -11,7 +11,7 @@ import { loadConfig } from "../../config/config.ts";
|
|
|
11
11
|
import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
|
|
12
12
|
import { resolveCrewRuntime } from "../../runtime/model/runtime-resolver.ts";
|
|
13
13
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
15
15
|
import { createRunManifest } from "../../state/stores/state-store.ts";
|
|
16
16
|
import { discoverTeams } from "../../teams/discover-teams.ts";
|
|
17
17
|
import type { TeamConfig } from "../../teams/team-config.ts";
|
|
@@ -165,7 +165,7 @@ async function spawnSingleTask(
|
|
|
165
165
|
runKind: "team-run",
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
await appendEventAsync(created.manifest.eventsPath, {
|
|
169
169
|
type: "run.started",
|
|
170
170
|
runId: created.manifest.runId,
|
|
171
171
|
message: `Parallel task: ${goal}`,
|
|
@@ -2,7 +2,7 @@ import { readCrewAgents, recordFromTask, saveCrewAgents } from "../../runtime/cr
|
|
|
2
2
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
3
3
|
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
4
4
|
import { appendMailboxMessage, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
6
6
|
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
7
7
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
8
8
|
import { locateRunCwd } from "../team-tool.ts";
|
|
@@ -133,13 +133,21 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
133
133
|
manifest = updateRunStatus(manifest, "running", `Resumed ${resumed.size} waiting task(s).`);
|
|
134
134
|
}
|
|
135
135
|
for (const taskId of resumed) {
|
|
136
|
-
|
|
136
|
+
// H1 (2026-08-10): handleRespond is a SYNC function inside a sync
|
|
137
|
+
// run-lock callback — cannot await; fire-and-forget async.
|
|
138
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
137
139
|
type: "task.resumed",
|
|
138
140
|
runId: manifest.runId,
|
|
139
141
|
taskId,
|
|
140
142
|
message: message || "Task re-queued after respond.",
|
|
141
143
|
data: { mailboxIds },
|
|
142
|
-
})
|
|
144
|
+
}).catch((error) =>
|
|
145
|
+
logInternalError(
|
|
146
|
+
"respond.resumed-event",
|
|
147
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
148
|
+
`runId=${manifest.runId}`,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
143
151
|
}
|
|
144
152
|
try {
|
|
145
153
|
const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-intent validation phase (H3 phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `handleRun` (src/extension/team-tool/run.ts) on 2026-08-10.
|
|
5
|
+
* Validates the tool params → resolved team/workflow/agent + goal + analysis,
|
|
6
|
+
* returning either a validated `RunIntent` (for materialization) or an error
|
|
7
|
+
* result. Behaviour is byte-identical to the inline block it replaces —
|
|
8
|
+
* including the exact error-precedence order the run tests assert on.
|
|
9
|
+
*
|
|
10
|
+
* The chain dispatch stays in `handleRun` because it recurses via a lazy
|
|
11
|
+
* injected handleRun reference (run.ts ↔ chain-dispatch.ts import cycle).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
|
|
16
|
+
import { loadConfig } from "../../config/config.ts";
|
|
17
|
+
import { sanitizeTaskText } from "../../runtime/task-packet.ts";
|
|
18
|
+
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
19
|
+
import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
|
|
20
|
+
import type { TeamConfig } from "../../teams/team-config.ts";
|
|
21
|
+
import { errorMessage } from "../../utils/guards.ts";
|
|
22
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
23
|
+
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
24
|
+
import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
|
|
25
|
+
import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
26
|
+
import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktree-manager.ts";
|
|
27
|
+
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
28
|
+
import type { TeamContext } from "./context.ts";
|
|
29
|
+
import { result } from "./context.ts";
|
|
30
|
+
import { isGoalWrapEnabled, shouldGoalWrap, startGoalWrappedRun } from "./goal-wrap.ts";
|
|
31
|
+
import { paramRequired } from "./param-error.ts";
|
|
32
|
+
|
|
33
|
+
/** Cap for inline/path analysis content (mirrors the schema maxLength). */
|
|
34
|
+
const MAX_ANALYSIS_BYTES = 100_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Module-scoped latch for the crew-init dynamic import (moved verbatim from
|
|
38
|
+
* run.ts — see the comment there for the jiti TDZ race it guards).
|
|
39
|
+
*/
|
|
40
|
+
var crewInitPromise: Promise<typeof import("../../state/crew-init.ts")> | undefined;
|
|
41
|
+
function loadCrewInit(): Promise<typeof import("../../state/crew-init.ts")> {
|
|
42
|
+
if (!crewInitPromise) {
|
|
43
|
+
crewInitPromise = import("../../state/crew-init.ts");
|
|
44
|
+
}
|
|
45
|
+
return crewInitPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve analysis text from inline or file. Mutual exclusivity mirrors the
|
|
50
|
+
* `budgetTotal`/`budgetUnlimited` pattern (cold-review #2 blocking fix).
|
|
51
|
+
*/
|
|
52
|
+
function resolveAnalysisText(
|
|
53
|
+
params: TeamToolParamsValue,
|
|
54
|
+
cwd: string,
|
|
55
|
+
): { text?: string; error?: string; source: "inline" | "path" | "none" } {
|
|
56
|
+
const hasInline = typeof params.analysis === "string" && params.analysis.length > 0;
|
|
57
|
+
const hasPath = typeof params.analysisPath === "string" && params.analysisPath.length > 0;
|
|
58
|
+
|
|
59
|
+
if (hasInline && hasPath) {
|
|
60
|
+
return {
|
|
61
|
+
error: "`analysis` and `analysisPath` are mutually exclusive. Set exactly one.",
|
|
62
|
+
source: "none",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!hasInline && !hasPath) return { source: "none" };
|
|
66
|
+
|
|
67
|
+
if (hasPath) {
|
|
68
|
+
let resolved: string;
|
|
69
|
+
try {
|
|
70
|
+
resolved = resolveRealContainedPath(cwd, params.analysisPath as string);
|
|
71
|
+
} catch {
|
|
72
|
+
return {
|
|
73
|
+
error: `analysisPath must be within project directory: ${params.analysisPath}`,
|
|
74
|
+
source: "none",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (!fs.existsSync(resolved)) {
|
|
78
|
+
return {
|
|
79
|
+
error: `Analysis file not found: ${resolved}`,
|
|
80
|
+
source: "none",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Size cap BEFORE reading: mirror the inline schema cap (maxLength 100_000)
|
|
84
|
+
// so a large file can't blow up worker prompts via the sharedReads channel.
|
|
85
|
+
const { size } = fs.statSync(resolved);
|
|
86
|
+
if (size > MAX_ANALYSIS_BYTES) {
|
|
87
|
+
return {
|
|
88
|
+
error: `Analysis file too large: ${size} bytes (max ${MAX_ANALYSIS_BYTES}). Trim the analysis or pass a summary inline.`,
|
|
89
|
+
source: "none",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const raw = fs.readFileSync(resolved, "utf-8");
|
|
93
|
+
const sanitized = sanitizeTaskText(raw);
|
|
94
|
+
if (!sanitized) return { source: "none" };
|
|
95
|
+
return { text: sanitized, source: "path" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// hasInline
|
|
99
|
+
const sanitized = sanitizeTaskText(params.analysis as string);
|
|
100
|
+
if (!sanitized) return { source: "none" };
|
|
101
|
+
return { text: sanitized, source: "inline" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The validated run intent produced by {@link validateRunIntent}. */
|
|
105
|
+
export interface RunIntent {
|
|
106
|
+
goal: string;
|
|
107
|
+
intentPrefix: string;
|
|
108
|
+
/** cwd after the git-root auto-correction for worktree mode. */
|
|
109
|
+
resolvedCtx: TeamContext;
|
|
110
|
+
directAgent: boolean;
|
|
111
|
+
team: TeamConfig;
|
|
112
|
+
workflow: WorkflowConfig;
|
|
113
|
+
/** All discovered agents (needed by the execution phase for executeTeamRun). */
|
|
114
|
+
agents: ReturnType<typeof allAgents>;
|
|
115
|
+
analysisParam: { text?: string; error?: string; source: "inline" | "path" | "none" };
|
|
116
|
+
isDynamicWorkflow: boolean;
|
|
117
|
+
/** params.runKind only when the workflow is dynamic (else undefined). */
|
|
118
|
+
effectiveRunKind: TeamToolParamsValue["runKind"];
|
|
119
|
+
/** normalizeSkillOverride result — matches TeamRunManifest.skillOverride. */
|
|
120
|
+
skillOverride: false | string[] | undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate the run params into a {@link RunIntent}.
|
|
125
|
+
*
|
|
126
|
+
* Error-precedence order (asserted by run tests, do not reorder):
|
|
127
|
+
* goal/params → crew-init → worktree precondition → agent → team →
|
|
128
|
+
* workflow → analysis → workflow-validation. Non-validation branches
|
|
129
|
+
* (chain dispatch, goal-wrap delegation) are handled inside.
|
|
130
|
+
*/
|
|
131
|
+
export async function validateRunIntent(
|
|
132
|
+
params: TeamToolParamsValue,
|
|
133
|
+
ctx: TeamContext,
|
|
134
|
+
): Promise<{ kind: "ok"; intent: RunIntent } | { kind: "error"; result: PiTeamsToolResult }> {
|
|
135
|
+
const goal = params.goal ?? params.task;
|
|
136
|
+
if (!goal)
|
|
137
|
+
return {
|
|
138
|
+
kind: "error",
|
|
139
|
+
result: result(
|
|
140
|
+
paramRequired("run", "goal or task", "{ action: 'run', goal: '<what to achieve>' }"),
|
|
141
|
+
{ action: "run", status: "error" },
|
|
142
|
+
true,
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
const intentPrefix = goal.length > 60 ? `${goal.slice(0, 57)}...` : goal;
|
|
146
|
+
|
|
147
|
+
// P0: Ensure .crew directory structure exists before creating any manifests.
|
|
148
|
+
// Latch shared across concurrent `team` tool calls (see loadCrewInit).
|
|
149
|
+
const workingDir = ctx.cwd ?? process.cwd();
|
|
150
|
+
const { ensureCrewDirectory } = await loadCrewInit();
|
|
151
|
+
await ensureCrewDirectory(workingDir);
|
|
152
|
+
|
|
153
|
+
// WORKTREE FIX: If worktree mode is needed but cwd is not a git repo,
|
|
154
|
+
// auto-correct to the nearest git repo root.
|
|
155
|
+
let resolvedCtx = ctx;
|
|
156
|
+
if (workingDir) {
|
|
157
|
+
try {
|
|
158
|
+
const gitRoot = await findGitRootAsync(workingDir);
|
|
159
|
+
if (gitRoot && gitRoot !== workingDir) {
|
|
160
|
+
resolvedCtx = { ...ctx, cwd: gitRoot };
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// cwd is not in a git repo — validate below if worktree mode is needed
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// WORKTREE PRECONDITION CHECK: validate git repo exists and is clean
|
|
168
|
+
// BEFORE creating the run manifest.
|
|
169
|
+
if (params.workspaceMode === "worktree") {
|
|
170
|
+
let gitRoot: string | undefined;
|
|
171
|
+
try {
|
|
172
|
+
gitRoot = await findGitRootAsync(resolvedCtx.cwd);
|
|
173
|
+
} catch {
|
|
174
|
+
// not a git repo
|
|
175
|
+
}
|
|
176
|
+
if (!gitRoot) {
|
|
177
|
+
return {
|
|
178
|
+
kind: "error",
|
|
179
|
+
result: result(
|
|
180
|
+
`Worktree mode requires a git repository. '${resolvedCtx.cwd}' is not inside a git repo.\nUse workspaceMode: 'single' or run from a git repository.`,
|
|
181
|
+
{ action: "run", status: "error" },
|
|
182
|
+
true,
|
|
183
|
+
),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// Check if clean leader is required (can be disabled via config)
|
|
187
|
+
const preCheckConfig = loadConfig(resolvedCtx.cwd);
|
|
188
|
+
if (preCheckConfig.config.requireCleanWorktreeLeader !== false) {
|
|
189
|
+
try {
|
|
190
|
+
await assertCleanLeaderAsync(gitRoot);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
const msg = errorMessage(err);
|
|
193
|
+
return {
|
|
194
|
+
kind: "error",
|
|
195
|
+
result: result(
|
|
196
|
+
`${msg}\nCommit or stash changes before using worktree mode, or use workspaceMode: 'single'.`,
|
|
197
|
+
{ action: "run", status: "error" },
|
|
198
|
+
true,
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const teams = allTeams(discoverTeams(resolvedCtx.cwd));
|
|
206
|
+
const workflows = allWorkflows(discoverWorkflows(resolvedCtx.cwd));
|
|
207
|
+
const agents = allAgents(discoverAgents(resolvedCtx.cwd));
|
|
208
|
+
const directAgent = params.agent ? agents.find((item) => item.name === params.agent) : undefined;
|
|
209
|
+
if (params.agent && !directAgent)
|
|
210
|
+
return { kind: "error", result: result(`Agent '${params.agent}' not found.`, { action: "run", status: "error" }, true) };
|
|
211
|
+
const teamName = params.team ?? "default";
|
|
212
|
+
const team = directAgent
|
|
213
|
+
? {
|
|
214
|
+
name: `direct-${directAgent.name}`,
|
|
215
|
+
description: `Direct subagent run for ${directAgent.name}`,
|
|
216
|
+
source: "builtin" as const,
|
|
217
|
+
filePath: "<generated>",
|
|
218
|
+
roles: [
|
|
219
|
+
{
|
|
220
|
+
name: params.role ?? "agent",
|
|
221
|
+
agent: directAgent.name,
|
|
222
|
+
description: directAgent.description,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
defaultWorkflow: "direct-agent",
|
|
226
|
+
workspaceMode: params.workspaceMode,
|
|
227
|
+
}
|
|
228
|
+
: teams.find((item) => item.name === teamName);
|
|
229
|
+
if (!team) return { kind: "error", result: result(`Team '${teamName}' not found.`, { action: "run", status: "error" }, true) };
|
|
230
|
+
// BUG-44 (github #44): `chain` is a dispatcher-only workflow — see the
|
|
231
|
+
// comment in run.ts; chain steps forwarding params.workflow fall back to
|
|
232
|
+
// the team's default workflow.
|
|
233
|
+
const workflowName = directAgent
|
|
234
|
+
? "direct-agent"
|
|
235
|
+
: params.workflow === "chain" && !params.chain
|
|
236
|
+
? (team.defaultWorkflow ?? "default")
|
|
237
|
+
: (params.workflow ?? team.defaultWorkflow ?? "default");
|
|
238
|
+
const baseWorkflow = directAgent
|
|
239
|
+
? {
|
|
240
|
+
name: "direct-agent",
|
|
241
|
+
description: `Direct task for ${directAgent.name}`,
|
|
242
|
+
source: "builtin" as const,
|
|
243
|
+
filePath: "<generated>",
|
|
244
|
+
steps: [
|
|
245
|
+
{
|
|
246
|
+
id: "01_agent",
|
|
247
|
+
role: params.role ?? "agent",
|
|
248
|
+
task: "{goal}",
|
|
249
|
+
model: params.model,
|
|
250
|
+
reads: params.analysis || params.analysisPath ? ["analysis.md"] : undefined,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
: workflows.find((item) => item.name === workflowName);
|
|
255
|
+
if (!baseWorkflow)
|
|
256
|
+
return { kind: "error", result: result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true) };
|
|
257
|
+
|
|
258
|
+
// ANALYSIS CHANNEL (round-X Y1): resolve analysis text BEFORE
|
|
259
|
+
// createRunManifest so validation errors fail-fast (no orphan run state).
|
|
260
|
+
const analysisParam = resolveAnalysisText(params, resolvedCtx.cwd);
|
|
261
|
+
if (analysisParam.error) return { kind: "error", result: result(analysisParam.error, { action: "run", status: "error" }, true) };
|
|
262
|
+
|
|
263
|
+
// LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { expandParallelResearchWorkflow }` (issue #28, RFC 17). Multi-line form breaks scripts/check-lazy-imports.mjs.
|
|
264
|
+
const { expandParallelResearchWorkflow: expandParallelResearch } = await import("../../runtime/scheduling/parallel-research.ts");
|
|
265
|
+
const workflow = directAgent ? baseWorkflow : expandParallelResearch(baseWorkflow, resolvedCtx.cwd);
|
|
266
|
+
const isDynamicWorkflow =
|
|
267
|
+
!directAgent && (workflow as import("../../workflows/workflow-config.ts").WorkflowConfig).runtime === "dynamic";
|
|
268
|
+
if (params.runKind !== undefined && !isDynamicWorkflow) {
|
|
269
|
+
logInternalError(
|
|
270
|
+
"team-tool.run.runKindIgnored",
|
|
271
|
+
new Error(`Ignoring runKind='${params.runKind}' because workflow '${workflow.name}' is not dynamic.`),
|
|
272
|
+
undefined,
|
|
273
|
+
"warn",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// PREFLIGHT (advisory only, since v0.9.15) — informational notes per the
|
|
278
|
+
// rule in .crew/knowledge.md "pi-crew USAGE THRESHOLD RULE". Never blocks.
|
|
279
|
+
if (!directAgent) {
|
|
280
|
+
// LAZY: defer preflight-validator import until a team run requests it.
|
|
281
|
+
const { validateWorkflowUsage } = await import("../../workflows/preflight-validator.ts");
|
|
282
|
+
const preflight = validateWorkflowUsage(workflow, {
|
|
283
|
+
force: params.force === true,
|
|
284
|
+
});
|
|
285
|
+
const icon = preflight.level === "warn" ? "⚠️ " : preflight.level === "note" ? "ℹ️ " : "";
|
|
286
|
+
const tag = preflight.level.toUpperCase();
|
|
287
|
+
console.warn(`${icon}[team-tool.preflight] ${tag}: ${preflight.message} (workflow=${workflow.name})`);
|
|
288
|
+
if (preflight.suggestion) {
|
|
289
|
+
console.warn(`[team-tool.preflight] → ${preflight.suggestion}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// RFC v0.5 vision: goal-wrap. If .crew/config.json has
|
|
294
|
+
// goalWrap[workflow.name].enabled=true, route to a goal loop where this
|
|
295
|
+
// workflow runs as the worker turn. Only for eligible builtins. When
|
|
296
|
+
// goal-wrap is unsafe for this workflow we fall through (never block the
|
|
297
|
+
// run the user asked for).
|
|
298
|
+
if (!directAgent && workflow.source === "builtin" && isGoalWrapEnabled(resolvedCtx.cwd, workflow.name)) {
|
|
299
|
+
const decision = shouldGoalWrap(resolvedCtx.cwd, workflow);
|
|
300
|
+
if (decision.enabled) {
|
|
301
|
+
if (analysisParam.text) {
|
|
302
|
+
console.warn(
|
|
303
|
+
`[team-tool.run] analysis param is ignored by goal-wrapped run (workflow=${workflow.name}). The analysis artifact will not be written.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
kind: "error",
|
|
308
|
+
result: await startGoalWrappedRun(params, ctx, workflow, goal),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (decision.message) {
|
|
312
|
+
logInternalError(
|
|
313
|
+
"team-tool.run.goalWrapBypassed",
|
|
314
|
+
new Error(decision.message),
|
|
315
|
+
`workflow=${workflow.name} reason=${decision.reason}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// LAZY: dodge the jiti ESM/CJS interop TDZ race (issue #28, RFC 17).
|
|
321
|
+
const { validateWorkflowForTeam: validateWorkflow } = await import("../../workflows/validate-workflow.ts");
|
|
322
|
+
const validationErrors = validateWorkflow(workflow, team);
|
|
323
|
+
if (validationErrors.length > 0) {
|
|
324
|
+
return {
|
|
325
|
+
kind: "error",
|
|
326
|
+
result: result(
|
|
327
|
+
[
|
|
328
|
+
`Workflow '${workflow.name}' is not valid for team '${team.name}':`,
|
|
329
|
+
...validationErrors.map((error) => `- ${error}`),
|
|
330
|
+
].join("\n"),
|
|
331
|
+
{ action: "run", status: "error" },
|
|
332
|
+
true,
|
|
333
|
+
),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// LAZY: dodge the jiti ESM/CJS interop TDZ race (issue #28, RFC 17).
|
|
338
|
+
const { normalizeSkillOverride: normalizeSkill } = await import("../../runtime/skill-instructions.ts");
|
|
339
|
+
const skillOverride = normalizeSkill(params.skill);
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
kind: "ok",
|
|
343
|
+
intent: {
|
|
344
|
+
goal,
|
|
345
|
+
intentPrefix,
|
|
346
|
+
resolvedCtx,
|
|
347
|
+
directAgent: !!directAgent,
|
|
348
|
+
team,
|
|
349
|
+
workflow,
|
|
350
|
+
agents,
|
|
351
|
+
analysisParam,
|
|
352
|
+
isDynamicWorkflow,
|
|
353
|
+
effectiveRunKind: isDynamicWorkflow ? params.runKind : undefined,
|
|
354
|
+
skillOverride,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|