pi-crew 0.10.4 → 0.10.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 (88) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/agents/analyst.md +37 -2
  3. package/agents/cold-verifier.md +10 -1
  4. package/agents/councillor-critic.md +39 -0
  5. package/agents/councillor-pragmatist.md +39 -0
  6. package/agents/councillor-skeptic.md +41 -0
  7. package/agents/critic.md +40 -2
  8. package/agents/designer.md +58 -0
  9. package/agents/executor.md +39 -2
  10. package/agents/explorer.md +38 -2
  11. package/agents/librarian.md +49 -0
  12. package/agents/oracle.md +54 -0
  13. package/agents/orchestrator.md +48 -0
  14. package/agents/planner.md +41 -2
  15. package/agents/reviewer.md +39 -2
  16. package/agents/security-reviewer.md +43 -2
  17. package/agents/test-engineer.md +48 -2
  18. package/agents/verifier.md +14 -1
  19. package/agents/writer.md +32 -2
  20. package/dist/index.mjs +1297 -853
  21. package/package.json +1 -1
  22. package/skills/async-worker-recovery/SKILL.md +4 -1
  23. package/skills/child-pi-spawning/SKILL.md +4 -1
  24. package/skills/context-artifact-hygiene/SKILL.md +4 -1
  25. package/skills/council/SKILL.md +24 -45
  26. package/skills/delegation-patterns/SKILL.md +18 -1
  27. package/skills/distill-persona/SKILL.md +4 -1
  28. package/skills/distill-software/SKILL.md +4 -1
  29. package/skills/event-log-tracing/SKILL.md +4 -1
  30. package/skills/git-master/SKILL.md +4 -1
  31. package/skills/iterative-audit/SKILL.md +4 -1
  32. package/skills/live-agent-lifecycle/SKILL.md +4 -1
  33. package/skills/mailbox-interactive/SKILL.md +4 -1
  34. package/skills/model-routing-context/SKILL.md +10 -1
  35. package/skills/multi-perspective-review/SKILL.md +18 -1
  36. package/skills/observability-reliability/SKILL.md +4 -1
  37. package/skills/orchestration/SKILL.md +18 -1
  38. package/skills/ownership-session-security/SKILL.md +4 -1
  39. package/skills/pi-extension-lifecycle/SKILL.md +4 -1
  40. package/skills/post-mortem/SKILL.md +4 -1
  41. package/skills/read-only-explorer/SKILL.md +4 -1
  42. package/skills/real-test-pi-crew/SKILL.md +165 -12
  43. package/skills/requirements-to-task-packet/SKILL.md +10 -1
  44. package/skills/research/SKILL.md +4 -1
  45. package/skills/resource-discovery-config/SKILL.md +10 -1
  46. package/skills/runtime-state-reader/SKILL.md +4 -1
  47. package/skills/safe-bash/SKILL.md +4 -1
  48. package/skills/scrutinize/SKILL.md +24 -1
  49. package/skills/secure-agent-orchestration-review/SKILL.md +4 -1
  50. package/skills/state-mutation-locking/SKILL.md +4 -1
  51. package/skills/systematic-debugging/SKILL.md +4 -1
  52. package/skills/verification-before-done/SKILL.md +18 -1
  53. package/skills/widget-rendering/SKILL.md +4 -1
  54. package/skills/workspace-isolation/SKILL.md +4 -1
  55. package/skills/worktree-isolation/SKILL.md +4 -1
  56. package/src/config/config-validation.ts +1 -0
  57. package/src/config/types.ts +8 -0
  58. package/src/errors.ts +1 -1
  59. package/src/extension/context-status-injection.ts +2 -2
  60. package/src/extension/knowledge-injection.ts +19 -7
  61. package/src/extension/post-init-skill-check.ts +32 -0
  62. package/src/extension/register.ts +9 -1
  63. package/src/extension/registration/hook-registration.ts +20 -3
  64. package/src/extension/registration/tool-loop-guard.ts +243 -0
  65. package/src/extension/team-tool/handle-settings.ts +10 -0
  66. package/src/extension/team-tool/run.ts +42 -1
  67. package/src/extension/team-tool-types.ts +6 -0
  68. package/src/prompt/prompt-runtime.ts +25 -6
  69. package/src/runtime/async-runner.ts +75 -11
  70. package/src/runtime/background-runner.ts +73 -7
  71. package/src/runtime/broker/crew-broker-client.ts +45 -2
  72. package/src/runtime/broker/crew-broker.ts +22 -27
  73. package/src/runtime/broker/protocol/request-parsers.ts +10 -2
  74. package/src/runtime/broker/stdin-handshake.ts +87 -0
  75. package/src/runtime/broker/wait-push.ts +45 -0
  76. package/src/runtime/detached-run-results.ts +25 -1
  77. package/src/runtime/foreground-watchdog.ts +24 -5
  78. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  79. package/src/runtime/model/model-scope.ts +2 -2
  80. package/src/runtime/run-tracker.ts +74 -19
  81. package/src/runtime/skill-instructions.ts +20 -4
  82. package/src/runtime/task-runner/child-executor.ts +1 -1
  83. package/src/runtime/task-runner/prompt-builder.ts +22 -9
  84. package/src/schema/config-schema.ts +1 -0
  85. package/src/skills/discover-skills.ts +2 -2
  86. package/src/ui/settings-overlay.ts +40 -0
  87. package/src/utils/frontmatter.ts +7 -1
  88. package/src/utils/ndjson.ts +9 -1
