pi-crew 0.11.4 → 0.11.6

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 (58) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +2 -2
  3. package/dist/index.mjs +498 -492
  4. package/docs/actions-reference.md +22 -0
  5. package/docs/dynamic-workflows.md +37 -0
  6. package/index.ts +9 -5
  7. package/package.json +18 -14
  8. package/scripts/analyze-run.mjs +101 -14
  9. package/skills/model-routing-context/SKILL.md +4 -3
  10. package/skills/real-test-pi-crew/SKILL.md +10 -7
  11. package/src/config/env-vars.ts +6 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/dispatch/index.ts +4 -3
  16. package/src/extension/team-tool/dispatch/status.ts +7 -3
  17. package/src/extension/team-tool/inspect.ts +90 -0
  18. package/src/extension/team-tool/respond.ts +54 -0
  19. package/src/extension/team-tool/status.ts +10 -0
  20. package/src/extension/team-tool.ts +393 -3
  21. package/src/extension/webhook-notify.ts +23 -1
  22. package/src/observability/exporters/otlp-exporter.ts +93 -11
  23. package/src/prompt/prompt-runtime.ts +13 -4
  24. package/src/runtime/agent-control.ts +1 -15
  25. package/src/runtime/background-runner.ts +3 -0
  26. package/src/runtime/broker/crew-broker.ts +12 -103
  27. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  28. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  29. package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
  30. package/src/runtime/dispatch-batch.ts +3 -1
  31. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  32. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  33. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  34. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  35. package/src/runtime/mcp-proxy.ts +110 -7
  36. package/src/runtime/model/pi-args.ts +14 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/recovery/crash-recovery.ts +6 -1
  39. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  40. package/src/runtime/stale-reconciler.ts +55 -0
  41. package/src/runtime/task-output-context.ts +11 -1
  42. package/src/runtime/task-runner/child-executor.ts +130 -4
  43. package/src/runtime/task-runner/output-splitter.ts +48 -5
  44. package/src/runtime/task-runner/post-execution.ts +2 -2
  45. package/src/runtime/task-runner/pre-execution.ts +37 -24
  46. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  47. package/src/schema/team-tool-schema.ts +27 -1
  48. package/src/state/contracts.ts +16 -0
  49. package/src/state/crew-init.ts +114 -0
  50. package/src/state/stores/active-run-registry.ts +13 -6
  51. package/src/state/types.ts +0 -8
  52. package/src/ui/goal-flag.ts +44 -0
  53. package/src/ui/live-run-sidebar.ts +5 -1
  54. package/src/ui/pi-ui-compat.ts +20 -3
  55. package/src/ui/run-dashboard.ts +2 -1
  56. package/src/ui/widget/widget-model.ts +6 -2
  57. package/src/utils/ip-literal.ts +145 -0
  58. package/src/workflows/workflow-config.ts +6 -0
@@ -131,6 +131,7 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
131
131
  const skillNames = ctx.skillNames;
132
132
  const skillPaths = ctx.skillPaths;
133
133
  const promptArtifact = ctx.promptArtifact;
134
+ const breakdownArtifact = ctx.breakdownArtifact;
134
135
  const inputsArtifact = ctx.inputsArtifact;
135
136
  const skillArtifact = ctx.skillArtifact;
136
137
  const coordinationArtifact = ctx.coordinationArtifact;
@@ -166,7 +167,6 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
166
167
  error: undefined,
167
168
  claim: undefined,
168
169
  heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), { alive: false }),
169
- workerExitStatus: terminalEvidence.at(-1)?.exitStatus,
170
170
  terminalEvidence: terminalEvidence.length ? [...(task.terminalEvidence ?? []), ...terminalEvidence] : task.terminalEvidence,
171
171
  diagnostics: {
172
172
  ...(task.diagnostics ?? {}),
@@ -544,7 +544,6 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
544
544
  resultArtifact,
545
545
  claim: undefined,
546
546
  heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), { alive: false }),
547
- workerExitStatus: terminalEvidence.at(-1)?.exitStatus,
548
547
  terminalEvidence: terminalEvidence.length ? [...(task.terminalEvidence ?? []), ...terminalEvidence] : task.terminalEvidence,
549
548
  ...(logArtifact ? { logArtifact } : {}),
550
549
  ...(transcriptArtifact ? { transcriptArtifact } : {}),
