pi-crew 0.1.39 → 0.1.41

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 (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +50 -4
  3. package/docs/usage.md +11 -0
  4. package/package.json +1 -1
  5. package/schema.json +4 -1
  6. package/src/agents/discover-agents.ts +1 -1
  7. package/src/config/config.ts +87 -2
  8. package/src/extension/async-notifier.ts +26 -4
  9. package/src/extension/notification-sink.ts +1 -1
  10. package/src/extension/register.ts +23 -7
  11. package/src/extension/registration/subagent-tools.ts +11 -6
  12. package/src/extension/result-watcher.ts +38 -8
  13. package/src/extension/team-tool/api.ts +61 -2
  14. package/src/extension/team-tool/doctor.ts +31 -0
  15. package/src/extension/team-tool/status.ts +23 -3
  16. package/src/observability/metric-sink.ts +1 -1
  17. package/src/runtime/agent-control.ts +4 -5
  18. package/src/runtime/attention-events.ts +23 -0
  19. package/src/runtime/child-pi.ts +2 -1
  20. package/src/runtime/completion-guard.ts +99 -0
  21. package/src/runtime/crew-agent-records.ts +5 -4
  22. package/src/runtime/crew-agent-runtime.ts +2 -2
  23. package/src/runtime/diagnostic-export.ts +2 -16
  24. package/src/runtime/group-join.ts +22 -4
  25. package/src/runtime/live-session-runtime.ts +12 -6
  26. package/src/runtime/sidechain-output.ts +2 -1
  27. package/src/runtime/subagent-manager.ts +6 -1
  28. package/src/runtime/task-runner/live-executor.ts +3 -0
  29. package/src/runtime/task-runner.ts +25 -2
  30. package/src/runtime/team-runner.ts +131 -6
  31. package/src/schema/config-schema.ts +3 -0
  32. package/src/schema/team-tool-schema.ts +12 -3
  33. package/src/state/artifact-store.ts +4 -2
  34. package/src/state/event-log.ts +2 -1
  35. package/src/state/jsonl-writer.ts +3 -1
  36. package/src/state/mailbox.ts +15 -4
  37. package/src/state/types.ts +25 -0
  38. package/src/teams/discover-teams.ts +1 -1
  39. package/src/ui/dashboard-panes/progress-pane.ts +3 -0
  40. package/src/ui/run-snapshot-cache.ts +29 -1
  41. package/src/ui/snapshot-types.ts +8 -0
  42. package/src/utils/fs-watch.ts +3 -3
  43. package/src/utils/redaction.ts +41 -0
  44. package/src/workflows/discover-workflows.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import type { CrewRuntimeConfig } from "../config/config.ts";
2
2
  import { writeArtifact } from "../state/artifact-store.ts";
3
3
  import { appendEvent } from "../state/event-log.ts";
4
- import { appendMailboxMessage } from "../state/mailbox.ts";
4
+ import { appendMailboxMessage, findMailboxMessageByRequestId, readDeliveryState } from "../state/mailbox.ts";
5
5
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
6
  import { aggregateTaskOutputs } from "./task-output-context.ts";
7
7
 
@@ -18,6 +18,9 @@ export interface CrewGroupJoinDelivery {
18
18
  remaining: string[];
19
19
  artifact?: ArtifactDescriptor;
20
20
  messageId?: string;
21
+ requestId?: string;
22
+ ackRequired?: boolean;
23
+ ackStatus?: "pending" | "acknowledged";
21
24
  }
22
25
 
23
26
  export function resolveGroupJoinMode(runtime?: CrewRuntimeConfig): CrewGroupJoinMode {
@@ -34,6 +37,10 @@ function batchIdFor(runId: string, taskIds: string[]): string {
34
37
  return `${runId}_${taskIds.join("+").replace(/[^a-zA-Z0-9_+-]/g, "_")}`;
35
38
  }
36
39
 
40
+ function requestIdFor(runId: string, batchId: string, partial: boolean): string {
41
+ return `${runId}:group-join:${partial ? "partial" : "completed"}:${batchId}`;
42
+ }
43
+
37
44
  function statusList(tasks: TeamTaskState[], status: TeamTaskState["status"]): string[] {
38
45
  return tasks.filter((task) => task.status === status).map((task) => task.id);
39
46
  }
@@ -55,7 +62,10 @@ export function deliverGroupJoin(input: {
55
62
  const partial = input.partial ?? remaining.length > 0;
56
63
  const batchId = batchIdFor(input.manifest.runId, taskIds);
57
64
  const summary = aggregateTaskOutputs(latest, input.manifest);
58
- const delivery: CrewGroupJoinDelivery = { batchId, mode: input.mode, partial, taskIds, completed, failed, skipped, remaining };
65
+ const requestId = requestIdFor(input.manifest.runId, batchId, partial);
66
+ const existingMailbox = findMailboxMessageByRequestId(input.manifest, requestId);
67
+ const existingStatus = existingMailbox ? readDeliveryState(input.manifest).messages[existingMailbox.id] ?? existingMailbox.status : undefined;
68
+ const delivery: CrewGroupJoinDelivery = { batchId, mode: input.mode, partial, taskIds, completed, failed, skipped, remaining, requestId, ackRequired: true, ackStatus: existingStatus === "acknowledged" ? "acknowledged" : "pending" };
59
69
  const content = `${JSON.stringify({ ...delivery, createdAt: new Date().toISOString() }, null, 2)}\n`;
60
70
  const artifact = writeArtifact(input.manifest.artifactsRoot, {
61
71
  kind: "metadata",
@@ -63,12 +73,13 @@ export function deliverGroupJoin(input: {
63
73
  producer: "group-join",
64
74
  content,
65
75
  });
66
- const mailbox = appendMailboxMessage(input.manifest, {
76
+ const mailbox = existingMailbox ?? appendMailboxMessage(input.manifest, {
67
77
  direction: "outbox",
68
78
  from: "group-join",
69
79
  to: "leader",
70
80
  body: [
71
81
  `Group join ${partial ? "partial" : "completed"}: ${taskIds.join(", ")}`,
82
+ `Request: ${requestId}`,
72
83
  `Completed: ${completed.join(", ") || "none"}`,
73
84
  `Failed: ${failed.join(", ") || "none"}`,
74
85
  `Skipped: ${skipped.join(", ") || "none"}`,
@@ -77,12 +88,19 @@ export function deliverGroupJoin(input: {
77
88
  summary,
78
89
  ].join("\n"),
79
90
  status: "delivered",
91
+ data: { kind: "group_join", requestId, batchId, partial, ackRequired: true, taskIds, completed, failed, skipped, remaining },
80
92
  });
81
93
  appendEvent(input.manifest.eventsPath, {
82
94
  type: partial ? "agent.group_join.partial" : "agent.group_join.completed",
83
95
  runId: input.manifest.runId,
84
96
  message: `Group join ${partial ? "partial" : "completed"} for ${taskIds.length} task(s).`,
85
- data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id },
97
+ data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id, fallback: "mailbox-delivered", reused: Boolean(existingMailbox) },
98
+ });
99
+ if (existingMailbox) appendEvent(input.manifest.eventsPath, {
100
+ type: "agent.group_join.delivery_reused",
101
+ runId: input.manifest.runId,
102
+ message: `Reused group join mailbox delivery for ${taskIds.length} task(s).`,
103
+ data: { requestId, messageId: mailbox.id, batchId, partial },
86
104
  });
87
105
  return { ...delivery, artifact, messageId: mailbox.id };
88
106
  }
@@ -10,6 +10,7 @@ import { subscribeLiveControlRealtime } from "./live-control-realtime.ts";
10
10
  import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "./sidechain-output.ts";
11
11
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
12
12
  import { isLiveSessionRuntimeAvailable } from "./runtime-resolver.ts";
13
+ import { redactSecrets } from "../utils/redaction.ts";
13
14
 
14
15
  export interface LiveSessionSpawnInput {
15
16
  manifest: TeamRunManifest;
@@ -25,6 +26,7 @@ export interface LiveSessionSpawnInput {
25
26
  parentContext?: string;
26
27
  parentModel?: unknown;
27
28
  modelRegistry?: unknown;
29
+ isCurrent?: () => boolean;
28
30
  }
29
31
 
30
32
  export interface LiveSessionRunResult {
@@ -70,7 +72,7 @@ type LiveSessionLike = {
70
72
  function appendTranscript(filePath: string | undefined, event: unknown): void {
71
73
  if (!filePath) return;
72
74
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
73
- fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, "utf-8");
75
+ fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(event))}\n`, "utf-8");
74
76
  }
75
77
 
76
78
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -173,6 +175,7 @@ export async function probeLiveSessionRuntime(): Promise<LiveSessionUnavailableR
173
175
  }
174
176
 
175
177
  export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<LiveSessionRunResult> {
178
+ const isCurrent = input.isCurrent ?? (() => true);
176
179
  if (process.env.PI_CREW_MOCK_LIVE_SESSION === "success") {
177
180
  const agentId = `${input.manifest.runId}:${input.task.id}`;
178
181
  const inherited = input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
@@ -183,9 +186,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
183
186
  const sidechainPath = sidechainOutputPath(input.manifest.stateRoot, input.task.id);
184
187
  writeSidechainEntry(sidechainPath, { agentId, type: "user", message: { role: "user", content: input.prompt }, cwd: input.task.cwd });
185
188
  writeSidechainEntry(sidechainPath, { agentId, type: "message", message: event, cwd: input.task.cwd });
186
- input.onEvent?.(event);
189
+ if (isCurrent()) input.onEvent?.(event);
187
190
  const stdout = `Mock live-session success for ${input.agent.name}${inherited}`;
188
- input.onOutput?.(stdout);
191
+ if (isCurrent()) input.onOutput?.(stdout);
189
192
  updateLiveAgentStatus(agentId, "completed");
190
193
  return { available: true, exitCode: 0, stdout, stderr: "", jsonEvents: 1 };
191
194
  }
@@ -234,7 +237,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
234
237
  const seenControlRequestIds = new Set<string>();
235
238
  let controlBusy = false;
236
239
  const pollControl = async () => {
237
- if (controlBusy || !session) return;
240
+ if (!isCurrent() || controlBusy || !session) return;
238
241
  controlBusy = true;
239
242
  try {
240
243
  controlCursor = await applyLiveAgentControlRequests({ manifest: input.manifest, taskId: input.task.id, agentId, session, cursor: controlCursor, seenRequestIds: seenControlRequestIds });
@@ -243,11 +246,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
243
246
  }
244
247
  };
245
248
  unsubscribeControlRealtime = subscribeLiveControlRealtime((request) => {
246
- if (request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
249
+ if (!isCurrent() || request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
247
250
  void applyLiveAgentControlRequest({ request, taskId: input.task.id, agentId, session, seenRequestIds: seenControlRequestIds });
248
251
  });
249
252
  await pollControl();
250
- controlTimer = setInterval(() => { void pollControl(); }, 500);
253
+ controlTimer = setInterval(() => {
254
+ if (isCurrent()) void pollControl();
255
+ }, 500);
251
256
  let turnCount = 0;
252
257
  let softLimitReached = false;
253
258
  const maxTurns = input.runtimeConfig?.maxTurns;
@@ -256,6 +261,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
256
261
  writeSidechainEntry(sidechainPath, { agentId, type: "user", message: { role: "user", content: input.prompt }, cwd: input.task.cwd });
257
262
  if (typeof session.subscribe === "function") {
258
263
  unsubscribe = session.subscribe((event) => {
264
+ if (!isCurrent()) return;
259
265
  jsonEvents += 1;
260
266
  appendTranscript(input.transcriptPath, event);
261
267
  const sidechainType = eventToSidechainType(event);
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { redactSecrets } from "../utils/redaction.ts";
3
4
 
4
5
  export interface SidechainEntry {
5
6
  isSidechain: true;
@@ -12,7 +13,7 @@ export interface SidechainEntry {
12
13
 
13
14
  export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
14
15
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
15
- fs.appendFileSync(filePath, `${JSON.stringify({ isSidechain: true, timestamp: new Date().toISOString(), ...entry })}\n`, "utf-8");
16
+ fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`, "utf-8");
16
17
  }
17
18
 
18
19
  export function sidechainOutputPath(stateRoot: string, taskId: string): string {
@@ -6,6 +6,7 @@ import { DEFAULT_SUBAGENT } from "../config/defaults.ts";
6
6
  import { projectCrewRoot } from "../utils/paths.ts";
7
7
  import { DEFAULT_PATHS } from "../config/defaults.ts";
8
8
  import { logInternalError } from "../utils/internal-error.ts";
9
+ import { redactSecrets } from "../utils/redaction.ts";
9
10
 
10
11
  export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "blocked" | "stopped";
11
12
 
@@ -17,6 +18,7 @@ export interface SubagentSpawnOptions {
17
18
  background: boolean;
18
19
  model?: string;
19
20
  maxTurns?: number;
21
+ ownerSessionGeneration?: number;
20
22
  }
21
23
 
22
24
  export interface SubagentRecord {
@@ -33,6 +35,7 @@ export interface SubagentRecord {
33
35
  resultConsumed?: boolean;
34
36
  model?: string;
35
37
  background: boolean;
38
+ ownerSessionGeneration?: number;
36
39
  stuckNotified?: boolean;
37
40
  blockedAt?: number;
38
41
  promise?: Promise<void>;
@@ -66,7 +69,7 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
66
69
  try {
67
70
  const filePath = persistedSubagentPath(cwd, record.id);
68
71
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
69
- fs.writeFileSync(filePath, `${JSON.stringify(serializableRecord(record), null, 2)}\n`, "utf-8");
72
+ fs.writeFileSync(filePath, `${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`, "utf-8");
70
73
  } catch (error) {
71
74
  logInternalError("subagent-manager.save", error, `id=${record.id}`);
72
75
  }
@@ -136,6 +139,7 @@ export class SubagentManager {
136
139
  startedAt: Date.now(),
137
140
  model: options.model,
138
141
  background: options.background,
142
+ ownerSessionGeneration: options.ownerSessionGeneration,
139
143
  };
140
144
  this.records.set(record.id, record);
141
145
  this.cwdByRecord.set(record.id, options.cwd);
@@ -373,6 +377,7 @@ export class SubagentManager {
373
377
  id: current.id,
374
378
  runId: current.runId,
375
379
  durationMs: Math.max(0, Date.now() - current.blockedAt),
380
+ ownerSessionGeneration: current.ownerSessionGeneration,
376
381
  });
377
382
  savePersistedSubagentRecord(cwd, current);
378
383
  };
@@ -24,6 +24,7 @@ export interface RunLiveTaskInput {
24
24
  parentContext?: string;
25
25
  parentModel?: unknown;
26
26
  modelRegistry?: unknown;
27
+ isCurrent?: () => boolean;
27
28
  }
28
29
 
29
30
  export interface RunLiveTaskOutput {
@@ -65,6 +66,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
65
66
  }
66
67
  };
67
68
  const attemptStartedAt = new Date();
69
+ const isCurrent = input.isCurrent ?? (() => input.signal?.aborted !== true);
68
70
  const liveResult = await runLiveSessionTask({
69
71
  manifest,
70
72
  task,
@@ -77,6 +79,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
77
79
  parentContext: input.parentContext,
78
80
  parentModel: input.parentModel,
79
81
  modelRegistry: input.modelRegistry,
82
+ isCurrent,
80
83
  onOutput: (text) => appendCrewAgentOutput(manifest, task.id, text),
81
84
  onEvent: (event) => {
82
85
  appendCrewAgentEvent(manifest, task.id, event);
@@ -25,6 +25,8 @@ import { coordinationBridgeInstructions, renderTaskPrompt } from "./task-runner/
25
25
  import { applyAgentProgressEvent, applyUsageToProgress, progressEventSummary, shouldFlushProgressEvent } from "./task-runner/progress.ts";
26
26
  import { checkpointTask, persistSingleTaskUpdate, updateTask } from "./task-runner/state-helpers.ts";
27
27
  import { cleanResultText, isFinalChildEvent } from "./task-runner/result-utils.ts";
28
+ import { evaluateCompletionMutationGuard } from "./completion-guard.ts";
29
+ import { appendTaskAttentionEvent } from "./attention-events.ts";
28
30
 
29
31
  export interface TaskRunnerInput {
30
32
  manifest: TeamRunManifest;
@@ -86,6 +88,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
86
88
  let error: string | undefined;
87
89
  let modelAttempts: ModelAttemptSummary[] | undefined;
88
90
  let parsedOutput: ParsedPiJsonOutput | undefined;
91
+ let finalStdout = "";
92
+ let transcriptPath: string | undefined;
89
93
 
90
94
  let startupEvidence = createStartupEvidence({ command: runtimeKind === "child-process" ? "pi" : runtimeKind === "live-session" ? "live-session" : "safe-scaffold", startedAt: new Date(task.startedAt ?? new Date().toISOString()), finishedAt: new Date(), promptSentAt: new Date(task.startedAt ?? new Date().toISOString()), promptAccepted: true, exitCode: 0 });
91
95
  const inputsArtifact = writeTaskInputsArtifact(manifest, task, dependencyContext);
@@ -100,10 +104,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
100
104
  const candidates = modelRoutingPlan.candidates;
101
105
  const attemptModels = candidates.length > 0 ? candidates : [undefined];
102
106
  const logs: string[] = [];
103
- let finalStdout = "";
104
107
  let finalStderr = "";
105
108
  modelAttempts = [];
106
- const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
109
+ transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
107
110
  let finalCheckpointWritten = false;
108
111
  let lastAgentRecordPersistedAt = 0;
109
112
  let lastHeartbeatPersistedAt = 0;
@@ -258,6 +261,26 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
258
261
  producer: task.id,
259
262
  }) : undefined;
260
263
 
264
+ const mutationGuardMode = input.runtimeConfig?.completionMutationGuard ?? "warn";
265
+ const mutationGuard = !error && mutationGuardMode !== "off" ? evaluateCompletionMutationGuard({ role: task.role, taskText: `${task.title}\n${input.step.task}`, transcriptPath: runtimeKind === "child-process" ? transcriptPath : transcriptArtifact?.path, stdout: finalStdout }) : undefined;
266
+ if (mutationGuard?.reason === "no_mutation_observed") {
267
+ appendTaskAttentionEvent({
268
+ manifest,
269
+ taskId: task.id,
270
+ message: "Implementation-style task completed without an observed mutation tool call.",
271
+ data: { activityState: "needs_attention", reason: "completion_guard", taskId: task.id, agentName: task.agent, observedTools: mutationGuard.observedTools, suggestedAction: mutationGuardMode === "fail" ? "Review the worker output and rerun with a concrete implementation task." : "Review the worker output; set runtime.completionMutationGuard='fail' to enforce this." },
272
+ });
273
+ task = { ...task, agentProgress: { ...(task.agentProgress ?? emptyCrewAgentProgress()), activityState: "needs_attention" } };
274
+ if (mutationGuardMode === "fail") {
275
+ error = "Completion mutation guard failed: implementation-style task completed without an observed mutation tool call.";
276
+ exitCode = exitCode === 0 ? 1 : exitCode;
277
+ if (modelAttempts?.length) {
278
+ modelAttempts = modelAttempts.map((attempt, index) => index === modelAttempts!.length - 1 ? { ...attempt, success: false, exitCode, error } : attempt);
279
+ }
280
+ }
281
+ tasks = updateTask(tasks, task);
282
+ }
283
+
261
284
  task = {
262
285
  ...task,
263
286
  status: error ? "failed" : "completed",
@@ -24,6 +24,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
24
24
  import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
25
25
  import { resolveBatchConcurrency } from "./concurrency.ts";
26
26
  import { mapConcurrent } from "./parallel-utils.ts";
27
+ import { permissionForRole } from "./role-permission.ts";
27
28
 
28
29
  export interface ExecuteTeamRunInput {
29
30
  manifest: TeamRunManifest;
@@ -117,8 +118,11 @@ function slug(value: string): string {
117
118
 
118
119
  function extractAdaptivePlanJson(text: string): string | undefined {
119
120
  const markerMatch = text.match(/ADAPTIVE_PLAN_JSON_START\s*([\s\S]*?)\s*ADAPTIVE_PLAN_JSON_END/);
120
- const fencedMatch = markerMatch ? undefined : text.match(/```(?:json)?\s*([\s\S]*?)```/i);
121
- return markerMatch?.[1] ?? fencedMatch?.[1];
121
+ if (markerMatch?.[1]) return markerMatch[1];
122
+ const startIndex = text.indexOf("ADAPTIVE_PLAN_JSON_START");
123
+ if (startIndex >= 0) return text.slice(startIndex + "ADAPTIVE_PLAN_JSON_START".length).trim();
124
+ const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
125
+ return fencedMatch?.[1];
122
126
  }
123
127
 
124
128
  export function __test__parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
@@ -178,6 +182,52 @@ function closeUnbalancedJson(raw: string): string {
178
182
  return result;
179
183
  }
180
184
 
185
+ function salvageCompletePhaseObjects(raw: string): unknown | undefined {
186
+ const phasesIndex = raw.indexOf('"phases"');
187
+ if (phasesIndex < 0) return undefined;
188
+ const arrayStart = raw.indexOf("[", phasesIndex);
189
+ if (arrayStart < 0) return undefined;
190
+ const phases: unknown[] = [];
191
+ let objectStart = -1;
192
+ let depth = 0;
193
+ let inString = false;
194
+ let escaped = false;
195
+ for (let index = arrayStart + 1; index < raw.length; index++) {
196
+ const char = raw[index];
197
+ if (escaped) {
198
+ escaped = false;
199
+ continue;
200
+ }
201
+ if (char === "\\" && inString) {
202
+ escaped = true;
203
+ continue;
204
+ }
205
+ if (char === '"') {
206
+ inString = !inString;
207
+ continue;
208
+ }
209
+ if (inString) continue;
210
+ if (char === "{") {
211
+ if (depth === 0) objectStart = index;
212
+ depth++;
213
+ continue;
214
+ }
215
+ if (char === "}") {
216
+ if (depth <= 0) continue;
217
+ depth--;
218
+ if (depth === 0 && objectStart >= 0) {
219
+ try {
220
+ phases.push(JSON.parse(raw.slice(objectStart, index + 1)));
221
+ } catch {
222
+ // Ignore malformed trailing phase objects and keep earlier complete phases.
223
+ }
224
+ objectStart = -1;
225
+ }
226
+ }
227
+ }
228
+ return phases.length ? { phases } : undefined;
229
+ }
230
+
181
231
  function adaptiveRoleAlias(role: string, allowed: Set<string>): string | undefined {
182
232
  if (allowed.has(role)) return role;
183
233
  const normalized = slug(role);
@@ -198,6 +248,7 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
198
248
  if (!raw) return { repaired: false, reason: "missing-json" };
199
249
  const candidates = [raw, closeUnbalancedJson(raw)];
200
250
  let parsed: unknown;
251
+ let salvageUsed = false;
201
252
  for (const candidate of candidates) {
202
253
  try {
203
254
  parsed = JSON.parse(candidate);
@@ -206,13 +257,17 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
206
257
  // Try the next repair candidate.
207
258
  }
208
259
  }
260
+ if (!parsed) {
261
+ parsed = salvageCompletePhaseObjects(raw);
262
+ salvageUsed = parsed !== undefined;
263
+ }
209
264
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { repaired: false, reason: "invalid-json" };
210
265
  const phasesRaw = Array.isArray((parsed as { phases?: unknown }).phases) ? (parsed as { phases: unknown[] }).phases : Array.isArray((parsed as { tasks?: unknown }).tasks) ? [{ name: "adaptive", tasks: (parsed as { tasks: unknown[] }).tasks }] : undefined;
211
266
  if (!phasesRaw) return { repaired: false, reason: "missing-phases" };
212
267
  const allowed = new Set(allowedRoles);
213
268
  const phases: AdaptivePlanPhase[] = [];
214
269
  let total = 0;
215
- let repaired = raw !== closeUnbalancedJson(raw);
270
+ let repaired = salvageUsed || raw !== closeUnbalancedJson(raw);
216
271
  for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
217
272
  if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) continue;
218
273
  const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
@@ -398,6 +453,47 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
398
453
  return result.tasks.find((item) => item.id === taskId && item.status === "failed");
399
454
  }
400
455
 
456
+ function requiresPlanApproval(workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
457
+ return workflow.name === "implementation" && runtimeConfig?.requirePlanApproval === true;
458
+ }
459
+
460
+ function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
461
+ return manifest.planApproval?.required === true && manifest.planApproval.status === "pending";
462
+ }
463
+
464
+ function isMutatingTask(task: TeamTaskState): boolean {
465
+ return permissionForRole(task.role) !== "read_only";
466
+ }
467
+
468
+ function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
469
+ if (manifest.planApproval) return manifest;
470
+ const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
471
+ const now = new Date().toISOString();
472
+ const updated: TeamRunManifest = {
473
+ ...manifest,
474
+ updatedAt: now,
475
+ planApproval: {
476
+ required: true,
477
+ status: "pending",
478
+ requestedAt: now,
479
+ updatedAt: now,
480
+ planTaskId: assessTask?.id,
481
+ planArtifactPath: assessTask?.resultArtifact?.path,
482
+ },
483
+ };
484
+ saveRunManifest(updated);
485
+ appendEvent(updated.eventsPath, { type: "plan.approval_required", runId: updated.runId, taskId: assessTask?.id, message: "Adaptive implementation plan requires explicit approval before mutating tasks run.", data: { planArtifactPath: assessTask?.resultArtifact?.path } });
486
+ return updated;
487
+ }
488
+
489
+ function cancelPlanTasks(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
490
+ return tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled", finishedAt: new Date().toISOString(), error: reason, graph: task.graph ? { ...task.graph, queue: "done" } : undefined } : task);
491
+ }
492
+
493
+ function hasPendingMutatingAdaptiveTask(tasks: TeamTaskState[]): boolean {
494
+ return tasks.some((task) => task.status === "queued" && task.adaptive && isMutatingTask(task));
495
+ }
496
+
401
497
  export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
402
498
  let workflow = input.workflow;
403
499
  let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
@@ -422,7 +518,18 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
422
518
  manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
423
519
  return { manifest, tasks };
424
520
  }
425
- if (initialAdaptive.injected) queueIndex = buildTaskGraphIndex(tasks);
521
+ if (initialAdaptive.injected) {
522
+ manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
523
+ queueIndex = buildTaskGraphIndex(tasks);
524
+ } else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
525
+ manifest = ensurePlanApprovalRequested(manifest, tasks);
526
+ }
527
+ if (manifest.planApproval?.status === "cancelled") {
528
+ tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
529
+ await saveRunTasksAsync(manifest, tasks);
530
+ manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
531
+ return { manifest, tasks };
532
+ }
426
533
  manifest = writeProgress(manifest, tasks, "team-runner");
427
534
  await saveRunManifestAsync(manifest);
428
535
  const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
@@ -451,8 +558,16 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
451
558
  if (concurrency.reason.includes(";unbounded:")) {
452
559
  appendEvent(manifest.eventsPath, { type: "limits.unbounded", runId: manifest.runId, message: "Unbounded worker concurrency was explicitly enabled for this run.", data: { concurrencyReason: concurrency.reason, maxConcurrent: concurrency.maxConcurrent } });
453
560
  }
454
- const readyBatch = getReadyTasks(tasks, concurrency.selectedCount, queueIndex);
561
+ const approvalPending = isPlanApprovalPending(manifest);
562
+ const candidateBatch = approvalPending ? getReadyTasks(tasks, tasks.length, queueIndex) : getReadyTasks(tasks, concurrency.selectedCount, queueIndex);
563
+ const readyBatch = approvalPending ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, concurrency.selectedCount) : candidateBatch;
455
564
  if (readyBatch.length === 0) {
565
+ if (approvalPending && candidateBatch.some(isMutatingTask)) {
566
+ await saveRunTasksAsync(manifest, tasks);
567
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
568
+ manifest = updateRunStatus(manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
569
+ return { manifest, tasks };
570
+ }
456
571
  tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
457
572
  await saveRunTasksAsync(manifest, tasks);
458
573
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
@@ -460,7 +575,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
460
575
  return { manifest, tasks };
461
576
  }
462
577
 
463
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: concurrency.reason } });
578
+ appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason } });
464
579
  const results = await mapConcurrent(
465
580
  readyBatch,
466
581
  concurrency.selectedCount,
@@ -520,7 +635,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
520
635
  return { manifest, tasks };
521
636
  }