@@ -69,6 +69,13 @@ export function startForegroundWatchdog(opts: WatchdogOptions): void {
69
69
  // Don't stack watchdogs for the same run
70
70
  if (activeWatchdogs.has(runId)) return;
71
71
 
72
+ // ARCH-5: cap consecutive no-progress wake notices at 2, then send one
73
+ // final guidance message and go quiet — a ~24-notices/2h drip trains the
74
+ // user to ignore the watchdog. Reset on any sign of progress (run ends or
75
+ // leaves the hung state).
76
+ let consecutiveHungNotices = 0;
77
+ const HUNG_NOTICE_CAP = 2;
78
+
72
79
  const check = (): void => {
73
80
  // Check if max monitor time exceeded
74
81
  if (Date.now() - startTime > maxMonitorMs) {
@@ -105,15 +112,27 @@ export function startForegroundWatchdog(opts: WatchdogOptions): void {
105
112
  const now = Date.now();
106
113
  if (isLikelyOrphanedActiveRun(manifest, agents, now)) {
107
114
  const detail = `status=${manifest.status}, updatedAt=${manifest.updatedAt}, agents=${agents.length}`;
115
+ consecutiveHungNotices += 1;
108
116
  try {
109
- pi.sendUserMessage(
110
- `pi-crew watchdog: run ${runId} appears hung (${detail}). Consider running team action='cancel' runId='${runId}' or team action='doctor'.`,
111
- { deliverAs: "followUp" },
112
- );
117
+ if (consecutiveHungNotices <= HUNG_NOTICE_CAP) {
118
+ pi.sendUserMessage(
119
+ `pi-crew watchdog: run ${runId} appears hung (${detail}). Consider running team action='cancel' runId='${runId}' or team action='doctor'.`,
120
+ { deliverAs: "followUp" },
121
+ );
122
+ } else if (consecutiveHungNotices === HUNG_NOTICE_CAP + 1) {
123
+ // One-time handoff, then silence — still monitoring, no more drips.
124
+ pi.sendUserMessage(
125
+ `pi-crew watchdog: run ${runId} is still hung after ${consecutiveHungNotices} checks — going quiet now. Intervene via team action='cancel' runId='${runId}', team action='doctor', or leave it; a completion notice will still fire.`,
126
+ { deliverAs: "followUp" },
127
+ );
128
+ }
113
129
  } catch {
114
130
  /* non-critical */
115
131
  }
116
- // Don't stop — keep monitoring. The assistant or user may intervene.
132
+ // Keep monitoring (ARCH-5: silently past the cap — no more notices).
133
+ } else {
134
+ // Run is alive and progressing — reset the no-progress wake counter.
135
+ consecutiveHungNotices = 0;
117
136
  }
118
137
  } catch {
119
138
  // Non-critical — skip this check
@@ -273,7 +273,7 @@ function numberField(obj: Record<string, unknown> | undefined, keys: string[]):
273
273
 
274
274
  /**
275
275
  * F7: resolve the enabledModels allowlist for the current project, but only
276
- * if the `runtime.reliability.scopeModels` toggle is ON. Returns an empty
276
+ * if the `reliability.scopeModels` toggle is ON. Returns an empty
277
277
  * array when the toggle is off or no allowlist is configured — the routing
278
278
  * gate treats empty patterns as "no enforcement" (no-op). Best-effort:
279
279
  * any failure to read the toggle or the allowlist silently disables the gate
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * model-scope.ts — Opt-in model-scope enforcement (F7).
3
3
  *
4
- * When `runtime.reliability.scopeModels` is enabled, subagent model choices
4
+ * When `reliability.scopeModels` is enabled, subagent model choices
5
5
  * that fall outside the user's pi `enabledModels` allowlist are flagged:
6
6
  * - Caller-supplied (per-spawn override / step / team role) out-of-scope
7
7
  * → HARD ERROR to orchestrator (fail fast before spawn).
@@ -128,7 +128,7 @@ export function checkModelScope(
128
128
  * Read the user's `enabledModels` allowlist from pi's SettingsManager.
129
129
  * Returns an empty array when the SettingsManager export is unavailable, the
130
130
  * allowlist is unset, or any error occurs (best-effort, never throws). The
131
- * caller should still gate on `runtime.reliability.scopeModels` — an empty
131
+ * caller should still gate on `reliability.scopeModels` — an empty
132
132
  * patterns array is a no-op (nothing to enforce against).
133
133
  *
134
134
  * @internal Only the runtime spawn layers should call this. Pure module: pure
@@ -1,8 +1,6 @@
1
1
  import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { loadRunManifestById } from "../state/stores/state-store.ts";
2
+ import { createRunPaths, loadRunManifestById } from "../state/stores/state-store.ts";
4
3
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
- import { projectCrewRoot } from "../utils/paths.ts";
6
4
  import { isFinishedRunStatus } from "./process-status.ts";
7
5
 
8
6
  export interface RunWaitResult {
@@ -11,6 +9,19 @@ export interface RunWaitResult {
11
9
  /** True when the waiter was released early by `detachRunPromise` while the
12
10
  * run itself keeps executing (see that function). */
13
11
  detached?: boolean;
12
+ /** F1 (2026-09-12 live battery): set when the waiter was released early
13
+ * because a task PARKED on `ask` — the broker pushes this via
14
+ * `resolveRunPromise` so the sync caller's tool call returns with the
15
+ * question instead of blocking until the watchdog kills the worker. The
16
+ * run keeps executing; the leader answers via `team action='respond'`
17
+ * then re-blocks via `team action='wait'`. */
18
+ waiting?: {
19
+ taskId: string;
20
+ questionId: string;
21
+ question: string;
22
+ deadline: number;
23
+ options?: string[];
24
+ };
14
25
  }
15
26
 
16
27
  export interface ActiveRunPromise {
@@ -32,7 +43,22 @@ const activeRunPromises = new Map<string, ActiveRunPromise>();
32
43
  */
33
44
  const detachRequests = new Set<string>();
34
45
 
46
+ /** F1 tombstones (2026-09-12): resolveRunPromise deletes the live entry after
47
+ * resolving it, so a register+resolve landing BETWEEN two poll ticks of a
48
+ * slow-path waiter orphaned the payload (the waiter held no reference to the
49
+ * entry). A waiter on the polling path consumes the tombstone instead.
50
+ * Bounded — oldest evicted past the limit (detached runs never wait). */
51
+ const resolvedRunResults = new Map<string, RunWaitResult>();
52
+ const RESOLVED_TOMBSTONE_LIMIT = 32;
53
+
35
54
  export function registerRunPromise(runId: string): ActiveRunPromise {
55
+ // Idempotent (F1 live-probe fix, 2026-09-12): run.ts pre-registers BEFORE
56
+ // startForegroundRun so the waitForRun that runs immediately after can hit
57
+ // the medium path; executeTeamRunCore's later `void registerRunPromise(...)`
58
+ // must NOT overwrite the entry (an overwrite strands waiters holding the
59
+ // old promise — the waiting-push would resolve a promise nobody awaits).
60
+ const existing = activeRunPromises.get(runId);
61
+ if (existing) return existing;
36
62
  detachRequests.delete(runId);
37
63
  let resolve!: (value: RunWaitResult) => void;
38
64
  let reject!: (reason: unknown) => void;
@@ -85,6 +111,13 @@ export function resolveRunPromise(runId: string, result: RunWaitResult): void {
85
111
  entry.resolve(result);
86
112
  activeRunPromises.delete(runId);
87
113
  }
114
+ // F1 tombstone: a slow-path waiter (register/await race, or register+resolve
115
+ // between two ticks) must still see the push. Cheap Map.set; bounded above.
116
+ resolvedRunResults.set(runId, result);
117
+ if (resolvedRunResults.size > RESOLVED_TOMBSTONE_LIMIT) {
118
+ const oldest = resolvedRunResults.keys().next().value;
119
+ if (oldest !== undefined) resolvedRunResults.delete(oldest);
120
+ }
88
121
  }
89
122
 
90
123
  export function rejectRunPromise(runId: string, reason: unknown): void {
@@ -95,6 +128,17 @@ export function rejectRunPromise(runId: string, reason: unknown): void {
95
128
  }
96
129
  }
97
130
 
131
+ function raceRunPromise(entry: ActiveRunPromise, timeoutMs: number, deadline: number): Promise<RunWaitResult> {
132
+ let timer: ReturnType<typeof setTimeout> | undefined;
133
+ const remaining = Math.max(0, deadline - Date.now());
134
+ const timeoutPromise = new Promise<never>((_, reject) => {
135
+ timer = setTimeout(() => reject(new Error(`waitForRun timed out after ${timeoutMs}ms`)), remaining);
136
+ });
137
+ return Promise.race([entry.promise, timeoutPromise]).finally(() => {
138
+ if (timer) clearTimeout(timer);
139
+ });
140
+ }
141
+
98
142
  /**
99
143
  * Wait for a team run to reach a terminal status.
100
144
  * - If the run is already finished on disk, returns immediately.
@@ -124,17 +168,7 @@ export async function waitForRun(
124
168
 
125
169
  // Medium path: foreground promise registered in this process
126
170
  const entry = activeRunPromises.get(runId);
127
- if (entry) {
128
- let timer: ReturnType<typeof setTimeout> | undefined;
129
- const timeoutPromise = new Promise<never>((_, reject) => {
130
- timer = setTimeout(() => reject(new Error(`waitForRun timed out after ${timeoutMs}ms`)), timeoutMs);
131
- });
132
- try {
133
- return await Promise.race([entry.promise, timeoutPromise]);
134
- } finally {
135
- if (timer) clearTimeout(timer);
136
- }
137
- }
171
+ if (entry) return await raceRunPromise(entry, timeoutMs, deadline);
138
172
 
139
173
  // Slow path: background run — poll with exponential backoff capped at pollIntervalMs.
140
174
  // This path is ALSO taken by a foreground run whose executeTeamRun has not
@@ -147,13 +181,24 @@ export async function waitForRun(
147
181
  const current = loadRunManifestById(cwd, runId);
148
182
  if (current) return { ...current, detached: true };
149
183
  }
184
+ // F1 live-probe fix (2026-09-12): a foreground promise may appear AFTER
185
+ // this waiter started (register/await race — the waiting-push from the
186
+ // broker resolves the ENTRY, which the polling loop would otherwise
187
+ // never observe). Re-check each tick and switch to the promise path with
188
+ // the REMAINING budget; evidence team_20260912053049 (parked 05:31:14,
189
+ // push no-op'd, waiter polled until the watchdog killed the worker).
190
+ const entryNow = activeRunPromises.get(runId);
191
+ if (entryNow) return await raceRunPromise(entryNow, timeoutMs, deadline);
150
192
  if (attempt === 0) {
151
193
  // Early exit: if the run directory doesn't exist, don't waste time polling.
152
- // Use projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
153
- // projects (see issue #29). Without this, the hardcoded `.crew/state/runs/`
154
- // path never resolves in projects that use the `.pi/` layout, the throw
155
- // escapes via subagent-manager.ts:281, and pi crashes with uncaughtException.
156
- const runDir = path.join(projectCrewRoot(cwd), "state", "runs", runId);
194
+ // Resolve through createRunPaths (scopeBaseRoot) so the probe matches where
195
+ // runs are CREATED: project scope — incl. the .pi/teams/ fallback for
196
+ // .pi-based projects (issue #29) — for cwds under a repo root, and USER
197
+ // scope for markerless cwds (issue #54). The previous projectCrewRoot(cwd)
198
+ // join always looked at <cwd>/.crew/state/runs for a markerless cwd, so
199
+ // RUN/WAIT instantly threw "Run not found" while the user-scope crew kept
200
+ // running. createRunPaths is pure path math (no mkdir), so it is safe here.
201
+ const runDir = createRunPaths(cwd, runId).stateRoot;
157
202
  if (!fs.existsSync(runDir)) {
158
203
  throw new Error(`Run ${runId} not found. No run directory at ${runDir}`);
159
204
  }
@@ -162,6 +207,15 @@ export async function waitForRun(
162
207
  if (fresh && isFinishedRunStatus(fresh.manifest.status)) {
163
208
  return fresh;
164
209
  }
210
+ // F1 tombstone: a push that resolved (and evicted) the live entry between
211
+ // ticks lands here — consume it so the leader sees the question, not a
212
+ // 600s block. Terminal-on-disk above still wins (a finished run outranks
213
+ // a stale waiting payload).
214
+ const tombstone = resolvedRunResults.get(runId);
215
+ if (tombstone) {
216
+ resolvedRunResults.delete(runId);
217
+ return tombstone;
218
+ }
165
219
  const delay = Math.min(pollIntervalMs, 50 * 2 ** Math.min(attempt, 6)); // max ~3.2s
166
220
  await new Promise((r) => setTimeout(r, delay));
167
221
  attempt++;
@@ -176,6 +230,7 @@ export function hasActiveRunPromise(runId: string): boolean {
176
230
 
177
231
  export function clearRunPromisesForTest(): void {
178
232
  detachRequests.clear();
233
+ resolvedRunResults.clear();
179
234
  for (const entry of activeRunPromises.values()) {
180
235
  entry.reject(new Error("Cleared by test"));
181
236
  }
@@ -1,14 +1,14 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import type { AgentConfig } from "../agents/agent-config.ts";
5
4
  import type { TeamRole } from "../teams/team-config.ts";
6
5
  import { logInternalError } from "../utils/internal-error.ts";
6
+ import { packageRoot } from "../utils/paths.ts";
7
7
  import { isSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
8
8
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
9
9
  import { CONFIDENCE_THRESHOLDS, getWeightedSkillsForRole, registerSkillEffectivenessHooks } from "./skill-effectiveness.ts";
10
10
 
11
- const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
11
+ const PACKAGE_SKILLS_DIR = path.join(packageRoot(), "skills");
12
12
 
13
13
  import * as os from "node:os";
14
14
  // peer-dep.ts resolves @earendil-works/pi-coding-agent robustly across install
@@ -121,8 +121,24 @@ function collectTaskSkillNames(input: ResolveTaskSkillsInput | undefined): strin
121
121
  if (input.agent?.skills?.length) names.push(...input.agent.skills);
122
122
  if (Array.isArray(input.teamRole?.skills)) names.push(...input.teamRole.skills);
123
123
  if (Array.isArray(input.step?.skills)) names.push(...input.step.skills);
124
- if (Array.isArray(input.override)) names.push(...input.override);
125
- return unique(names);
124
+ // SKILL-HYGIENE-2: support wildcard (`*`) and denylist (`!name`) syntax in override.
125
+ // - `*` is a marker (no-op; defaults already included via defaultSkillsForRole).
126
+ // - `!name` removes `name` from the final selection.
127
+ // - Other names are added (existing additive behavior).
128
+ const denylist = new Set<string>();
129
+ if (Array.isArray(input.override)) {
130
+ for (const item of input.override) {
131
+ if (item === "*") {
132
+ // wildcard marker; defaults already in names via defaultSkillsForRole
133
+ } else if (item.startsWith("!")) {
134
+ denylist.add(item.slice(1));
135
+ } else {
136
+ names.push(item);
137
+ }
138
+ }
139
+ }
140
+ if (denylist.size === 0) return unique(names);
141
+ return unique(names).filter((n) => !denylist.has(n));
126
142
  }
127
143
 
128
144
  export function resolveTaskSkillNames(input: ResolveTaskSkillsInput): string[] {
@@ -107,7 +107,7 @@ async function appendBackgroundLogAsync(bgLogPath: string, eventLine: string): P
107
107
 
108
108
  /**
109
109
  * F7: resolve the enabledModels allowlist for the child-process spawn path,
110
- * but only if `runtime.reliability.scopeModels` is ON. Returns [] (no-op)
110
+ * but only if `reliability.scopeModels` is ON. Returns [] (no-op)
111
111
  * when the toggle is off or the allowlist is empty. Best-effort: any failure
112
112
  * to read config or the allowlist silently disables the gate so spawn is
113
113
  * never blocked by a misconfiguration.
@@ -35,10 +35,11 @@ function readOnlyRoleInstructions(role: string): string {
35
35
  ].join("\n");
36
36
  }
37
37
 
38
- export function coordinationBridgeInstructions(task: TeamTaskState): string {
38
+ export function coordinationBridgeInstructions(task: TeamTaskState, opts?: { includeMailboxTarget?: boolean }): string {
39
+ const includeMailboxTarget = opts?.includeMailboxTarget ?? true;
39
40
  return [
40
41
  "# Crew Coordination Channel",
41
- `Mailbox target for this task: ${task.id}`,
42
+ ...(includeMailboxTarget ? [`Mailbox target for this task: ${task.id}`] : []),
42
43
  "Use the run mailbox contract for coordination with the leader/orchestrator:",
43
44
  "- If blocked or uncertain, report the blocker in your final result and, when mailbox tools/API are available, send an inbox/outbox message addressed to the leader.",
44
45
  "- Never guess implementation details that materially affect decisions. If the `ask` tool is available and you need a clarification, a decision, or a missing requirement before you can proceed safely, call `ask` and wait — a parked question is cheaper than a wrong build.",
@@ -262,7 +263,10 @@ export async function renderTaskPrompt(
262
263
  // computation for parallel siblings in the same batch.
263
264
  const stableComponents = precomputedStableComponents ?? (await computeStablePrefixComponents(manifest, step, task, agent));
264
265
 
265
- // Stable prefix: role instructions, coordination, workspace tree — rarely changes
266
+ // Stable prefix: role instructions, coordination, workspace tree — rarely changes.
267
+ // ARCH-3 (byte-stable worker prefix): per-task values (Task ID, Task cwd, mailbox
268
+ // target) live in dynamicSuffix so siblings sharing a run+role produce a
269
+ // byte-identical prefix and hit provider KV-cache across the batch.
266
270
  const stablePrefix = [
267
271
  "# pi-crew Worker Runtime Context",
268
272
  `Run ID: ${manifest.runId}`,
@@ -271,8 +275,6 @@ export async function renderTaskPrompt(
271
275
  `State root: ${manifest.stateRoot}`,
272
276
  `Artifacts root: ${manifest.artifactsRoot}`,
273
277
  `Events path: ${manifest.eventsPath}`,
274
- `Task ID: ${task.id}`,
275
- `Task cwd: ${task.cwd}`,
276
278
  `Workspace mode: ${manifest.workspaceMode}`,
277
279
  "",
278
280
  "Protocol:",
@@ -280,10 +282,14 @@ export async function renderTaskPrompt(
280
282
  "- Report blockers and verification evidence in the final result.",
281
283
  "- Do not claim completion without evidence.",
282
284
  "- Follow the Task Packet contract below; escalate if any contract field is impossible to satisfy.",
285
+ // PROMPT-2 (port of OMO-slim task-rejection, improved phrasing): a
286
+ // universal lane-guard for every role — complements the per-agent reject
287
+ // sections in agents/*.md with a scaffold-level instruction.
288
+ "- If a task falls outside your role, do not attempt partial work. Return a concise rejection to the leader naming the lane that should own it.",
283
289
  "",
284
290
  readOnlyRoleInstructions(task.role),
285
291
  "",
286
- coordinationBridgeInstructions(task),
292
+ coordinationBridgeInstructions(task, { includeMailboxTarget: false }),
287
293
  "",
288
294
  stableComponents.treeBlock,
289
295
  "",
@@ -291,9 +297,13 @@ export async function renderTaskPrompt(
291
297
  "",
292
298
  toolGuidanceBlock(agent),
293
299
  "",
294
- // O4: project knowledge (.crew/knowledge.md) — workers don't load the
295
- // pi-crew extension (spawned with --no-extensions), so before_agent_start
296
- // never fires for them. Inject here so every worker sees project knowledge.
300
+ // O4 (ARCH-2 corrected): project knowledge (.crew/knowledge.md). Builtin
301
+ // workers don't load the pi-crew extension (agents declare no `extensions:`
302
+ // in frontmatter), so before_agent_start knowledge injection doesn't fire
303
+ // for them — and the knowledge-injection hook now early-returns on
304
+ // PI_CREW_KIND=subagent, so even agents that DO declare the extension
305
+ // can't double-inject. This prompt-builder fragment is the single source
306
+ // of worker project knowledge.
297
307
  stableComponents.knowledgeFragment,
298
308
  ]
299
309
  .filter(Boolean)
@@ -301,6 +311,9 @@ export async function renderTaskPrompt(
301
311
 
302
312
  // Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
303
313
  const dynamicSuffix = [
314
+ `Task ID: ${task.id}`,
315
+ `Task cwd: ${task.cwd}`,
316
+ `Mailbox target: ${task.id}`,
304
317
  `Goal:\n${manifest.goal}`,
305
318
  "",
306
319
  `Step: ${step.id}`,
@@ -270,6 +270,7 @@ export const PiTeamsReliabilityConfigSchema = Type.Object(
270
270
  ambientStatusInjection: Type.Optional(Type.Boolean()),
271
271
  perWriteValidation: Type.Optional(Type.Boolean()),
272
272
  scopeModels: Type.Optional(Type.Boolean()),
273
+ loopGuard: Type.Optional(Type.Boolean()),
273
274
  },
274
275
  { additionalProperties: false },
275
276
  );
@@ -1,15 +1,15 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
  // peer-dep.ts resolves @earendil-works/pi-coding-agent robustly across install
6
5
  // layouts. See src/runtime/peer-dep.ts (split-scope install fix).
7
6
  import { getAgentDir } from "../runtime/peer-dep.ts";
8
7
  import { logInternalError } from "../utils/internal-error.ts";
8
+ import { packageRoot } from "../utils/paths.ts";
9
9
  import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
10
10
  import { parseSkillFrontmatter, type SkillValidationError, validateSkillFrontmatter } from "./validate.ts";
11
11
 
12
- const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
12
+ const PACKAGE_SKILLS_DIR = path.join(packageRoot(), "skills");
13
13
 
14
14
  const CACHE_TTL_MS = 30_000; // 30 seconds
15
15
  let cache: { skills: SkillDescriptor[]; cachedAt: number; cwd: string } | null = null;
@@ -304,6 +304,41 @@ const SETTINGS: SettingDef[] = [
304
304
  tab: "advanced",
305
305
  description: "Remove /tmp/pi-crew-* directories after reconciliation (1h age threshold).",
306
306
  },
307
+ {
308
+ id: "reliability.loopGuard",
309
+ label: "Tool Loop Guard",
310
+ type: "boolean",
311
+ tab: "advanced",
312
+ description: "Warn at 3 / block at 5 identical consecutive read-only tool results (ARCH-1).",
313
+ },
314
+ {
315
+ id: "reliability.perWriteValidation",
316
+ label: "Per-Write Validation",
317
+ type: "boolean",
318
+ tab: "advanced",
319
+ description: "Validate state writes as they are made (default on).",
320
+ },
321
+ {
322
+ id: "reliability.ambientStatusInjection",
323
+ label: "Ambient Status Injection",
324
+ type: "boolean",
325
+ tab: "advanced",
326
+ description: "Inject a compact crew-status note into context on every LLM call while runs are in-flight.",
327
+ },
328
+ {
329
+ id: "reliability.forcePreflight",
330
+ label: "Force Preflight (audit override)",
331
+ type: "boolean",
332
+ tab: "advanced",
333
+ description: "Skip usage-threshold BLOCK/WARN preflight. Default false (enforce). Audit/debug only.",
334
+ },
335
+ {
336
+ id: "reliability.scopeModels",
337
+ label: "Scope Models (F7)",
338
+ type: "boolean",
339
+ tab: "advanced",
340
+ description: "Enforce user enabledModels allowlist on subagent model choices. Default false.",
341
+ },
307
342
  {
308
343
  id: "telemetry.enabled",
309
344
  label: "Telemetry",
@@ -359,6 +394,11 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
359
394
  "reliability.autoRetry": false,
360
395
  "reliability.autoRecover": false,
361
396
  "reliability.cleanupOrphanedTempDirs": true,
397
+ "reliability.loopGuard": true,
398
+ "reliability.perWriteValidation": true,
399
+ "reliability.ambientStatusInjection": true,
400
+ "reliability.forcePreflight": false,
401
+ "reliability.scopeModels": false,
362
402
  "telemetry.enabled": false,
363
403
  "notifications.enabled": false,
364
404
  };
@@ -35,7 +35,13 @@ function parseLines(raw: string): Record<string, string> {
35
35
  const separator = trimmed.indexOf(":");
36
36
  if (separator === -1) continue;
37
37
  const key = trimmed.slice(0, separator).trim();
38
- const value = trimmed.slice(separator + 1).trim();
38
+ let value = trimmed.slice(separator + 1).trim();
39
+ // Strip one pair of symmetric surrounding double quotes so quoted
40
+ // frontmatter values (required by strict YAML when the value contains
41
+ // ": ") behave identically to unquoted ones for every consumer.
42
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
43
+ value = value.slice(1, -1);
44
+ }
39
45
  if (key) frontmatter[key] = value;
40
46
  }
41
47
  return frontmatter;
@@ -17,7 +17,15 @@ export const MAX_BROKER_FRAME_BYTES = 256 * 1024;
17
17
  // BrokerError — typed protocol errors
18
18
  // ============================================================================
19
19
 
20
- export type BrokerErrorCode = "oversize-frame" | "auth" | "protocol" | "timeout" | "close" | "not-implemented" | "rate-limit";
20
+ export type BrokerErrorCode =
21
+ | "oversize-frame"
22
+ | "auth"
23
+ | "protocol"
24
+ | "timeout"
25
+ | "request-timeout"
26
+ | "close"
27
+ | "not-implemented"
28
+ | "rate-limit";
21
29
 
22
30
  export class BrokerError extends Error {
23
31
  readonly code: BrokerErrorCode;