pi-crew 0.9.26 → 0.9.28

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 (64) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/NOTICE.md +14 -0
  3. package/assets/crew-vibes.ttf +0 -0
  4. package/assets/runner-spritesheet.png +0 -0
  5. package/dist/build-meta.json +245 -48
  6. package/dist/index.mjs +1611 -243
  7. package/dist/index.mjs.map +4 -4
  8. package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
  9. package/package.json +9 -2
  10. package/scripts/bench-check.mjs +96 -0
  11. package/scripts/bench-cold-start.mjs +216 -0
  12. package/scripts/build-bundle.mjs +84 -0
  13. package/scripts/build-crew-vibes-font.py +328 -0
  14. package/scripts/check-all-skills.ts +294 -0
  15. package/scripts/check-bundle-staleness.mjs +97 -0
  16. package/scripts/check-conflict-markers.mjs +91 -0
  17. package/scripts/check-lazy-imports.mjs +28 -0
  18. package/scripts/install-crew-vibes-font.mjs +91 -0
  19. package/scripts/postinstall.mjs +47 -0
  20. package/scripts/profile-startup.mjs +125 -0
  21. package/scripts/release-smoke.mjs +74 -0
  22. package/scripts/run-bench.mjs +47 -0
  23. package/scripts/run-real-chain.ts +41 -0
  24. package/scripts/test-issue-29-crash.ts +111 -0
  25. package/scripts/test-issue-29-e2e.ts +183 -0
  26. package/scripts/test-issue-29-real-runtime.ts +330 -0
  27. package/scripts/test-issue-29-real-tasks.ts +387 -0
  28. package/scripts/test-issue-29-team-tool.ts +105 -0
  29. package/scripts/test-runner.mjs +74 -0
  30. package/scripts/verify-flicker-fix.ts +109 -0
  31. package/scripts/verify-skill.ts +550 -0
  32. package/scripts/watch-bundle.mjs +259 -0
  33. package/scripts/watchdog-harness.ts +119 -0
  34. package/src/agents/agent-config.ts +2 -0
  35. package/src/agents/discover-agents.ts +10 -1
  36. package/src/config/defaults.ts +6 -0
  37. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  38. package/src/extension/crew-vibes/config.ts +195 -0
  39. package/src/extension/crew-vibes/figures.ts +94 -0
  40. package/src/extension/crew-vibes/font-detect.ts +58 -0
  41. package/src/extension/crew-vibes/index.ts +385 -0
  42. package/src/extension/crew-vibes/provider-usage.ts +396 -0
  43. package/src/extension/crew-vibes/render.ts +206 -0
  44. package/src/extension/crew-vibes/speed.ts +286 -0
  45. package/src/extension/knowledge-injection.ts +12 -3
  46. package/src/extension/management.ts +23 -3
  47. package/src/extension/register.ts +7 -0
  48. package/src/extension/team-tool.ts +11 -0
  49. package/src/prompt/prompt-runtime.ts +65 -0
  50. package/src/runtime/child-pi.ts +123 -10
  51. package/src/runtime/crew-agent-records.ts +10 -3
  52. package/src/runtime/pi-args.ts +2 -0
  53. package/src/runtime/retry-executor.ts +4 -1
  54. package/src/runtime/task-runner/state-helpers.ts +9 -30
  55. package/src/runtime/task-runner.ts +1 -0
  56. package/src/runtime/team-runner.ts +5 -3
  57. package/src/state/atomic-write.ts +153 -49
  58. package/src/state/event-log.ts +16 -10
  59. package/src/state/locks.ts +7 -8
  60. package/src/state/mailbox.ts +15 -4
  61. package/src/state/state-store.ts +39 -10
  62. package/src/teams/discover-teams.ts +56 -1
  63. package/src/utils/safe-paths.ts +2 -1
  64. package/src/workflows/discover-workflows.ts +72 -1
@@ -31,6 +31,18 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
31
31
  const activeChildProcesses = new Map<number, ChildProcess>();
32
32
  const childHardKillTimers = new Map<number, NodeJS.Timeout>();
33
33
 
34
+ // Periodic cleanup of dead child process entries to prevent memory leaks.
35
+ // If a child process never emits exit/close (zombie), the entry would leak.
36
+ setInterval(() => {
37
+ for (const [pid, child] of activeChildProcesses) {
38
+ try {
39
+ process.kill(pid, 0); // Throws ESRCH if dead
40
+ } catch {
41
+ activeChildProcesses.delete(pid);
42
+ }
43
+ }
44
+ }, 60_000).unref();
45
+
34
46
  /**
35
47
  * SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
36
48
  * events and error messages. The in-memory stdout/stderr accumulators receive
@@ -176,6 +188,10 @@ export interface ChildPiLifecycleEvent {
176
188
  stderrExcerpt?: string;
177
189
  /** Timestamp (ISO). */
