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.
Files changed (54) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +2 -3
  3. package/agents/executor.md +1 -1
  4. package/agents/test-engineer.md +1 -1
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +4526 -4100
  7. package/package.json +7 -4
  8. package/skills/real-test-pi-crew/SKILL.md +10 -9
  9. package/src/config/config.ts +19 -3
  10. package/src/config/role-tools.ts +2 -2
  11. package/src/config/types.ts +2 -0
  12. package/src/extension/knowledge-injection.ts +17 -0
  13. package/src/extension/pi-api.ts +0 -16
  14. package/src/extension/team-tool/api/agent-control.ts +358 -0
  15. package/src/extension/team-tool/api/handler-context.ts +57 -0
  16. package/src/extension/team-tool/api/heartbeat.ts +75 -0
  17. package/src/extension/team-tool/api/mailbox.ts +242 -0
  18. package/src/extension/team-tool/api/plan-approval.ts +190 -0
  19. package/src/extension/team-tool/api/read.ts +443 -0
  20. package/src/extension/team-tool/api/task-claims.ts +207 -0
  21. package/src/extension/team-tool/api.ts +56 -1301
  22. package/src/extension/team-tool/cancel.ts +49 -4
  23. package/src/extension/team-tool/dispatch/manage.ts +7 -4
  24. package/src/extension/team-tool/explain.ts +3 -1
  25. package/src/extension/team-tool/goal-wrap.ts +2 -2
  26. package/src/extension/team-tool/goal.ts +4 -4
  27. package/src/extension/team-tool/lifecycle-actions.ts +9 -6
  28. package/src/extension/team-tool/parallel-dispatch.ts +2 -2
  29. package/src/extension/team-tool/respond.ts +11 -3
  30. package/src/extension/team-tool/run-intent.ts +357 -0
  31. package/src/extension/team-tool/run.ts +8 -285
  32. package/src/extension/team-tool/status.ts +56 -21
  33. package/src/extension/team-tool-types.ts +2 -0
  34. package/src/extension/team-tool.ts +10 -8
  35. package/src/prompt/scratchpad-lifecycle.ts +80 -5
  36. package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
  37. package/src/runtime/child-pi/child-pi.ts +22 -144
  38. package/src/runtime/child-pi/mock-fixtures.ts +171 -0
  39. package/src/runtime/crew-agent-records.ts +18 -1
  40. package/src/runtime/output/output-validator.ts +34 -6
  41. package/src/runtime/scheduling/scheduler.ts +67 -19
  42. package/src/runtime/scratchpad/README.md +6 -0
  43. package/src/runtime/scratchpad/guest.ts +54 -5
  44. package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
  45. package/src/runtime/supervisor-contact.ts +0 -16
  46. package/src/runtime/task-runner/child-executor.ts +1 -0
  47. package/src/runtime/task-runner/state-helpers.ts +9 -1
  48. package/src/runtime/team-runner.ts +39 -2
  49. package/src/state/contracts.ts +3 -0
  50. package/src/state/event-log/event-log.ts +19 -27
  51. package/src/state/gitignore-manager.ts +61 -7
  52. package/src/utils/glob-match.ts +29 -0
  53. package/types/dwf.d.ts +1 -1
  54. 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
- appendEvent(loaded.manifest.eventsPath, {
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
- appendEvent(loaded.manifest.eventsPath, {
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
- ["Updated pi-crew config.", `Path: ${saved.path}`, "Effective config:", JSON.stringify(saved.config, null, 2)].join(
134
- "\n",
135
- ),
136
- { action: "config", status: "ok" },
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 loaded = loadRunManifestById(cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
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 { appendEvent } from "../../state/event-log/event-log.ts";
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
- appendEvent(paths.eventsPath, {
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 { appendEvent } from "../../state/event-log/event-log.ts";
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
- appendEvent(paths.eventsPath, {
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
- appendEvent(eventsPath, {
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
- appendEvent(eventsPath, {
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 loaded = loadRunManifestById(ctx.cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
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
- appendEvent(loaded.manifest.eventsPath, {
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
- appendEvent(loaded.manifest.eventsPath, {
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
- appendEvent(loaded.manifest.eventsPath, {
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
- appendEvent(loaded.manifest.eventsPath, {
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 { appendEvent } from "../../state/event-log/event-log.ts";
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
- appendEvent(created.manifest.eventsPath, {
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 { appendEvent } from "../../state/event-log/event-log.ts";
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
- appendEvent(manifest.eventsPath, {
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
+ }