@@ -623,6 +622,7 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
623
622
  artifacts: [
624
623
  ...manifest.artifacts,
625
624
  promptArtifact,
625
+ ...(breakdownArtifact ? [breakdownArtifact] : []),
626
626
  ...(resultArtifact ? [resultArtifact] : []),
627
627
  inputsArtifact,
628
628
  coordinationArtifact,
@@ -23,7 +23,6 @@ import { logInternalError } from "../../utils/internal-error.ts";
23
23
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
24
24
  import type { PreparedTaskWorkspace } from "../../worktree/worktree-manager.ts";
25
25
  import { prepareTaskWorkspaceAsync } from "../../worktree/worktree-manager.ts";
26
- import { reserveControlChannel } from "../agent-control.ts";
27
26
  import { emptyCrewAgentProgress, recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
28
27
  import type { CrewRuntimeKind } from "../crew-agent-runtime.ts";
29
28
  import type { registerStreamBridge } from "../event-stream-bridge.ts";
@@ -71,6 +70,8 @@ export interface TaskExecutionContext {
71
70
  skillPaths: string[] | undefined;
72
71
  prompt: string;
73
72
  promptArtifact: ArtifactDescriptor;
73
+ /** G18: prompt-breakdown artifact descriptor (undefined when PI_CREW_PROMPT_BREAKDOWN off). */
74
+ breakdownArtifact?: ArtifactDescriptor;
74
75
  inputsArtifact: ArtifactDescriptor;
75
76
  skillArtifact: ArtifactDescriptor | undefined;
76
77
  coordinationArtifact: ArtifactDescriptor;
@@ -150,12 +151,18 @@ export async function prepareTaskExecutionContext(
150
151
  startedAt: new Date().toISOString(),
151
152
  claim: createTaskClaim(`task-runner:${input.task.id}`),
152
153
  heartbeat: createWorkerHeartbeat(input.task.id),
154
+ // NEW-3 (SDD-4 W-D) note: deliberately NO pid here — at prepare time the
155
+ // worker does not exist yet (spawn happens later in child-executor.ts).
156
+ // The singleton path's real pid arrives via checkpoint.childPid
157
+ // (child-executor "child-spawned" checkpoint), which the
158
+ // `heartbeat?.pid ?? checkpoint?.childPid` consumers already read as a
159
+ // fallback. Do NOT stamp process.pid here — that is the orchestrator, and
160
+ // it outlives the worker, which would blind the stale-reconciler's
161
+ // PID-liveness gate forever.
153
162
  agentProgress: input.task.agentProgress ?? emptyCrewAgentProgress(),
154
163
  // Lifetime usage accumulator — survives compaction unlike session.stats
155
164
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
156
165
  ...(dependencyContextText ? { dependencyContextText } : {}),
157
- // Reserve control channel before spawn so cancel/steer can target this task immediately
158
- controlReservation: reserveControlChannel(input.task.id, manifest.runId),
159
166
  } as TeamTaskState;
160
167
  let tasks = updateTask(input.tasks, task);
161
168
  // T2/R4 (ADR-4 §3 single-writer linkage): record on the CURRENT plan
@@ -343,27 +350,32 @@ export async function prepareTaskExecutionContext(
343
350
  // SR-02 phase 1: per-section breakdown artifact (opt-in via
344
351
  // PI_CREW_PROMPT_BREAKDOWN=1). Adds the SYSTEM-side pieces the prompt
345
352
  // builder cannot see (agent definition, skills, pre-step output).
346
- if (promptBreakdownEnabled()) {
347
- const sections: Record<string, number> = {
348
- "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
349
- ...(promptResult.sections ?? {}),
350
- "dynamic.preStepOutput": preStepOutput?.length ?? 0,
351
- };
352
- writeArtifact(manifest.artifactsRoot, {
353
- kind: "metadata",
354
- relativePath: `metadata/${task.id}.prompt-breakdown.json`,
355
- content: `${JSON.stringify(
356
- Object.fromEntries(
357
- Object.entries(sections)
358
- .filter(([, chars]) => chars > 0)
359
- .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
360
- ),
361
- null,
362
- 2,
363
- )}\n`,
364
- producer: "prompt-breakdown",
365
- });
366
- }
353
+ // G18 (SDD Buổi-1 WI-3 follow-up): capture the descriptor so it flows
354
+ // through ctx into the manifest artifacts index — otherwise the
355
+ // `breakdown` team-tool action cannot find it on real runs.
356
+ const breakdownArtifact: ArtifactDescriptor | undefined = promptBreakdownEnabled()
357
+ ? (() => {
358
+ const sections: Record<string, number> = {
359
+ "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
360
+ ...(promptResult.sections ?? {}),
361
+ "dynamic.preStepOutput": preStepOutput?.length ?? 0,
362
+ };
363
+ return writeArtifact(manifest.artifactsRoot, {
364
+ kind: "metadata",
365
+ relativePath: `metadata/${task.id}.prompt-breakdown.json`,
366
+ content: `${JSON.stringify(
367
+ Object.fromEntries(
368
+ Object.entries(sections)
369
+ .filter(([, chars]) => chars > 0)
370
+ .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
371
+ ),
372
+ null,
373
+ 2,
374
+ )}\n`,
375
+ producer: "prompt-breakdown",
376
+ });
377
+ })()
378
+ : undefined;
367
379
 
368
380
  const collectedJsonEvents: Record<string, unknown>[] | undefined = collectYieldEvents ? [] : undefined;
369
381
 
@@ -415,6 +427,7 @@ export async function prepareTaskExecutionContext(
415
427
  skillPaths,
416
428
  prompt,
417
429
  promptArtifact,
430
+ breakdownArtifact,
418
431
  inputsArtifact,
419
432
  skillArtifact,
420
433
  coordinationArtifact,
@@ -31,6 +31,27 @@ export const MAX_SUGGESTED_FILES = 10;
31
31
  /** Minimum files suggested when retrieval returns anything. */
32
32
  export const MIN_SUGGESTED_FILES = 5;
33
33
 
34
+ /**
35
+ * G23b (2026-10-02): minimum retrieval score for a file to be suggested.
36
+ *
37
+ * Rationale — the legacy `score > 0` floor in the byPath pass only keeps
38
+ * out zero-score files, so generic path-keyword grazers sail through and
39
+ * fill the top-10. Live evidence (run team_20261002065548, the small-wins
40
+ * batch this fix ships in): 10 junk suggestions scoring 0.45–0.46 (paths
41
+ * grazing generic tokens like "template", "security", "skill", "md",
42
+ * "ts", "source") were injected into worker context ahead of any useful
43
+ * file. 0.5 sits above that observed junk band while still admitting
44
+ * genuinely relevant path-only matches (useful path-only scores reach
45
+ * ~0.64; HIGH_RELEVANCE_THRESHOLD=0.7 stays convergence-only). Applied
46
+ * BEFORE the top-N cap/slice so junk can never occupy a slot even when
47
+ * the qualified set is small.
48
+ *
49
+ * Tuning: recalibrate against real-run evidence if the junk band shifts
50
+ * above 0.5 or gold files start scoring below — and update the G23b
51
+ * pinning tests in the same change (they import this constant).
52
+ */
53
+ export const MIN_SUGGEST_SCORE = 0.5;
54
+
34
55
  /** Stopwords dropped during keyword tokenization (lowercase comparison). */
35
56
  // PERF round 3: expanded from 14 function words to the common verb/pronoun/
36
57
  // filler set. These multiply the scoring cost (keywords × files × passes)
@@ -492,8 +513,13 @@ export async function runRetrievalCycle(task: string, goal: string, cwd: string)
492
513
  const converged = hasConverged(evaluations);
493
514
  // Sort by score desc, take top N (5..10).
494
515
  evaluations.sort((a, b) => b.relevance - a.relevance);
495
- const cap = Math.min(MAX_SUGGESTED_FILES, Math.max(MIN_SUGGESTED_FILES, evaluations.length));
496
- const top = evaluations.slice(0, cap).map((e) => ({
516
+ // G23b: drop sub-floor keyword-grazers BEFORE the cap/slice so junk can
517
+ // never occupy a top-N slot. Sub-floor evaluations stay in `evaluations`
518
+ // above — convergence only counts ≥0.7 hits over always-empty
519
+ // missingContext, so filtering here cannot change `converged`.
520
+ const qualified = evaluations.filter((e) => e.relevance >= MIN_SUGGEST_SCORE);
521
+ const cap = Math.min(MAX_SUGGESTED_FILES, Math.max(MIN_SUGGESTED_FILES, qualified.length));
522
+ const top = qualified.slice(0, cap).map((e) => ({
497
523
  path: path.isAbsolute(e.path) ? path.relative(cwd, e.path) : e.path,
498
524
  score: e.relevance,
499
525
  reason: e.reason,
@@ -376,6 +376,27 @@ const sharedFields = {
376
376
  },
377
377
  ),
378
378
  ),
379
+ // SDD-3 W-C G13: agent-call cap for dynamic-workflow runs. A run is always
380
+ // bounded — unset falls back to DEFAULT_MAX_AGENT_CALLS (200) in the ctx;
381
+ // tripping the cap terminates the run with a structured reason instead of
382
+ // the blind 30-min script timeout. Same loose-numeric shape as tokenBudget.
383
+ maxAgentCalls: Type.Optional(
384
+ Type.Union(
385
+ [
386
+ Type.Literal(""),
387
+ Type.Number({
388
+ description:
389
+ "Cap on ctx.agent() invocations per dynamic-workflow run (steps ≡ agent calls). Default 200 when unset. Tripping the cap terminates the run with a structured agent-call-cap reason. Overrides workflow.maxAgentCalls.",
390
+ minimum: 1,
391
+ }),
392
+ Type.String({ pattern: NUMERIC_STRING_RE }),
393
+ ],
394
+ {
395
+ description:
396
+ "Cap on ctx.agent() invocations per dynamic-workflow run (steps ≡ agent calls). Default 200 when unset. Tripping the cap terminates the run with a structured agent-call-cap reason. Overrides workflow.maxAgentCalls.",
397
+ },
398
+ ),
399
+ ),
379
400
  args: Type.Optional(Type.Any()),
380
401
  analysis: Type.Optional(
381
402
  Type.String({
@@ -398,7 +419,7 @@ const sharedFields = {
398
419
  ),
399
420
  };
400
421
 
401
- // ─── Domain action unions (10+16+7+16+6 = 55 actions) ──────────────────────
422
+ // ─── Domain action unions (10+17+7+17+6 = 57 actions) ──────────────────────
402
423
 
403
424
  const ACTION_DESCRIPTION = "Team action. Defaults to 'list' when omitted.";
404
425
 
@@ -411,6 +432,7 @@ const STATUS_ACTIONS = [
411
432
  "get",
412
433
  "events",
413
434
  "artifacts",
435
+ "breakdown",
414
436
  "summary",
415
437
  "graph",
416
438
  "search",
@@ -586,6 +608,10 @@ export interface TeamToolParamsValue {
586
608
  runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
587
609
  /** Per-workflow token budget for dynamic-workflow runs (round-14 P1-2). */
588
610
  tokenBudget?: number;
611
+ /** Cap on ctx.agent() invocations per dynamic-workflow run (SDD-3 W-C G13).
612
+ * Unset → DEFAULT_MAX_AGENT_CALLS (200); tripping the cap terminates the
613
+ * run with a structured agent-call-cap reason. */
614
+ maxAgentCalls?: number;
589
615
  /** Typed workflow arguments for .dwf.ts scripts, accessible via ctx.args<T>() (round-14 P1-5). */
590
616
  args?: unknown;
591
617
  /** Inline analysis/context notes from the calling session. Persisted to
@@ -94,6 +94,17 @@ export const TEAM_EVENT_TYPES = [
94
94
  "async.stale",
95
95
  "task.waiting",
96
96
  "task.resumed",
97
+ // G5 (deep-review 2026-10-01): respond-delivery observability. The leader
98
+ // previously had NO confirmation that a mailbox respond reached the worker:
99
+ // `respond_delivered` fires at the broker's wait.resolve ack when the parked
100
+ // worker reports it PICKED UP the questionId-tagged response (terminal
101
+ // outcome "answered" — timed-out/aborted resolves are NOT deliveries);
102
+ // `respond_missed` fires root-side when the respond discriminator finds the
103
+ // waiting worker dead (delivery rides the requeue+inject path instead).
104
+ // Neither transitions run state — sibling ask.answered/task.resumed carry
105
+ // the lifecycle; these two carry only the delivery verdict.
106
+ "task.respond_delivered",
107
+ "task.respond_missed",
97
108
  "task.retried",
98
109
  // WP-2/R2 waiting-producer (ADR-0 2026-08-17-waiting-producer-ask item 10):
99
110
  // `ask` tool lifecycle — requested on park acceptance, answered on delivery
@@ -231,6 +242,10 @@ export const TEAM_EVENT_TYPES = [
231
242
  "run.forget_requested",
232
243
  "run.goal_achievement",
233
244
  "run.resume_requested",
245
+ // SDD-3 W-C G4 (team-tool.ts handleResume): forced resume of a FOREIGN-owned
246
+ // run — ownership was bypassed with force:true (liveness was NOT bypassable;
247
+ // live runs are refused before this event can fire). Security-audit trace.
248
+ "run.resume_forced_foreign",
234
249
  // Runtime/surface resolution
235
250
  "runtime.resolved",
236
251
  "surface.degraded",
@@ -249,6 +264,7 @@ export const TEAM_EVENT_TYPES = [
249
264
  "task.retry_attempt",
250
265
  "task.budget_fair_share",
251
266
  "task.model_dropped",
267
+ "task.mcp_enforcement_degraded",
252
268
  "task.output_validation",
253
269
  "task.steer_queued",
254
270
  "task.steer_dropped",
@@ -276,6 +276,116 @@ function computeCrewRoot(cwd: string): string {
276
276
  return fs.existsSync(piDir) ? safeJoin(piDir, "teams") : crewDir;
277
277
  }
278
278
 
279
+ /**
280
+ * G23c (2026-10-02): sweep an empty legacy `.pi/teams` residue once the
281
+ * project has migrated to the `.crew/` layout.
282
+ *
283
+ * When `.crew/` exists, `computeCrewRoot` resolves to `.crew/` and the
284
+ * legacy `.pi/teams/` tree from an older pi-crew version is never revisited
285
+ * — dead residue stays on disk forever (plan §G23c: "0 code dọn"). This
286
+ * sweep is deliberately CONSERVATIVE because it deletes from the user's
287
+ * workspace:
288
+ *
289
+ * 1. Fires ONLY when the active crew root is the `.crew` layout AND
290
+ * `<repoRoot>/.pi/teams` exists (a live legacy root is never touched).
291
+ * 2. Removes the legacy tree ONLY when it is a bare pi-crew skeleton:
292
+ * known pi-crew directory names at every depth, and only placeholder
293
+ * files (`.gitkeep` anywhere; `README.md`/`.gitignore` at the root).
294
+ * Every entry is content-checked before anything is deleted.
295
+ * 3. Any real data — a run manifest under `state/`, artifact content, a
296
+ * user-authored agent/workflow file, an unknown entry — keeps the
297
+ * residue in place with ONE info log line: auto-migrating run state
298
+ * would change the run-lookup path, which is too risky for an
299
+ * automatic sweep (leader/user decision).
300
+ * 4. Never touches anything under `.pi/` other than the `teams/` path
301
+ * itself — the `.pi` parent and all siblings (`.pi/agent`, ...) stay.
302
+ *
303
+ * Self-contained by design: inline helpers only, no new module imports.
304
+ * Any failure inside the sweep is logged and swallowed — init must never
305
+ * break because the residue could not be inspected.
306
+ */
307
+
308
+ /** Directory names pi-crew itself creates/uses under a crew root (any depth).
309
+ * Sources: ensureCrewDirectory (state/subagents/runs/artifacts/cache/graphs/
310
+ * audit), project-init.ts (agents/teams/workflows/imports), and the legacy
311
+ * .gitignore entries in gitignore-manager.ts (worktrees/imports). */
312
+ const LEGACY_TEAMS_SKELETON_DIRS = new Set([
313
+ "state",
314
+ "subagents",
315
+ "runs",
316
+ "artifacts",
317
+ "cache",
318
+ "graphs",
319
+ "audit",
320
+ "worktrees",
321
+ "agents",
322
+ "teams",
323
+ "workflows",
324
+ "imports",
325
+ ]);
326
+
327
+ /** Placeholder files pi-crew itself writes at the crew-root level. */
328
+ const LEGACY_TEAMS_SKELETON_ROOT_FILES = new Set(["README.md", ".gitignore"]);
329
+
330
+ /**
331
+ * True when `root` holds no data: every directory has a pi-crew skeleton
332
+ * name and every file is a pi-crew placeholder. Anything else — run
333
+ * manifests, artifacts, user agent/workflow content, unknown entries —
334
+ * returns false (the caller then keeps the residue).
335
+ */
336
+ function isBareLegacyTeamsSkeleton(root: string): boolean {
337
+ const walk = (dir: string, depth: number): boolean => {
338
+ let entries: fs.Dirent[];
339
+ try {
340
+ entries = fs.readdirSync(dir, { withFileTypes: true });
341
+ } catch {
342
+ // Unreadable => assume data; never delete what we cannot inspect.
343
+ return false;
344
+ }
345
+ for (const entry of entries) {
346
+ if (entry.isDirectory()) {
347
+ if (!LEGACY_TEAMS_SKELETON_DIRS.has(entry.name)) return false;
348
+ if (!walk(safeJoin(dir, entry.name), depth + 1)) return false;
349
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
350
+ // `.gitkeep` placeholders are written at any depth; README.md and
351
+ // .gitignore only at the crew-root level. Any other file (run
352
+ // manifest, artifact, user content) counts as data.
353
+ if (entry.name === ".gitkeep") continue;
354
+ if (depth === 0 && LEGACY_TEAMS_SKELETON_ROOT_FILES.has(entry.name)) continue;
355
+ return false;
356
+ } else {
357
+ // Sockets/FIFOs/devices — unknown => keep.
358
+ return false;
359
+ }
360
+ }
361
+ return true;
362
+ };
363
+ return walk(root, 0);
364
+ }
365
+
366
+ /** See the G23c block comment above. Returns silently when there is nothing
367
+ * safe to sweep. */
368
+ function sweepLegacyTeamsResidue(cwd: string, activeCrewRoot: string): void {
369
+ try {
370
+ const repoRoot = findProjectRoot(cwd) ?? cwd;
371
+ const legacyTeamsDir = safeJoin(repoRoot, ".pi", "teams");
372
+ // Sweep only when the ACTIVE layout is `.crew` (computeCrewRoot did NOT
373
+ // resolve to the legacy root) and a legacy tree actually exists.
374
+ if (activeCrewRoot === legacyTeamsDir || !fs.existsSync(legacyTeamsDir)) return;
375
+ if (isBareLegacyTeamsSkeleton(legacyTeamsDir)) {
376
+ // Exact-path removal of `.pi/teams` only — the `.pi` parent and
377
+ // every sibling under it stay untouched.
378
+ fs.rmSync(legacyTeamsDir, { recursive: true, force: true });
379
+ return;
380
+ }
381
+ console.info(
382
+ `[pi-crew] legacy ${legacyTeamsDir} still contains data — kept in place (auto-migration would change the run-lookup path); remove manually once you no longer need it`,
383
+ );
384
+ } catch (error) {
385
+ console.info(`[pi-crew] legacy .pi/teams residue sweep skipped: ${error instanceof Error ? error.message : String(error)}`);
386
+ }
387
+ }
388
+
279
389
  /**
280
390
  * Ensure the .crew directory structure exists with all required subdirectories,
281
391
  * placeholder files, README, and .gitignore entries.
@@ -287,6 +397,10 @@ function computeCrewRoot(cwd: string): string {
287
397
  export async function ensureCrewDirectory(cwd: string): Promise<void> {
288
398
  const crewRoot = computeCrewRoot(cwd);
289
399
 
400
+ // G23c: once `.crew/` is the active layout, sweep an empty legacy
401
+ // `.pi/teams` skeleton left behind by an older pi-crew version.
402
+ sweepLegacyTeamsResidue(cwd, crewRoot);
403
+
290
404
  // 1. Create directory structure
291
405
  const dirs = [
292
406
  crewRoot,
@@ -16,6 +16,12 @@ const BINARY_MAGIC = Buffer.from("PICREW2BIN", "utf-8");
16
16
  /** Binary format version for forward compatibility. */
17
17
  const BINARY_VERSION = 1;
18
18
 
19
+ /** Staleness horizon (2.19 / FIX Issue 2): entries — non-async ones, and async
20
+ * ones guarding against PID reuse — whose manifest last write is older than
21
+ * this are filtered as not alive. Exported so resume-liveness consumers
22
+ * (team-tool.ts) share the exact horizon instead of mirroring a copy. */
23
+ export const ACTIVE_RUN_STALE_MS = 30 * 60_000;
24
+
19
25
  export interface ActiveRunRegistryEntry {
20
26
  runId: string;
21
27
  cwd: string;
@@ -334,16 +340,17 @@ function filterAliveEntries(entries: ActiveRunRegistryEntry[]): {
334
340
  if (code !== "ESRCH" && code !== "ENOENT") return false;
335
341
  return false;
336
342
  }
337
- // FIX Issue 2: Async runs older than 30 min are stale even if PID is alive.
338
- // This guards against PID reuse after the original process exits.
343
+ // FIX Issue 2: Async runs older than the staleness horizon are stale even if
344
+ // PID is alive. This guards against PID reuse after the original process exits.
339
345
  const updatedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : NaN;
340
- if (Number.isFinite(updatedAt) && Date.now() - updatedAt > 30 * 60 * 1000) return false;
346
+ if (Number.isFinite(updatedAt) && Date.now() - updatedAt > ACTIVE_RUN_STALE_MS) return false;
341
347
  }
342
- // 2.19 — Stale non-async run: live-session/scaffold runs older than 30 min
343
- // Without this, test runs that crash/leak would stay in the registry forever.
348
+ // 2.19 — Stale non-async run: live-session/scaffold runs older than the
349
+ // staleness horizon. Without this, test runs that crash/leak would stay in
350
+ // the registry forever.
344
351
  if (!raw.async) {
345
352
  const updatedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : NaN;
346
- if (Number.isFinite(updatedAt) && Date.now() - updatedAt > 30 * 60 * 1000) return false;
353
+ if (Number.isFinite(updatedAt) && Date.now() - updatedAt > ACTIVE_RUN_STALE_MS) return false;
347
354
  }
348
355
  } catch {
349
356
  return false;
@@ -638,7 +638,6 @@ export interface TeamTaskState {
638
638
  heartbeat?: WorkerHeartbeatState;
639
639
  checkpoint?: TaskCheckpointState;
640
640
  attempts?: TaskAttemptState[];
641
- workerExitStatus?: WorkerExitStatus;
642
641
  terminalEvidence?: OperationTerminalEvidence[];
643
642
  taskPacket?: TaskPacket;
644
643
  verification?: VerificationEvidence;
@@ -675,7 +674,6 @@ export interface TeamTaskState {
675
674
  retryCount?: number;
676
675
  lastDecision?: PolicyDecision;
677
676
  };
678
- controlReservation?: ControlReservation;
679
677
 
680
678
  /** Structured diagnostics per task (ASI pattern from pi-autoresearch). */
681
679
  diagnostics?: Record<string, unknown>;
@@ -713,12 +711,6 @@ export interface TeamTaskState {
713
711
  };
714
712
  }
715
713
 
716
- export interface ControlReservation {
717
- reservedAt: string;
718
- controllerId: string;
719
- acceptsControlEvents: boolean;
720
- }
721
-
722
714
  /**
723
715
  * A task scheduled to fire on a cron expression, interval, or one-shot.
724
716
  * Persisted at `<cwd>/.crew/state/schedules/<sessionId>.json`.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * G19 (W-E Phase 1): shared goal-achievement surfacing helpers.
3
+ *
4
+ * `manifest.goalAchieved` (set once by `applyGoalAchievement` at run
5
+ * completion) had ZERO readers — the false-green verdict was persisted but
6
+ * never shown. These helpers are the single render rule so every surface
7
+ * (team-tool status, widget label, dashboard rows, live sidebar) agrees:
8
+ *
9
+ * - NEVER render while the run is still active (verdict is not final).
10
+ * - `true` → silent (a green run needs no extra noise).
11
+ * - `false` / `"unknown"` → ⚠ warning (false-green / not verified).
12
+ * - `undefined` → silent (legacy runs created before the assessment existed,
13
+ * or goal-loop assessment not enabled).
14
+ */
15
+
16
+ import { isTerminalRunStatus, type TeamRunStatus } from "../state/contracts.ts";
17
+
18
+ /** True when the run is terminal AND the goal was NOT confirmed achieved. */
19
+ export function isGoalNotAchieved(run: { status?: string; goalAchieved?: boolean | "unknown" }): boolean {
20
+ if (run.status === undefined) return false;
21
+ if (!isTerminalRunStatus(run.status as TeamRunStatus)) return false;
22
+ return run.goalAchieved === false || run.goalAchieved === "unknown";
23
+ }
24
+
25
+ /** Short suffix for one-line labels (widget title, dashboard row, sidebar). */
26
+ export function goalFlagSuffix(run: { status?: string; goalAchieved?: boolean | "unknown" }): string {
27
+ return isGoalNotAchieved(run) ? " ⚠" : "";
28
+ }
29
+
30
+ /**
31
+ * Full status line for team-tool status output. Empty string when silent
32
+ * (non-terminal, achieved, or never assessed). Includes the false-green note
33
+ * (the reason string recorded by `applyGoalAchievement`) when present.
34
+ */
35
+ export function goalAchievedStatusLabel(run: {
36
+ status?: string;
37
+ goalAchieved?: boolean | "unknown";
38
+ goalAchievementNote?: string;
39
+ }): string {
40
+ if (!isGoalNotAchieved(run)) return "";
41
+ const verdict = run.goalAchieved === "unknown" ? "unknown (not verified)" : "not achieved";
42
+ const note = run.goalAchievementNote ? ` — ${run.goalAchievementNote}` : "";
43
+ return `Goal achieved: ⚠ ${verdict}${note}`;
44
+ }
@@ -9,6 +9,7 @@ import { aggregateUsage, formatTokens } from "../state/usage.ts";
9
9
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
10
10
  import { truncate } from "../utils/visual.ts";
11
11
  import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
12
+ import { goalFlagSuffix } from "./goal-flag.ts";
12
13
  import { DASHBOARD_KEYS } from "./keybinding-map.ts";
13
14
  import { ACTIVE, canopyLine, formatHint, RAIL, type RailSlot, railLine, sectionLine, shortId, statusSlot } from "./rail.ts";
14
15
  import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
@@ -230,6 +231,9 @@ export class LiveRunSidebar {
230
231
  // user sees *why* a run ended without having to switch panes. The reason
231
232
  // is already computed on the consumed snapshot (cancellationReason).
232
233
  const TERMINAL_WITH_REASON = ["failed", "cancelled", "stopped"];
234
+ // G19 (W-E Phase 1): goal false-green flag on terminal runs — same rule
235
+ // as the team-tool status output; never rendered while still running.
236
+ const goalSuffix = goalFlagSuffix(run) ? " · ⚠ goal" : "";
233
237
  const reasonSuffix =
234
238
  TERMINAL_WITH_REASON.includes(run.status) && snapshot?.cancellationReason
235
239
  ? ` · ${truncate(snapshot.cancellationReason, 40)}`
@@ -241,7 +245,7 @@ export class LiveRunSidebar {
241
245
  "border",
242
246
  this.theme.fg(
243
247
  "muted",
244
- `${shortId(run.runId)} · ${run.status ?? "?"}${reasonSuffix} · ${run.workspaceMode ?? "single"}`,
248
+ `${shortId(run.runId)} · ${run.status ?? "?"}${reasonSuffix}${goalSuffix} · ${run.workspaceMode ?? "single"}`,
245
249
  ),
246
250
  this.theme,
247
251
  budget,
@@ -36,6 +36,8 @@ export function setWorkingIndicator(ctx: UiContext, options?: WorkingIndicatorOp
36
36
  if (typeof fn === "function") fn.call(ctx.ui, options);
37
37
  }
38
38
 
39
+ /** Install or update an extension widget slot.
40
+ * No-op when the host UI predates the `setWidget` API. */
39
41
  export function setExtensionWidget(
40
42
  ctx: UiContext,
41
43
  key: string,
@@ -43,7 +45,9 @@ export function setExtensionWidget(
43
45
  options?: WidgetOptionsWithPersist,
44
46
  ): void {
45
47
  const { persist: _persist, ...widgetOptions } = options ?? {};
46
- ctx.ui.setWidget(key, content as never, widgetOptions as WidgetOptions);
48
+ const record = maybeRecord(ctx.ui);
49
+ const fn = record?.setWidget;
50
+ if (typeof fn === "function") fn.call(ctx.ui, key, content as never, widgetOptions as WidgetOptions);
47
51
  }
48
52
 
49
53
  /**
@@ -66,12 +70,25 @@ export function setFooter(ctx: UiContext | undefined, factory: FooterFactory | u
66
70
  if (typeof fn === "function") fn.call(ctx.ui, factory as never);
67
71
  }
68
72
 
73
+ /** Show a custom focused component.
74
+ * When the host UI predates (or mangles) the `custom` API, resolves `undefined`
75
+ * instead of rejecting or hanging: the sole caller today
76
+ * (src/extension/registration/ui.ts) already fire-and-forgets via
77
+ * `void showCustom(...)`, so a rejected or never-settling promise would only
78
+ * surface as an unhandled rejection. */
69
79
  export function showCustom<T>(ctx: UiContext, factory: CustomFactory<T>, options?: CustomOptions): Promise<T> {
70
- const custom = ctx.ui.custom as unknown as GenericCustom;
80
+ const record = maybeRecord(ctx.ui);
81
+ const fn = record?.custom;
82
+ if (typeof fn !== "function") return Promise.resolve(undefined as T);
83
+ const custom = fn as unknown as GenericCustom;
71
84
  return custom<T>(factory, options);
72
85
  }
73
86
 
87
+ /** Set a status fallback line.
88
+ * No-op when the host UI predates the `setStatus` API. */
74
89
  export function setStatusFallback(ctx: UiContext, key: string, lines: string | readonly string[] | undefined, segment?: string): void {
75
90
  const text = typeof lines === "string" ? lines : lines ? [...lines].join("\n") : undefined;
76
- ctx.ui.setStatus(segment ? `${key}:${segment}` : key, text);
91
+ const record = maybeRecord(ctx.ui);
92
+ const fn = record?.setStatus;
93
+ if (typeof fn === "function") fn.call(ctx.ui, segment ? `${key}:${segment}` : key, text);
77
94
  }
@@ -29,6 +29,7 @@ import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
29
29
  import { renderScheduleDetails, renderSchedulesPane } from "./dashboard-panes/schedules-pane.ts";
30
30
  import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
31
31
  import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
32
+ import { goalFlagSuffix } from "./goal-flag.ts";
32
33
  import { type DashboardKeyAction, dashboardActionForKey } from "./keybinding-map.ts";
33
34
  import { HelpOverlay } from "./overlays/help-overlay.ts";
34
35
  import { ACTIVE, CURSOR, canopyLine, formatHint, gaugeBar, overflowHint, RAIL, railLine, sectionLine } from "./rail.ts";
@@ -448,7 +449,7 @@ function runLabel(
448
449
  // survive; team/workflow/step clip) rather than the goal. Optional `maxW`
449
450
  // enables the goal-aware truncation; legacy 3-arg callers (dev patch
450
451
  // scripts) get the untruncated full label.
451
- const head = `${marker} ${icon} ${run.runId.slice(-8)} ${status}`;
452
+ const head = `${marker} ${icon} ${run.runId.slice(-8)} ${status}${goalFlagSuffix(run)}`;
452
453
  const meta = `${teamWorkflowLabel(run.team, run.workflow)} · ${step}`;
453
454
  const goal = sanitizeLine(run.goal ?? "");
454
455
  if (maxW === undefined) return sanitizeLine(`${head} · ${meta} · ${goal}`);
@@ -12,6 +12,7 @@ import type { ManifestCache } from "../../runtime/manifest-cache.ts";
12
12
  import { isDisplayActiveRun } from "../../runtime/process-status.ts";
13
13
  import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts";
14
14
  import type { TeamRunManifest } from "../../state/types.ts";
15
+ import { goalFlagSuffix } from "../goal-flag.ts";
15
16
  import { ACTIVE, RAIL, shortId } from "../rail.ts";
16
17
  import type { RunSnapshotCache } from "../snapshot-types.ts";
17
18
  import type { WidgetRun } from "./widget-types.ts";
@@ -128,6 +129,9 @@ export function statusSummary(runs: WidgetRun[]): string {
128
129
  export function shortRunLabel(run: TeamRunManifest): string {
129
130
  const team = (run.team ?? "").trim();
130
131
  const workflow = (run.workflow ?? "").trim();
131
- if (team && workflow && team !== workflow) return `${team}/${workflow}`;
132
- return team || workflow || shortId(run.runId);
132
+ // G19 (W-E Phase 1): append the ⚠ goal flag when a TERMINAL run did not
133
+ // achieve its goal (false-green surfacing) — silent otherwise, so active
134
+ // runs and green runs never flicker.
135
+ const label = team && workflow && team !== workflow ? `${team}/${workflow}` : team || workflow || shortId(run.runId);
136
+ return `${label}${goalFlagSuffix(run)}`;
133
137
  }