178
190
  ts: string;
191
+ /** F12: optional cause for `final_drain` events. `"stdout-quiet"` indicates
192
+ * the drain was triggered by the quiet-window early-exit rather than the
193
+ * default 5 s ceiling. Other drain reasons (default) leave this undefined. */
194
+ reason?: "stdout-quiet";
179
195
  /** Phase-0 diagnostic (HB-003a): the signal that killed the child (when
180
196
  * available). Was previously discarded after building the error string. */
181
197
  signal?: string;
@@ -205,6 +221,9 @@ export interface ChildPiRunInput {
205
221
  onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
206
222
  maxDepth?: number;
207
223
  finalDrainMs?: number;
224
+ /** F12: early-exit the drain when stdout has been silent for this many ms
225
+ * after the final assistant event. Set to ≥ finalDrainMs to disable. */
226
+ finalDrainQuietMs?: number;
208
227
  hardKillMs?: number;
209
228
  responseTimeoutMs?: number;
210
229
  /** Soft limit on assistant turns — inject steer at this count. */
@@ -219,6 +238,8 @@ export interface ChildPiRunInput {
219
238
  excludeContextBash?: boolean;
220
239
  /** pi session ID for session naming (aligns with pi-crew run ID) */
221
240
  sessionId?: string;
241
+ /** Path to steering JSONL file for real-time steer injection. */
242
+ steeringFile?: string;
222
243
  /** Run ID for cleanup tracking */
223
244
  runId?: string;
224
245
  /** Agent ID for cleanup tracking */
@@ -298,6 +319,8 @@ const BASE_ALLOWLIST: string[] = [
298
319
  "PI_TEAMS_PI_BIN",
299
320
  "PI_TEAMS_MOCK_CHILD_PI",
300
321
  "PI_CREW_ALLOW_MOCK",
322
+ "PI_CREW_MAX_OUTPUT",
323
+ "PI_CREW_STEERING_FILE",
301
324
  ];
302
325
 
303
326
  export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
@@ -569,17 +592,22 @@ function compactChildPiLine(line: string): {
569
592
  export class ChildPiLineObserver {
570
593
  private buffer = "";
571
594
  private readonly input: ChildPiRunInput;
572
- /** RAW (uncapped) assistant-text fragments, accumulated in arrival order.
573
- * Mirrors {@link parsePiJsonOutput}'s textEvents/finalText extraction but
574
- * operates on the RAW event stream instead of the 16K-compacted transcript.
575
- * This is the source of the AUTHORITATIVE result.txt; the transcript stays
576
- * compacted (memory bound). */
595
+ /** F9: bounded ring buffer for RAW assistant-text fragments. Consumers
596
+ * (getRawFinalText) only read the last element, but the legacy implementation
597
+ * accumulated every fragment unconditionally, which let a verbose/long-running
598
+ * worker grow this array linearly with output. We retain the last 2 entries:
599
+ * the consumer needs the last; we keep the second-to-last only as a defensive
600
+ * fence against a race where a final event arrives just after the consumer
601
+ * read (the previous "last" is still the most-recent pre-final text in that
602
+ * window). 2 is well below any plausible consumer's "tail-only" need while
603
+ * bounding memory. */
604
+ private static readonly MAX_RAW_TEXT_EVENTS = 2;
577
605
  private readonly rawTextEvents: string[] = [];
578
- /** #7 hardening: bounded digest of intermediate findings. When a worker spends
579
- * its entire budget on tool calls (never emits a final assistant text),
580
- * getRawFinalText() returns undefined but this digest captures the last
581
- * display lines (tool results, stdout fragments) before budget exhaustion.
582
- * Capped at MAX_INTERMEDIATE_DIGEST_LINES so result artifacts stay bounded. */
606
+ /** F9: bounded ring buffer for intermediate findings. The downstream digest
607
+ * (getIntermediateFindings) slices the last 20, but the array previously grew
608
+ * to 1000s of entries. We keep MAX_INTERMEDIATE_DIGEST_LINES + headroom so
609
+ * the public API behaviour is preserved (still returns "last 20 lines"). */
610
+ private static readonly MAX_INTERMEDIATE_FINDINGS = 32;
583
611
  private readonly intermediateFindings: string[] = [];
584
612
 
585
613
  constructor(input: ChildPiRunInput) {
@@ -633,12 +661,19 @@ export class ChildPiLineObserver {
633
661
  const rawParsed = JSON.parse(line);
634
662
  const rawTexts = extractText(rawParsed);
635
663
  if (rawTexts.length > 0) {
664
+ // F9: trim from the front if the push would exceed the cap. Slice's
665
+ // second arg excludes the index, so this drops the oldest entries
666
+ // while keeping the freshly pushed tail.
636
667
  this.rawTextEvents.push(...rawTexts);
668
+ const rawOverflow = this.rawTextEvents.length - ChildPiLineObserver.MAX_RAW_TEXT_EVENTS;
669
+ if (rawOverflow > 0) this.rawTextEvents.splice(0, rawOverflow);
637
670
  // Also capture raw assistant text as intermediate findings — the last raw
638
671
  // text may be a partial answer before the worker ran out of budget.
639
672
  const last = rawTexts[rawTexts.length - 1];
640
673
  if (last.trim().length > 0) {
641
674
  this.intermediateFindings.push(last.trim());
675
+ const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
676
+ if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
642
677
  }
643
678
  }
644
679
  } catch {
@@ -663,6 +698,8 @@ export class ChildPiLineObserver {
663
698
  // findings. This ensures we capture tool output even when no assistant text
664
699
  // is emitted (budget exhausted on tool calls).
665
700
  this.intermediateFindings.push(compact.displayLine!.trim());
701
+ const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
702
+ if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
666
703
  }
667
704
  }
668
705
  }
@@ -855,6 +892,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
855
892
  skillPaths: input.skillPaths,
856
893
  role: input.role,
857
894
  });
895
+ // Pass steering file path to child for real-time steer injection
896
+ if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
858
897
  const spawnSpec = getPiSpawnCommand(built.args);
859
898
  try {
860
899
  return await new Promise<ChildPiRunResult>((resolve) => {
@@ -921,6 +960,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
921
960
  // handler know a drain timer existed even after clearFinalDrainTimers() ran;
922
961
  // spawnMonotonicMs gives us relative timing to distinguish a race from a crash.
923
962
  let finalDrainArmed = false;
963
+ // F12: monotonic timestamp of the last stdout JSON event (any event —
964
+ // we want to know when stdout *stopped*, not when the final assistant
965
+ // event arrived). Updated on every onJsonEvent dispatch.
966
+ let lastStdoutActivityMonotonicMs = performance.now();
924
967
  let finalDrainFiredMonotonicMs: number | undefined;
925
968
  const spawnMonotonicMs = performance.now();
926
969
  let finalAssistantEventMonotonicMs: number | undefined;
@@ -1141,10 +1184,80 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1141
1184
  completeOperation(eventOpId);
1142
1185
  throw err;
1143
1186
  }
1187
+ // F12: capture monotonic timestamp BEFORE dispatching — any stdout
1188
+ // JSON event counts as activity. This lets the quiet-window
1189
+ // detection measure "time since last byte of stdout" accurately
1190
+ // regardless of what onJsonEvent does.
1191
+ lastStdoutActivityMonotonicMs = performance.now();
1144
1192
  input.onJsonEvent?.(event);
1145
1193
  if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
1146
1194
  finalAssistantEventMonotonicMs = performance.now();
1147
1195
  finalDrainArmed = true; // Phase-0 diagnostic: track that a drain timer was created.
1196
+ // F12: alongside the 5 s ceiling timer, start a polling watcher
1197
+ // that fires the drain early if stdout goes quiet for `quietMs`
1198
+ // after the final assistant event. Heavy children that emit a
1199
+ // stopReason=stop message_end and then sit idle will exit in
1200
+ // ~quietMs (default 800 ms) instead of up to up to 5 s. unref() so
1201
+ // the poller never holds the event loop on shutdown.
1202
+ // NOTE: The polling watcher is NOT explicitly cleared on process exit.
1203
+ // This is safe because: (1) it's unref()'d, so it won't prevent exit;
1204
+ // (2) the `settled || childExited` guard at the top prevents firing
1205
+ // after the child has exited; (3) sending SIGTERM to an already-
1206
+ // exiting process is harmless. The `finalDrainQuietMs` config allows
1207
+ // disabling this behavior (set >= finalDrainMs, e.g., 10000).
1208
+ const quietMs = input.finalDrainQuietMs ?? DEFAULT_CHILD_PI.finalDrainQuietMs;
1209
+ if (quietMs < (input.finalDrainMs ?? DEFAULT_CHILD_PI.finalDrainMs)) {
1210
+ const pollHandle = setInterval(() => {
1211
+ if (settled || childExited) {
1212
+ clearInterval(pollHandle);
1213
+ pollHandle.unref();
1214
+ return;
1215
+ }
1216
+ const sinceLast = performance.now() - lastStdoutActivityMonotonicMs;
1217
+ if (sinceLast >= quietMs) {
1218
+ clearInterval(pollHandle);
1219
+ pollHandle.unref();
1220
+ // Trigger the same drain path as the 5 s timer:
1221
+ // mark forced, fire final_drain lifecycle, SIGTERM.
1222
+ forcedFinalDrain = true;
1223
+ finalDrainFiredMonotonicMs = performance.now();
1224
+ input.onLifecycleEvent?.({
1225
+ type: "final_drain",
1226
+ pid: child.pid,
1227
+ ts: new Date().toISOString(),
1228
+ reason: "stdout-quiet",
1229
+ });
1230
+ try {
1231
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
1232
+ } catch (error) {
1233
+ logInternalError("child-pi.quiet-drain-term", error, `pid=${child.pid}`);
1234
+ }
1235
+ // Mark for hard kill fallback so the existing timer is
1236
+ // still reaped if it ever fires later.
1237
+ hardKillTimer = setTimeout(() => {
1238
+ if (settled || childExited) return;
1239
+ try {
1240
+ hardKilled = true;
1241
+ input.onLifecycleEvent?.({
1242
+ type: "hard_kill",
1243
+ pid: child.pid,
1244
+ ts: new Date().toISOString(),
1245
+ });
1246
+ child.kill(process.platform === "win32" ? undefined : "SIGKILL");
1247
+ } catch (error) {
1248
+ logInternalError("child-pi.quiet-drain-hard-kill", error, `pid=${child.pid}`);
1249
+ }
1250
+ }, hardKillMs);
1251
+ hardKillTimer.unref();
1252
+ // Cancel the 5 s ceiling so we don't double-fire.
1253
+ if (finalDrainTimer) {
1254
+ clearTimeout(finalDrainTimer);
1255
+ finalDrainTimer = undefined;
1256
+ }
1257
+ }
1258
+ }, 200);
1259
+ pollHandle.unref();
1260
+ }
1148
1261
  finalDrainTimer = setTimeout(() => {
1149
1262
  if (settled || childExited) return;
1150
1263
  forcedFinalDrain = true;
@@ -338,7 +338,9 @@ export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentReco
338
338
 
339
339
  export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
340
340
  ensureAgentStateDir(manifest, record.taskId);
341
- atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record));
341
+ // F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
342
+ // durability so the notifier/dashboard health sees the final state immediately.
343
+ atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
342
344
  }
343
345
 
344
346
  // 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
@@ -350,14 +352,19 @@ const AGENT_COALESCE_MS = 250;
350
352
  export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
351
353
  const filePath = agentsPath(manifest);
352
354
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
353
- atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS);
355
+ // F4: progress write — best-effort is safe because the terminal
356
+ // writeCrewAgentStatus above remains durable and the notifier watches the
357
+ // events.jsonl independently of these JSON files.
358
+ atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
354
359
  asyncAgentReaderCache.delete(filePath);
355
360
  for (const record of records) writeCrewAgentStatusCoalesced(manifest, record);
356
361
  }
357
362
 
358
363
  export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
359
364
  ensureAgentStateDir(manifest, record.taskId);
360
- atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS);
365
+ atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS, {
366
+ durability: "best-effort",
367
+ });
361
368
  }