522
637
  if (injectedAfterBatch.injected) {
638
+ manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
523
639
  queueIndex = buildTaskGraphIndex(tasks);
640
+ } else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
641
+ manifest = ensurePlanApprovalRequested(manifest, tasks);
642
+ }
643
+ if (manifest.planApproval?.status === "cancelled") {
644
+ tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
645
+ await saveRunTasksAsync(manifest, tasks);
646
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
647
+ manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
648
+ return { manifest, tasks };
524
649
  }
525
650
  await saveRunTasksAsync(manifest, tasks);
526
651
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
@@ -36,6 +36,9 @@ export const PiTeamsRuntimeConfigSchema = Type.Object({
36
36
  inheritContext: Type.Optional(Type.Boolean()),
37
37
  promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
38
38
  groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
39
+ groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
40
+ requirePlanApproval: Type.Optional(Type.Boolean()),
41
+ completionMutationGuard: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")])),
39
42
  }, { additionalProperties: false });
40
43
 
41
44
  export const PiTeamsControlConfigSchema = Type.Object({
@@ -1,9 +1,18 @@
1
1
  import { Type } from "typebox";
2
2
 
3
3
  const SkillOverride = Type.Unsafe({
4
- type: ["string", "array", "boolean"],
5
- items: { type: "string" },
6
4
  description: "Skill name(s) to inject, array of skill names, or false to disable role defaults.",
5
+ anyOf: [
6
+ { type: "string" },
7
+ { type: "array", items: { type: "string" } },
8
+ { type: "boolean" },
9
+ ],
10
+ });
11
+
12
+ const FreeformConfig = Type.Unsafe({
13
+ description: "Resource config for management actions.",
14
+ type: "object",
15
+ additionalProperties: true,
7
16
  });
8
17
 
9
18
  export const TeamToolParams = Type.Object({
@@ -66,7 +75,7 @@ export const TeamToolParams = Type.Object({
66
75
  Type.Literal("project"),
67
76
  Type.Literal("both"),
68
77
  ], { description: "Resource scope for discovery or management." })),
69
- config: Type.Optional(Type.Unsafe({ description: "Resource config for management actions." })),
78
+ config: Type.Optional(FreeformConfig),
70
79
  dryRun: Type.Optional(Type.Boolean({ description: "Preview a management mutation without writing files." })),
71
80
  confirm: Type.Optional(Type.Boolean({ description: "Required for destructive management actions." })),
72
81
  force: Type.Optional(Type.Boolean({ description: "Override reference checks for destructive management actions." })),
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import type { ArtifactDescriptor } from "./types.ts";
5
5
  import { atomicWriteFile } from "./atomic-write.ts";
6
6
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
7
+ import { redactSecretString } from "../utils/redaction.ts";
7
8
 
8
9
  function hashContent(content: string): string {
9
10
  return createHash("sha256").update(content).digest("hex");
@@ -108,7 +109,8 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
108
109
  resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
109
110
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
110
111
  resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
111
- atomicWriteFile(filePath, options.content);
112
+ const content = redactSecretString(options.content);
113
+ atomicWriteFile(filePath, content);
112
114
  const stats = fs.statSync(filePath);
113
115
  return {
114
116
  kind: options.kind,
@@ -116,7 +118,7 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
116
118
  createdAt: new Date().toISOString(),
117
119
  producer: options.producer,
118
120
  sizeBytes: stats.size,
119
- contentHash: hashContent(options.content),
121
+ contentHash: hashContent(content),
120
122
  retention: options.retention ?? "run",
121
123
  };
122
124
  }
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
5
5
  import { atomicWriteFile } from "./atomic-write.ts";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
+ import { redactSecrets } from "../utils/redaction.ts";
7
8
 
8
9
  export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
9
10
  export type TeamWatcherAction = "act" | "observe" | "ignore";
@@ -135,7 +136,7 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
135
136
  } catch (error) {
136
137
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
137
138
  }
138
- fs.appendFileSync(eventsPath, `${JSON.stringify(fullEvent)}\n`, "utf-8");
139
+ fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
139
140
  const seq = fullEvent.metadata?.seq ?? 0;
140
141
  try {
141
142
  const stat = fs.statSync(eventsPath);
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import { redactJsonLine } from "../utils/redaction.ts";
2
3
 
3
4
  export interface DrainableSource {
4
5
  pause(): void;
@@ -50,7 +51,8 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
50
51
  return {
51
52
  writeLine(line: string) {
52
53
  if (!stream || closed || !line.trim()) return;
53
- const chunk = `${line}\n`;
54
+ const safeLine = redactJsonLine(line);
55
+ const chunk = `${safeLine}\n`;
54
56
  const chunkBytes = Buffer.byteLength(chunk, "utf-8");
55
57
  if (bytesWritten + chunkBytes > maxBytes) return;
56
58
  try {