pi-crew 0.5.25 → 0.6.1

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 (81) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +13 -11
  3. package/docs/patterns/command-agent-skill.md +71 -0
  4. package/package.json +1 -1
  5. package/skills/council/SKILL.md +163 -0
  6. package/src/agents/agent-config.ts +4 -1
  7. package/src/agents/discover-agents.ts +1 -0
  8. package/src/benchmark/feedback-loop.ts +4 -2
  9. package/src/extension/cross-extension-rpc.ts +48 -0
  10. package/src/extension/registration/commands.ts +2 -1
  11. package/src/extension/registration/subagent-tools.ts +2 -0
  12. package/src/extension/registration/team-tool.ts +2 -0
  13. package/src/extension/registration/viewers.ts +1 -0
  14. package/src/extension/run-export.ts +16 -1
  15. package/src/extension/run-import.ts +16 -0
  16. package/src/extension/team-tool/anchor.ts +5 -1
  17. package/src/extension/team-tool/api.ts +9 -4
  18. package/src/extension/team-tool/config-patch.ts +15 -1
  19. package/src/extension/team-tool.ts +2 -1
  20. package/src/hooks/registry.ts +9 -1
  21. package/src/hooks/types.ts +14 -0
  22. package/src/i18n.ts +15 -2
  23. package/src/observability/exporters/otlp-exporter.ts +73 -0
  24. package/src/runtime/adaptive-plan.ts +24 -0
  25. package/src/runtime/agent-control.ts +6 -3
  26. package/src/runtime/async-runner.ts +58 -3
  27. package/src/runtime/background-runner.ts +1 -1
  28. package/src/runtime/chain-parser.ts +192 -0
  29. package/src/runtime/chain-runner.ts +58 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crew-agent-records.ts +4 -3
  32. package/src/runtime/cross-extension-rpc.ts +34 -8
  33. package/src/runtime/diagnostic-export.ts +3 -4
  34. package/src/runtime/dynamic-script-runner.ts +7 -7
  35. package/src/runtime/foreground-watchdog.ts +2 -2
  36. package/src/runtime/intercom-bridge.ts +178 -0
  37. package/src/runtime/live-agent-manager.ts +6 -3
  38. package/src/runtime/live-irc.ts +4 -2
  39. package/src/runtime/parallel-utils.ts +2 -1
  40. package/src/runtime/plan-templates.ts +200 -0
  41. package/src/runtime/post-checks.ts +10 -3
  42. package/src/runtime/run-drift.ts +220 -0
  43. package/src/runtime/sandbox.ts +26 -20
  44. package/src/runtime/semaphore.ts +2 -1
  45. package/src/runtime/settings-store.ts +14 -2
  46. package/src/runtime/skill-effectiveness.ts +4 -2
  47. package/src/runtime/skill-instructions.ts +4 -1
  48. package/src/runtime/subagent-manager.ts +20 -2
  49. package/src/runtime/subprocess-tool-registry.ts +2 -2
  50. package/src/runtime/task-graph.ts +79 -0
  51. package/src/runtime/task-id.ts +148 -0
  52. package/src/runtime/task-packet.ts +13 -1
  53. package/src/runtime/task-runner/context-retrieval.ts +172 -0
  54. package/src/runtime/task-runner.ts +39 -1
  55. package/src/runtime/team-runner.ts +7 -0
  56. package/src/runtime/usage-tracker.ts +4 -2
  57. package/src/runtime/verification-gates.ts +36 -9
  58. package/src/state/contracts.ts +2 -1
  59. package/src/state/event-log.ts +16 -5
  60. package/src/state/hook-instinct-bridge.ts +2 -1
  61. package/src/state/locks.ts +9 -2
  62. package/src/state/memory-store.ts +244 -0
  63. package/src/state/observation-store.ts +177 -0
  64. package/src/state/state-store.ts +4 -2
  65. package/src/state/task-claims.ts +9 -2
  66. package/src/tools/safe-bash.ts +69 -20
  67. package/src/types/new-api-types.ts +10 -5
  68. package/src/ui/keybinding-map.ts +2 -1
  69. package/src/ui/run-action-dispatcher.ts +2 -1
  70. package/src/ui/status-colors.ts +2 -1
  71. package/src/ui/syntax-highlight.ts +2 -1
  72. package/src/ui/tool-render.ts +13 -3
  73. package/src/utils/fingerprint.ts +183 -0
  74. package/src/utils/fs-watch.ts +4 -2
  75. package/src/utils/gh-protocol.ts +2 -1
  76. package/src/utils/safe-paths.ts +6 -0
  77. package/src/workflows/discover-workflows.ts +5 -1
  78. package/src/workflows/intermediate-store.ts +173 -0
  79. package/src/workflows/workflow-config.ts +8 -0
  80. package/src/worktree/cleanup.ts +8 -5
  81. package/src/worktree/worktree-manager.ts +1 -1
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Iterative retrieval loop — workers progressively discover needed context.
3
+ *
4
+ * Pattern origin: ECC/skills/iterative-retrieval/SKILL.md — 4-phase loop:
5
+ * Dispatch → Evaluate → Refine → Loop. Max 3 cycles. Convergence when
6
+ * ≥3 high-relevance files found AND no critical gaps.
7
+ *
8
+ * This module provides the scoring and convergence logic.
9
+ * The actual file discovery is delegated to the caller (prompt-builder or task-runner).
10
+ */
11
+
12
+ // ── Types ────────────────────────────────────────────────────────────────
13
+
14
+ export interface RetrievalQuery {
15
+ patterns: string[];
16
+ keywords: string[];
17
+ excludes: string[];
18
+ focusAreas?: string[];
19
+ }
20
+
21
+ export interface RelevanceEvaluation {
22
+ path: string;
23
+ relevance: number; // 0.0–1.0
24
+ reason: string;
25
+ missingContext: string[];
26
+ }
27
+
28
+ export interface RetrievalResult {
29
+ query: RetrievalQuery;
30
+ evaluations: RelevanceEvaluation[];
31
+ cycle: number;
32
+ converged: boolean;
33
+ }
34
+
35
+ // ── Scoring ──────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Score relevance of a file to a task description.
39
+ *
40
+ * Uses keyword matching as a heuristic. In production, this would be
41
+ * replaced by embedding-based similarity or BM25 scoring.
42
+ *
43
+ * @param filePath - Path to the file
44
+ * @param fileContent - Content of the file (or excerpt)
45
+ * @param keywords - Task-relevant keywords
46
+ * @returns Relevance score 0.0–1.0
47
+ */
48
+ export function scoreRelevance(
49
+ filePath: string,
50
+ fileContent: string,
51
+ keywords: string[],
52
+ ): number {
53
+ if (keywords.length === 0) return 0;
54
+
55
+ const pathLower = filePath.toLowerCase();
56
+ const contentLower = fileContent.toLowerCase();
57
+ let matchCount = 0;
58
+ let weightedScore = 0;
59
+
60
+ for (const keyword of keywords) {
61
+ const kw = keyword.toLowerCase();
62
+ // Path match is worth more (file naming is intentional)
63
+ if (pathLower.includes(kw)) {
64
+ matchCount++;
65
+ weightedScore += 0.3;
66
+ }
67
+ // Content match
68
+ const contentMatches = contentLower.split(kw).length - 1;
69
+ if (contentMatches > 0) {
70
+ matchCount++;
71
+ // Diminishing returns for repeated matches
72
+ weightedScore += Math.min(0.2, 0.05 * Math.log2(contentMatches + 1));
73
+ }
74
+ }
75
+
76
+ // Normalize: if all keywords matched, score is high
77
+ const keywordCoverage = matchCount / keywords.length;
78
+ const rawScore = keywordCoverage * 0.6 + Math.min(weightedScore, 0.4);
79
+
80
+ return Math.min(1.0, Math.max(0.0, rawScore));
81
+ }
82
+
83
+ // ── Convergence ──────────────────────────────────────────────────────────
84
+
85
+ const CONVERGENCE_MIN_HIGH_RELEVANCE = 3;
86
+ const HIGH_RELEVANCE_THRESHOLD = 0.7;
87
+
88
+ /**
89
+ * Check if retrieval has converged — enough high-relevance files found.
90
+ *
91
+ * @param evaluations - Current relevance evaluations
92
+ * @returns true if converged
93
+ */
94
+ export function hasConverged(evaluations: RelevanceEvaluation[]): boolean {
95
+ const highRelevance = evaluations.filter((e) => e.relevance >= HIGH_RELEVANCE_THRESHOLD);
96
+ if (highRelevance.length < CONVERGENCE_MIN_HIGH_RELEVANCE) return false;
97
+
98
+ // Check for critical gaps — any evaluation with empty missingContext
99
+ const criticalGaps = evaluations.some(
100
+ (e) => e.relevance < 0.3 && e.missingContext.length > 0,
101
+ );
102
+
103
+ return !criticalGaps;
104
+ }
105
+
106
+ // ── Refinement ───────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Refine a retrieval query based on evaluation results.
110
+ *
111
+ * Extracts new keywords from high-relevance files, adds discovered
112
+ * terminology, and excludes confirmed-irrelevant paths.
113
+ *
114
+ * @param query - Original query
115
+ * @param evaluations - Results from the current cycle
116
+ * @returns Refined query for the next cycle
117
+ */
118
+ export function refineQuery(
119
+ query: RetrievalQuery,
120
+ evaluations: RelevanceEvaluation[],
121
+ ): RetrievalQuery {
122
+ const newKeywords = new Set(query.keywords);
123
+ const newExcludes = new Set(query.excludes);
124
+ const newFocusAreas = new Set(query.focusAreas ?? []);
125
+
126
+ for (const eval_ of evaluations) {
127
+ if (eval_.relevance >= HIGH_RELEVANCE_THRESHOLD) {
128
+ // Extract potential keywords from the file path
129
+ const parts = eval_.path.replace(/[\\/]/g, "/").split("/");
130
+ for (const part of parts) {
131
+ // Skip common non-informative segments
132
+ if (part.length > 2 && !["src", "lib", "test", "dist", "node_modules"].includes(part)) {
133
+ // Use the filename stem as a keyword hint
134
+ const stem = part.replace(/\.[^.]+$/, "").replace(/[.-]/g, " ");
135
+ for (const word of stem.split(/\s+/)) {
136
+ if (word.length > 3) newKeywords.add(word);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ if (eval_.relevance < 0.2) {
143
+ // Exclude confirmed-irrelevant paths
144
+ newExcludes.add(eval_.path);
145
+ }
146
+
147
+ // Track missing context as focus areas
148
+ for (const gap of eval_.missingContext) {
149
+ newFocusAreas.add(gap);
150
+ }
151
+ }
152
+
153
+ return {
154
+ patterns: query.patterns, // patterns don't change
155
+ keywords: [...newKeywords],
156
+ excludes: [...newExcludes],
157
+ focusAreas: newFocusAreas.size > 0 ? [...newFocusAreas] : undefined,
158
+ };
159
+ }
160
+
161
+ // ── Loop Control ─────────────────────────────────────────────────────────
162
+
163
+ const MAX_CYCLES = 3;
164
+
165
+ /**
166
+ * Determine if another retrieval cycle should run.
167
+ */
168
+ export function shouldContinue(evaluations: RelevanceEvaluation[], cycle: number): boolean {
169
+ if (cycle >= MAX_CYCLES) return false;
170
+ if (hasConverged(evaluations)) return false;
171
+ return true;
172
+ }
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentConfig } from "../agents/agent-config.ts";
3
4
  import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
4
5
  import type {
@@ -267,6 +268,30 @@ export async function runTeamTask(
267
268
  const skillNames = input.skillNames ?? renderedSkills?.names;
268
269
  const skillPaths = input.skillPaths ?? renderedSkills?.paths;
269
270
 
271
+ // Deterministic pre-step: run script, inject stdout into worker prompt
272
+ let preStepOutput: string | undefined;
273
+ if (input.step.preStepScript) {
274
+ const scriptTimeout = input.step.preStepTimeout ?? 30_000;
275
+ const scriptArgs = input.step.preStepArgs ?? [];
276
+ // SECURITY: Validate preStepScript path is contained within cwd
277
+ const resolved = path.resolve(manifest.cwd, input.step.preStepScript);
278
+ if (!resolved.startsWith(path.resolve(manifest.cwd) + path.sep) && resolved !== path.resolve(manifest.cwd)) {
279
+ throw new Error(`Security: preStepScript path escapes working directory: ${input.step.preStepScript}`);
280
+ }
281
+ try {
282
+ const { execFileSync } = await import("node:child_process");
283
+ preStepOutput = execFileSync(input.step.preStepScript, scriptArgs, {
284
+ timeout: scriptTimeout,
285
+ encoding: "utf-8",
286
+ cwd: manifest.cwd,
287
+ maxBuffer: 1024 * 1024, // 1MB cap
288
+ });
289
+ } catch (err) {
290
+ const msg = err instanceof Error ? err.message : String(err);
291
+ throw new Error(`preStepScript failed: ${input.step.preStepScript}: ${msg}`);
292
+ }
293
+ }
294
+
270
295
  const promptResult = await renderTaskPrompt(
271
296
  manifest,
272
297
  input.step,
@@ -274,7 +299,12 @@ export async function runTeamTask(
274
299
  input.agent,
275
300
  skillBlock,
276
301
  );
277
- const prompt = promptResult.full;
302
+ let prompt = promptResult.full;
303
+
304
+ // Inject deterministic pre-step output into prompt
305
+ if (preStepOutput) {
306
+ prompt += "\n\n---\n## Pre-Step Script Output\n\nThe following data was produced by a pre-step script. Use it as context for your task:\n\n<output>\n" + preStepOutput + "\n</output>\n";
307
+ }
278
308
  const promptArtifact = writeArtifact(manifest.artifactsRoot, {
279
309
  kind: "prompt",
280
310
  relativePath: `prompts/${task.id}.md`,
@@ -502,6 +532,9 @@ export async function runTeamTask(
502
532
  collectedJsonEvents.push(
503
533
  event as Record<string, unknown>,
504
534
  );
535
+ if (collectedJsonEvents.length > 1000) {
536
+ collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
537
+ }
505
538
  // Accumulate lifetime usage via message_end events (survives compaction)
506
539
  if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
507
540
  const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
@@ -1211,6 +1244,11 @@ export async function runTeamTask(
1211
1244
  taskId: task.id,
1212
1245
  message: error,
1213
1246
  });
1247
+
1248
+ // Execute after_task_complete lifecycle hook (non-blocking)
1249
+ const afterTaskReport = await executeHook("after_task_complete", { runId: manifest.runId, taskId: task.id, cwd: manifest.cwd, status: error ? "failed" : noYield ? "needs_attention" : "completed" });
1250
+ appendHookEvent(manifest, afterTaskReport);
1251
+
1214
1252
  return { manifest, tasks };
1215
1253
  } finally {
1216
1254
  streamBridge?.dispose();
@@ -324,6 +324,13 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
324
324
  // Emit run completion hook (100% reliable, fire-and-forget)
325
325
  crewHooks.emit({ type: "run_completed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: result.manifest.status, taskCount: result.tasks.length } });
326
326
 
327
+ // Execute after_run_complete lifecycle hook (non-blocking)
328
+ const afterRunReport = await executeHook("after_run_complete", { runId: manifest.runId, cwd: manifest.cwd, status: result.manifest.status });
329
+ appendHookEvent(manifest, afterRunReport);
330
+ if (afterRunReport.outcome === "block") {
331
+ logInternalError("team-runner.after_run_complete.blocked", new Error(afterRunReport.reason ?? "after_run_complete hook blocked"), `runId=${manifest.runId}`);
332
+ }
333
+
327
334
  return result;
328
335
  } catch (error) {
329
336
  // P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
@@ -16,7 +16,8 @@ export function addUsage(into: LifetimeUsage, delta: { input?: number; output?:
16
16
  if (typeof delta.cacheWrite === "number") into.cacheWrite += delta.cacheWrite;
17
17
  }
18
18
 
19
- export function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
19
+ /** @internal */
20
+ function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
20
21
  if (!state) return emptyLifetimeUsage();
21
22
  return {
22
23
  input: state.input ?? 0,
@@ -59,7 +60,8 @@ export const getTaskUsage = getTrackedTaskUsage;
59
60
  export const getRunUsage = getTrackedTaskUsage;
60
61
  export const clearAllTaskUsage = clearAllTrackedTaskUsage;
61
62
 
62
- export function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
63
+ /** @internal */
64
+ function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
63
65
  const total = emptyLifetimeUsage();
64
66
  for (const task of tasks) {
65
67
  const tracked = getTrackedTaskUsage(task.id);
@@ -38,35 +38,61 @@ export interface PhaseGateBundle {
38
38
  * Sequential enforcement: each phase must pass before proceeding.
39
39
  */
40
40
  export const NPM_TYPESCRIPT_GATES: Array<{ name: string; command: string; critical: boolean }> = [
41
- { name: "build", command: "npm run build 2>&1 || true", critical: true },
42
- { name: "typecheck", command: "npx tsc --noEmit 2>&1 || true", critical: true },
43
- { name: "lint", command: "npm run lint 2>&1 || true", critical: false },
44
- { name: "tests", command: "npm test 2>&1 || true", critical: true },
41
+ { name: "build", command: "npm run build 2>&1", critical: true },
42
+ { name: "typecheck", command: "npx tsc --noEmit 2>&1", critical: true },
43
+ { name: "lint", command: "npm run lint 2>&1", critical: false },
44
+ { name: "tests", command: "npm test 2>&1", critical: true },
45
45
  ];
46
46
 
47
47
  /**
48
48
  * Cargo/Rust project phase gates.
49
49
  */
50
50
  export const CARGO_RUST_GATES: Array<{ name: string; command: string; critical: boolean }> = [
51
- { name: "check", command: "cargo check 2>&1 || true", critical: true },
52
- { name: "test", command: "cargo test 2>&1 || true", critical: true },
53
- { name: "clippy", command: "cargo clippy 2>&1 || true", critical: false },
51
+ { name: "check", command: "cargo check 2>&1", critical: true },
52
+ { name: "test", command: "cargo test 2>&1", critical: true },
53
+ { name: "clippy", command: "cargo clippy 2>&1", critical: false },
54
54
  ];
55
55
 
56
56
  /**
57
57
  * Execute a single command and capture output.
58
58
  */
59
+ /** Characters/patterns that indicate dangerous shell metacharacters. */
60
+ const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\b(eval|exec)\b|>>|<[^&])/;
61
+ // Note: single `>` is NOT blocked here because `2>&1` is a safe redirect used by built-in gates.
62
+ // `>>` (append) is still blocked. `<` without `&` (input redirect) is still blocked.
63
+
64
+ /**
65
+ * Validate a verification gate command is safe to execute.
66
+ * Rejects commands with shell metacharacters that could enable injection.
67
+ * Allows: pipes (|), redirection of stderr (2>&1), and basic npm/cargo/npx commands.
68
+ */
69
+ function validateGateCommand(command: string): void {
70
+ const normalized = command
71
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // ANSI escape sequences
72
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // control chars
73
+ .replace(/\\\n/g, ' ') // escaped newlines
74
+ .replace(/\s+/g, ' ') // collapse whitespace
75
+ .trim();
76
+ if (DANGEROUS_SHELL_PATTERNS.test(normalized)) {
77
+ throw new Error(
78
+ `Security: verification gate command rejected (dangerous shell pattern): ${command}`,
79
+ );
80
+ }
81
+ }
82
+
59
83
  async function executeCommand(
60
84
  command: string,
61
85
  cwd: string,
62
86
  timeoutMs: number = 120000,
63
87
  ): Promise<{ exitCode: number | null; output: string; durationMs: number }> {
88
+ // SECURITY: Validate command before shell execution to prevent injection.
89
+ validateGateCommand(command);
90
+
64
91
  const start = Date.now();
65
92
  let output = "";
66
93
  let exitCode: number | null = null;
67
94
 
68
95
  return new Promise((resolve) => {
69
- // Use shell to handle compound commands
70
96
  const shell = spawn("sh", ["-c", command], {
71
97
  cwd,
72
98
  timeout: timeoutMs,
@@ -313,7 +339,8 @@ export function computeGreenLevelFromResults(
313
339
  * Create a verification gate report artifact.
314
340
  * Formatted for human review per ECC verification-loop pattern.
315
341
  */
316
- export function createVerificationGateReport(
342
+ /** @internal */
343
+ function createVerificationGateReport(
317
344
  taskId: string,
318
345
  contract: VerificationContract,
319
346
  results: VerificationCommandResult[],
@@ -28,7 +28,8 @@ export const TEAM_TASK_STATUS_TRANSITIONS: Readonly<Record<TeamTaskStatus, reado
28
28
  needs_attention: ["queued", "running"],
29
29
  };
30
30
 
31
- export const TEAM_EVENT_TYPES = [
31
+ /** @internal */
32
+ const TEAM_EVENT_TYPES = [
32
33
  "run.created",
33
34
  "run.queued",
34
35
  "run.planning",
@@ -80,7 +80,11 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
80
80
  const lockDir = `${eventsPath}.lock`;
81
81
  const pidFile = path.join(lockDir, "pid");
82
82
  const start = Date.now();
83
- const timeout = 120000; // 120s timeout for slow CI environments
83
+ // SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
84
+ // event loop indefinitely. 500 retries × 10ms = 5s max. After timeout, we
85
+ // throw a clear error instead of blocking forever. This ensures AbortSignal
86
+ // handlers, SIGTERM, and graceful shutdown can fire within seconds.
87
+ const timeout = 5000;
84
88
  const staleMs = 10000;
85
89
  let acquired = false;
86
90
  while (true) {
@@ -91,10 +95,12 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
91
95
  break;
92
96
  } catch {
93
97
  if (Date.now() - start > timeout) {
94
- // Log error and continue without lock lock is held by live process.
95
- // Stale detection will clean up dead locks on next attempt.
96
- logInternalError("event-log.lock-timeout", new Error(`Event log lock timeout for ${eventsPath}`), `lockDir=${lockDir}`);
97
- break;
98
+ // SECURITY (HIGH #2 fix): Throw instead of continuing without lock.
99
+ // Previously this logged and broke out of the loop, executing the
100
+ // operation without lock protection. Now we throw so callers can retry.
101
+ throw new Error(
102
+ `Event log lock timeout for ${eventsPath}: could not acquire lock within ${timeout}ms`,
103
+ );
98
104
  }
99
105
  // Stale detection: if the owning process is dead, remove the stale lock.
100
106
  try {
@@ -217,6 +223,11 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
217
223
  // --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
218
224
  const asyncQueues = new Map<string, Promise<unknown>>();
219
225
 
226
+ /** Reset event log mode (for testing only). */
227
+ export function resetEventLogMode(): void {
228
+ asyncQueues.clear();
229
+ }
230
+
220
231
  /**
221
232
  * Append an event to the event log using non-blocking async I/O.
222
233
  *
@@ -80,7 +80,8 @@ crewHooks.register("run_completed", async (event) => {
80
80
  /**
81
81
  * Get instinct-based recommendations.
82
82
  */
83
- export async function getInstinctRecommendations() {
83
+ /** @internal */
84
+ async function getInstinctRecommendations() {
84
85
  try {
85
86
  const store = await getStore();
86
87
  return store.getInstincts().filter((i: { confidence: number }) => i.confidence >= 0.6);
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { randomUUID } from "node:crypto";
3
+ import { randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import type { TeamRunManifest } from "./types.ts";
5
5
  import { DEFAULT_LOCKS } from "../config/defaults.ts";
6
6
  import { sleepSync } from "../utils/sleep.ts";
@@ -103,9 +103,16 @@ function readLockToken(filePath: string): string | undefined {
103
103
  *
104
104
  * With token matching, A's release is a no-op for B's lock.
105
105
  */
106
+ function timingSafeTokenMatch(a: string, b: string): boolean {
107
+ const bufA = Buffer.from(String(a));
108
+ const bufB = Buffer.from(String(b));
109
+ if (bufA.length !== bufB.length) return false;
110
+ return timingSafeEqual(bufA, bufB);
111
+ }
112
+
106
113
  function releaseLock(filePath: string, token: string): void {
107
114
  const stored = readLockToken(filePath);
108
- if (stored === undefined || stored === token) {
115
+ if (stored === undefined || timingSafeTokenMatch(stored, token)) {
109
116
  try {
110
117
  fs.rmSync(filePath, { force: true });
111
118
  } catch {