362
369
 
363
370
  /** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
@@ -360,6 +360,8 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
360
360
  PI_TEAMS_DEPTH: String(parentDepth + 1),
361
361
  PI_TEAMS_MAX_DEPTH: String(maxDepth),
362
362
  PI_TEAMS_ROLE: input.agent.name,
363
+ // maxTokens cap for background workers — prompt-runtime reads this to cap API output
364
+ ...(input.agent.maxTokens ? { PI_CREW_MAX_OUTPUT: String(input.agent.maxTokens) } : {}),
363
365
  },
364
366
  tempDir,
365
367
  };
@@ -33,7 +33,10 @@ function asError(error: unknown): Error {
33
33
  }
34
34
 
35
35
  function globToRegex(pattern: string): RegExp {
36
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
36
+ const escaped = pattern
37
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
38
+ .replace(/\*/g, ".*")
39
+ .replace(/\?/g, ".");
37
40
  return new RegExp(`^${escaped}$`, "i");
38
41
  }
39
42
 
@@ -68,12 +68,19 @@ export function persistSingleTaskUpdate(
68
68
  const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
69
69
  merged = updateTask(latest, taskWithCheckpoint);
70
70
 
71
- // Re-stat to detect concurrent writes
71
+ // F2: collapsed from 3 redundant statSync calls into 1. The previous
72
+ // implementation re-checked mtime twice more after load and before
73
+ // write, but since the code is synchronous and `loadRunManifestById`
74
+ // holds no I/O-yield between the load and this stat, those re-checks
75
+ // always returned the same mtime and added nothing. The one CAS below
76
+ // remains necessary for best-effort writers (async-notifier,
77
+ // crash-recovery) that don't acquire the run lock.
72
78
  let currentMtime: number;
73
79
  try {
74
80
  currentMtime = fs.statSync(manifest.tasksPath).mtimeMs;
75
81
  } catch {
76
- currentMtime = 0;
82
+ // Run state deleted (prune/forget) — nothing to persist.
83
+ return fallbackTasks;
77
84
  }
78
85
 
79
86
  if (currentMtime !== baseMtime) {
@@ -82,34 +89,6 @@ export function persistSingleTaskUpdate(
82
89
  continue;
83
90
  }
84
91
 
85
- // No concurrent writer — check that our merged result is based on the
86
- // same base we observed (no intermediate writer between our load and check)
87
- let recheckMtime: number;
88
- try {
89
- recheckMtime = fs.statSync(manifest.tasksPath).mtimeMs;
90
- } catch {
91
- // Run state deleted (prune/forget) — nothing to persist.
92
- return fallbackTasks;
93
- }
94
- if (recheckMtime !== baseMtime) {
95
- baseMtime = recheckMtime;
96
- continue;
97
- }
98
-
99
- // Final pre-write mtime check to catch any concurrent writer that completed
100
- // between the recheck and saveRunTasks
101
- let preWriteMtime: number;
102
- try {
103
- preWriteMtime = fs.statSync(manifest.tasksPath).mtimeMs;
104
- } catch {
105
- preWriteMtime = 0;
106
- }
107
- if (preWriteMtime !== baseMtime) {
108
- // Another writer committed — retry
109
- baseMtime = preWriteMtime;
110
- continue;
111
- }
112
-
113
92
  break;
114
93
  }
115
94
 
@@ -449,6 +449,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
449
449
  runId: manifest.runId,
450
450
  agentId: task.id,
451
451
  artifactsRoot: manifest.artifactsRoot,
452
+ steeringFile: `${manifest.artifactsRoot}/steering/${task.id}.jsonl`,
452
453
  onSpawn: (pid) => {
453
454
  try {
454
455
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
@@ -171,14 +171,16 @@ export function checkPerTaskBudget(
171
171
  const totalUsed = (usage?.input ?? 0) + (usage?.output ?? 0) + (usage?.cacheWrite ?? 0);
172
172
  const abort = totalUsed >= budgetAbort * budgetTotal;
173
173
  const warning = !abort && totalUsed >= budgetWarning * budgetTotal;
174
- const remainingBudget = Math.max(0, budgetTotal - totalUsed);
175
- const fairShareThreshold = remainingBudget * fairShareFraction;
174
+ // Fair share threshold based on TOTAL budget, not remaining budget.
175
+ // This ensures a task that consumed 60% of total budget is flagged even
176
+ // if only 40% remains (40% * 50% = 20% threshold would miss the 60% usage).
177
+ const fairShareThreshold = budgetTotal * fairShareFraction;
176
178
  const fairShareViolators: string[] = [];
177
179
  for (const task of tasks) {
178
180
  if (!task.usage) continue;
179
181
  const taskTotal = (task.usage.input ?? 0) + (task.usage.output ?? 0) + (task.usage.cacheWrite ?? 0);
180
182
  // Only flag tasks that individually consumed a significant portion of the
181
- // budget (>10% of total) AND exceeded the fair share of remaining budget.
183
+ // budget (>10% of total) AND exceeded the fair share threshold.
182
184
  if (fairShareThreshold > 0 && taskTotal > fairShareThreshold && taskTotal > budgetTotal * 0.1) {
183
185
  fairShareViolators.push(task.id);
184
186
  }