pi-crew 0.5.0 → 0.5.2

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 (69) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +1 -1
  3. package/docs/actions-reference.md +87 -0
  4. package/docs/commands-reference.md +5 -0
  5. package/docs/pi-crew-bugs.md +6 -0
  6. package/index.ts +1 -1
  7. package/package.json +18 -16
  8. package/src/benchmark/benchmark-runner.ts +245 -0
  9. package/src/benchmark/feedback-loop.ts +66 -0
  10. package/src/extension/async-notifier.ts +1 -1
  11. package/src/extension/autonomous-policy.ts +1 -1
  12. package/src/extension/cross-extension-rpc.ts +1 -1
  13. package/src/extension/plan-orchestrate.ts +322 -0
  14. package/src/extension/register.ts +31 -41
  15. package/src/extension/registration/command-utils.ts +1 -1
  16. package/src/extension/registration/commands.ts +1 -1
  17. package/src/extension/registration/compaction-guard.ts +1 -1
  18. package/src/extension/registration/subagent-helpers.ts +1 -1
  19. package/src/extension/registration/subagent-tools.ts +1 -1
  20. package/src/extension/registration/team-tool.ts +1 -1
  21. package/src/extension/registration/viewers.ts +1 -1
  22. package/src/extension/session-summary.ts +1 -1
  23. package/src/extension/team-manager-command.ts +1 -1
  24. package/src/extension/team-onboard.ts +1 -3
  25. package/src/extension/team-tool/context.ts +1 -1
  26. package/src/extension/team-tool/handle-schedule.ts +183 -0
  27. package/src/extension/team-tool/orchestrate.ts +102 -0
  28. package/src/extension/team-tool/run.ts +215 -28
  29. package/src/extension/team-tool.ts +115 -0
  30. package/src/extension/tool-result.ts +1 -1
  31. package/src/i18n.ts +1 -1
  32. package/src/observability/event-to-metric.ts +1 -1
  33. package/src/prompt/prompt-runtime.ts +1 -1
  34. package/src/runtime/background-runner.ts +27 -5
  35. package/src/runtime/crash-recovery.ts +1 -1
  36. package/src/runtime/crew-hooks.ts +240 -0
  37. package/src/runtime/custom-tools/irc-tool.ts +1 -1
  38. package/src/runtime/custom-tools/submit-result-tool.ts +1 -1
  39. package/src/runtime/diagnostic-export.ts +38 -2
  40. package/src/runtime/foreground-watchdog.ts +1 -1
  41. package/src/runtime/live-session-runtime.ts +1 -1
  42. package/src/runtime/mcp-proxy.ts +1 -1
  43. package/src/runtime/pi-spawn.ts +20 -4
  44. package/src/runtime/process-status.ts +15 -2
  45. package/src/runtime/runtime-resolver.ts +1 -1
  46. package/src/runtime/session-resources.ts +1 -1
  47. package/src/runtime/task-runner.ts +31 -1
  48. package/src/runtime/team-runner.ts +6 -0
  49. package/src/schema/team-tool-schema.ts +36 -1
  50. package/src/state/crew-init.ts +56 -38
  51. package/src/state/decision-ledger.ts +295 -0
  52. package/src/state/hook-instinct-bridge.ts +90 -0
  53. package/src/state/hook-integrations.ts +51 -0
  54. package/src/state/instinct-store.ts +249 -0
  55. package/src/state/run-graph.ts +5 -24
  56. package/src/state/run-metrics.ts +135 -0
  57. package/src/state/tiered-eval.ts +471 -0
  58. package/src/state/types-eval.ts +58 -0
  59. package/src/state/types.ts +3 -0
  60. package/src/tools/safe-bash-extension.ts +5 -5
  61. package/src/ui/crew-widget.ts +1 -1
  62. package/src/ui/pi-ui-compat.ts +1 -1
  63. package/src/ui/run-action-dispatcher.ts +1 -1
  64. package/src/ui/tool-render.ts +2 -2
  65. package/src/utils/bm25-search.ts +0 -2
  66. package/src/utils/project-detector.ts +160 -0
  67. package/test-bugs-all.mjs +1 -1
  68. package/skills/.gitkeep +0 -0
  69. package/skills/REFERENCE.md +0 -136
@@ -8,6 +8,11 @@ export interface PiSpawnCommand {
8
8
  args: string[];
9
9
  }
10
10
 
11
+ const PI_PACKAGE_NAMES = [
12
+ "@earendil-works/pi-coding-agent",
13
+ "@mariozechner/pi-coding-agent",
14
+ ];
15
+
11
16
  function isRunnableNodeScript(filePath: string): boolean {
12
17
  return fs.existsSync(filePath) && /\.(?:mjs|cjs|js)$/i.test(filePath);
13
18
  }
@@ -26,6 +31,7 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
26
31
  try {
27
32
  const execDir = path.dirname(fs.realpathSync.native(process.execPath));
28
33
  allowedPrefixes.push(execDir.toLowerCase());
34
+ allowedPrefixes.push(path.join(path.dirname(execDir), "lib", "node_modules").toLowerCase());
29
35
  } catch { /* ignore */ }
30
36
 
31
37
  // npm global bin via APPDATA
@@ -33,6 +39,12 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
33
39
  allowedPrefixes.push(path.join(process.env.APPDATA, "npm").toLowerCase());
34
40
  }
35
41
 
42
+ const npmPrefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX;
43
+ if (npmPrefix) {
44
+ allowedPrefixes.push(path.resolve(npmPrefix).toLowerCase());
45
+ allowedPrefixes.push(path.join(path.resolve(npmPrefix), "lib", "node_modules").toLowerCase());
46
+ }
47
+
36
48
  // Project-local node_modules/.bin
37
49
  try {
38
50
  const projectBin = path.resolve("node_modules", ".bin");
@@ -62,7 +74,7 @@ function resolvePiPackageRoot(): string | undefined {
62
74
  while (dir !== path.dirname(dir)) {
63
75
  try {
64
76
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")) as { name?: string };
65
- if (pkg.name === "@mariozechner/pi-coding-agent") return dir;
77
+ if (pkg.name && PI_PACKAGE_NAMES.includes(pkg.name)) return dir;
66
78
  } catch {
67
79
  // Continue walking upward.
68
80
  }
@@ -92,12 +104,15 @@ function findPiPackageJsonFrom(startDir: string): string | undefined {
92
104
  const direct = path.join(dir, "package.json");
93
105
  try {
94
106
  const pkg = JSON.parse(fs.readFileSync(direct, "utf-8")) as { name?: string };
95
- if (pkg.name === "@mariozechner/pi-coding-agent") return direct;
107
+ if (pkg.name && PI_PACKAGE_NAMES.includes(pkg.name)) return direct;
96
108
  } catch {
97
109
  // Continue searching upward and in node_modules.
98
110
  }
99
- const dependency = path.join(dir, "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
100
- if (fs.existsSync(dependency)) return dependency;
111
+ for (const pkgName of PI_PACKAGE_NAMES) {
112
+ const [scope, name] = pkgName.replace("@", "").split("/");
113
+ const dependency = path.join(dir, "node_modules", `@${scope}`, name, "package.json");
114
+ if (fs.existsSync(dependency)) return dependency;
115
+ }
101
116
  dir = path.dirname(dir);
102
117
  }
103
118
  return undefined;
@@ -112,6 +127,7 @@ function resolvePiCliScript(): string | undefined {
112
127
 
113
128
  const roots = [
114
129
  resolvePiPackageRoot(),
130
+ process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@earendil-works", "pi-coding-agent") : undefined,
115
131
  process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@mariozechner", "pi-coding-agent") : undefined,
116
132
  path.dirname(fileURLToPath(import.meta.url)),
117
133
  process.cwd(),
@@ -77,8 +77,21 @@ export function isLikelyOrphanedActiveRun(run: TeamRunManifest, agents: CrewAgen
77
77
  }
78
78
 
79
79
  function hasDurableActiveAgentEvidence(agent: CrewAgentRecord): boolean {
80
- if (agent.status !== "running" && agent.status !== "queued") return false;
81
- return Boolean(agent.statusPath || agent.eventsPath || agent.outputPath || agent.progress || agent.toolUses || agent.jsonEvents);
80
+ if (agent.status === "running") {
81
+ // Running agents are actively executing trust them.
82
+ // Activity evidence is only required for queued agents (zombie prevention).
83
+ return true;
84
+ }
85
+ if (agent.status === "queued") {
86
+ // Queued agents need actual activity evidence to distinguish from zombies:
87
+ // spawned-but-never-executed agents should not appear as active.
88
+ return Boolean(
89
+ (agent.progress && (agent.progress.toolCount > 0 || agent.progress.recentOutput.length > 0)) ||
90
+ (agent.jsonEvents && agent.jsonEvents > 0) ||
91
+ (agent.toolUses && agent.toolUses > 0),
92
+ );
93
+ }
94
+ return false;
82
95
  }
83
96
 
84
97
  export function hasStaleAsyncProcess(run: TeamRunManifest, now = Date.now()): boolean {
@@ -38,7 +38,7 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
38
38
  const probe = async (): Promise<{ available: boolean; reason?: string }> => {
39
39
  try {
40
40
  // LAZY: optional peer dependency — probe at runtime to avoid hard dependency.
41
- const mod = await import("@mariozechner/pi-coding-agent");
41
+ const mod = await import("@earendil-works/pi-coding-agent");
42
42
  const api = mod as Record<string, unknown>;
43
43
  const required = ["createAgentSession", "DefaultResourceLoader", "SessionManager", "SettingsManager"];
44
44
  const missing = required.filter((name) => typeof api[name] === "undefined");
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { logInternalError } from "../utils/internal-error.ts";
3
3
 
4
4
  /**
@@ -40,6 +40,7 @@ import { executeHook, appendHookEvent } from "../hooks/registry.ts";
40
40
  import { createVerificationEvidence } from "./green-contract.ts";
41
41
  import { createStartupEvidence } from "./worker-startup.ts";
42
42
  import { permissionForRole } from "./role-permission.ts";
43
+ import { crewHooks } from "./crew-hooks.ts";
43
44
  import {
44
45
  collectDependencyOutputContext,
45
46
  renderDependencyOutputContext,
@@ -401,6 +402,7 @@ export async function runTeamTask(
401
402
  modelAttempts: [...modelAttempts, pendingAttempt],
402
403
  };
403
404
  tasks = updateTask(tasks, task);
405
+ crewHooks.emit({ type: "task_started", timestamp: new Date().toISOString(), runId: manifest.runId, taskId: task.id, data: { role: task.role, model: model ?? "default" } });
404
406
  upsertCrewAgent(
405
407
  manifest,
406
408
  recordFromTask(manifest, task, "child-process"),
@@ -808,7 +810,22 @@ export async function runTeamTask(
808
810
  exitCode = live.exitCode;
809
811
  error = live.error;
810
812
  parsedOutput = live.parsedOutput;
811
- resultArtifact = live.resultArtifact;
813
+ // Bug #21 fix: live-session may not produce structured output via submit_result,
814
+ // leaving finalText empty. Re-write resultArtifact with parsedOutput.finalText
815
+ // so downstream tasks that depend on this task can read meaningful output.
816
+ const liveText = cleanResultText(parsedOutput?.finalText);
817
+ if (liveText) {
818
+ // Re-write the artifact with the captured stdout — this is the content
819
+ // downstream tasks will read via task.resultArtifact.path.
820
+ resultArtifact = writeArtifact(manifest.artifactsRoot, {
821
+ kind: "result",
822
+ relativePath: `results/${task.id}.txt`,
823
+ content: liveText,
824
+ producer: task.id,
825
+ });
826
+ } else {
827
+ resultArtifact = live.resultArtifact;
828
+ }
812
829
  logArtifact = live.logArtifact;
813
830
  transcriptArtifact = live.transcriptArtifact;
814
831
  } else {
@@ -855,6 +872,8 @@ export async function runTeamTask(
855
872
  data: {
856
873
  activityState: "needs_attention",
857
874
  reason: "no_yield",
875
+ // Bug #21 fix: include result path so downstream tasks can read the output
876
+ resultPath: resultArtifact?.path,
858
877
  },
859
878
  });
860
879
  }
@@ -1004,6 +1023,17 @@ export async function runTeamTask(
1004
1023
  ...(transcriptArtifact ? { transcriptArtifact } : {}),
1005
1024
  };
1006
1025
  tasks = updateTask(tasks, task);
1026
+
1027
+ // Emit task completion hooks (100% reliable, fire-and-forget)
1028
+ const hookType = task.status === "completed" ? "task_completed" : task.status === "failed" ? "task_failed" : "task_started";
1029
+ crewHooks.emit({
1030
+ type: hookType,
1031
+ timestamp: task.finishedAt ?? new Date().toISOString(),
1032
+ runId: manifest.runId,
1033
+ taskId: task.id,
1034
+ data: { status: task.status, role: task.role, error: task.error, exitCode: task.exitCode, usage: task.usage },
1035
+ });
1036
+
1007
1037
  const packetArtifact = writeArtifact(manifest.artifactsRoot, {
1008
1038
  kind: "metadata",
1009
1039
  relativePath: `metadata/${task.id}.task-packet.json`,
@@ -28,6 +28,7 @@ import { executeWithRetry, DEFAULT_RETRY_POLICY, type RetryPolicy } from "./retr
28
28
  import { appendDeadletter } from "./deadletter.ts";
29
29
  import type { MetricRegistry } from "../observability/metric-registry.ts";
30
30
  import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
31
+ import { crewHooks } from "./crew-hooks.ts";
31
32
  import { resolveBatchConcurrency } from "./concurrency.ts";
32
33
  import { mapConcurrent } from "./parallel-utils.ts";
33
34
  import { permissionForRole } from "./role-permission.ts";
@@ -279,6 +280,10 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
279
280
  cleanupUsage();
280
281
  // Terminate live agents for this run — agents are done when the run ends.
281
282
  void terminateLiveAgentsForRun(manifest.runId, "completed", appendEvent, manifest.eventsPath).catch(() => {});
283
+
284
+ // Emit run completion hook (100% reliable, fire-and-forget)
285
+ crewHooks.emit({ type: "run_completed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: result.manifest.status, taskCount: result.tasks.length } });
286
+
282
287
  return result;
283
288
  } catch (error) {
284
289
  // P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
@@ -310,6 +315,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
310
315
  }
311
316
  const result = { manifest, tasks };
312
317
  rejectRunPromise(manifest.runId, error instanceof Error ? error : new Error(message));
318
+ crewHooks.emit({ type: "run_failed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: manifest.status, error: message } });
313
319
  cleanupUsage();
314
320
  return result;
315
321
  }
@@ -59,6 +59,15 @@ export const TeamToolParams = Type.Object({
59
59
  Type.Literal("settings"),
60
60
  Type.Literal("steer"),
61
61
  Type.Literal("health"),
62
+ Type.Literal("graph"),
63
+ Type.Literal("onboard"),
64
+ Type.Literal("explain"),
65
+ Type.Literal("cache"),
66
+ Type.Literal("checkpoint"),
67
+ Type.Literal("search"),
68
+ Type.Literal("orchestrate"),
69
+ Type.Literal("schedule"),
70
+ Type.Literal("scheduled"),
62
71
  ],
63
72
  { description: "Team action. Defaults to 'list' when omitted." },
64
73
  ),
@@ -183,6 +192,18 @@ export const TeamToolParams = Type.Object({
183
192
  replyDeadline: Type.Optional(
184
193
  Type.Integer({ description: "Ms epoch deadline for a reply." }),
185
194
  ),
195
+ planPath: Type.Optional(
196
+ Type.String({ description: "Path to a markdown plan document for orchestration." }),
197
+ ),
198
+ cron: Type.Optional(
199
+ Type.String({ description: "Cron expression for recurring scheduled runs (e.g., '0 9 * * MON')." }),
200
+ ),
201
+ interval: Type.Optional(
202
+ Type.Number({ description: "Interval in milliseconds between recurring scheduled runs." }),
203
+ ),
204
+ once: Type.Optional(
205
+ Type.Union([Type.String(), Type.Number()], { description: "ISO timestamp or epoch ms for a one-time scheduled run." }),
206
+ ),
186
207
  });
187
208
 
188
209
  export interface TeamToolParamsValue {
@@ -222,7 +243,16 @@ export interface TeamToolParamsValue {
222
243
  | "settings"
223
244
  | "steer"
224
245
  | "invalidate"
225
- | "health";
246
+ | "health"
247
+ | "graph"
248
+ | "onboard"
249
+ | "explain"
250
+ | "cache"
251
+ | "checkpoint"
252
+ | "search"
253
+ | "orchestrate"
254
+ | "schedule"
255
+ | "scheduled";
226
256
  resource?: "agent" | "team" | "workflow";
227
257
  team?: string;
228
258
  workflow?: string;
@@ -252,4 +282,9 @@ export interface TeamToolParamsValue {
252
282
  replyFrom?: string;
253
283
  /** Ms epoch deadline for a reply. */
254
284
  replyDeadline?: number;
285
+ /** Path to a markdown plan document for orchestration. */
286
+ planPath?: string;
287
+ cron?: string;
288
+ interval?: number;
289
+ once?: string | number;
255
290
  }
@@ -2,12 +2,19 @@
2
2
  * Auto-initialize .crew directory structure and .gitignore entries.
3
3
  * Called on first team run in a workspace to ensure all required
4
4
  * directories and files exist.
5
+ *
6
+ * IMPORTANT: This module must be COMPLETELY self-contained with NO dependencies
7
+ * on other pi-crew modules (especially paths.ts). It is called via dynamic
8
+ * import from child-process contexts (background runners, subagents) where
9
+ * module binding can fail. Keep this file minimal and self-contained.
5
10
  */
6
11
  import * as fs from "node:fs";
7
12
  import * as path from "node:path";
8
- import { projectCrewRoot } from "../utils/paths.ts";
9
13
  import { updateGitignore } from "./gitignore-manager.ts";
10
14
 
15
+ // Re-export updateGitignore for backwards compatibility with tests.
16
+ export { updateGitignore };
17
+
11
18
  /** README content for the .crew directory. */
12
19
  const CREW_README = `# .crew — pi-crew Runtime Directory
13
20
 
@@ -37,15 +44,57 @@ team action='cache' action='clear'
37
44
  \`\`\`
38
45
  `;
39
46
 
47
+ /**
48
+ * Find the project root by walking up from start directory.
49
+ * Inline implementation to avoid module dependency on paths.ts.
50
+ * Matches the logic in src/utils/paths.ts:computeRepoRoot().
51
+ */
52
+ function findProjectRoot(start: string): string | undefined {
53
+ const dirMarkers = [".git", ".hg", ".svn"];
54
+ const fileMarkers = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
55
+ const root = path.parse(start).root;
56
+ let current = path.resolve(start);
57
+ // Walk up to find project root
58
+ while (current !== root) {
59
+ for (const marker of dirMarkers) {
60
+ if (fs.existsSync(path.join(current, marker))) return current;
61
+ }
62
+ for (const marker of fileMarkers) {
63
+ if (fs.existsSync(path.join(current, marker))) return current;
64
+ }
65
+ const parent = path.dirname(current);
66
+ if (parent === current) break;
67
+ current = parent;
68
+ }
69
+ // Check root as fallback
70
+ if (dirMarkers.some((m) => fs.existsSync(path.join(root, m)))) return root;
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * Compute the crew root directory for a given working directory.
76
+ * Matches src/utils/paths.ts:projectCrewRoot() logic.
77
+ */
78
+ function computeCrewRoot(cwd: string): string {
79
+ const repoRoot = findProjectRoot(cwd) ?? cwd;
80
+ const crewDir = path.join(repoRoot, ".crew");
81
+ // Keep existing .crew/ stable even when .pi/ exists for project config.
82
+ if (fs.existsSync(crewDir)) return crewDir;
83
+ // Legacy reuse: if .pi/ already exists, namespace under .pi/teams/
84
+ const piDir = path.join(repoRoot, ".pi");
85
+ return fs.existsSync(piDir) ? path.join(piDir, "teams") : crewDir;
86
+ }
87
+
40
88
  /**
41
89
  * Ensure the .crew directory structure exists with all required subdirectories,
42
90
  * placeholder files, README, and .gitignore entries.
43
91
  *
44
- * Uses `projectCrewRoot()` to resolve the correct root (`.crew/` or `.pi/teams/`
45
- * for legacy projects). Idempotent safe to call multiple times.
92
+ * This function is self-contained with NO dependencies on other pi-crew modules.
93
+ * It uses inline implementations of findProjectRoot and computeCrewRoot to avoid
94
+ * module binding issues in child-process contexts.
46
95
  */
47
96
  export async function ensureCrewDirectory(cwd: string): Promise<void> {
48
- const crewRoot = projectCrewRoot(cwd);
97
+ const crewRoot = computeCrewRoot(cwd);
49
98
 
50
99
  // 1. Create directory structure
51
100
  const dirs = [
@@ -81,41 +130,10 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
81
130
  // 3. Write README.md (always overwrite to keep it current)
82
131
  fs.writeFileSync(path.join(crewRoot, "README.md"), CREW_README, "utf-8");
83
132
 
84
- // 4. Update .gitignore resolve project root to place .gitignore correctly
85
- // Find the repo root to place .gitignore at the project root (not inside .crew)
86
- const repoRoot = findRepoRootForGitignore(cwd);
133
+ // 4. Update .gitignore at project root
134
+ const repoRoot = findProjectRoot(cwd);
87
135
  if (repoRoot) {
88
136
  const gitignorePath = path.join(repoRoot, ".gitignore");
89
137
  await updateGitignore(gitignorePath);
90
138
  }
91
- }
92
-
93
- /**
94
- * Find the appropriate project root for placing the .gitignore.
95
- * Walks up from cwd to find a directory with project markers.
96
- */
97
- function findRepoRootForGitignore(cwd: string): string | undefined {
98
- // Use the same project root markers as paths.ts
99
- const dirMarkers = [".git", ".pi", ".crew", ".hg", ".svn"];
100
- const fileMarkers = [
101
- "package.json",
102
- "pyproject.toml",
103
- "Cargo.toml",
104
- "go.mod",
105
- ];
106
- const root = path.parse(cwd).root;
107
- let current = path.resolve(cwd);
108
- while (current !== root) {
109
- for (const marker of dirMarkers) {
110
- if (fs.existsSync(path.join(current, marker))) return current;
111
- }
112
- for (const marker of fileMarkers) {
113
- if (fs.existsSync(path.join(current, marker))) return current;
114
- }
115
- const parent = path.dirname(current);
116
- if (parent === current) break;
117
- current = parent;
118
- }
119
- // No project root found — don't create .gitignore
120
- return undefined;
121
- }
139
+ }
@@ -0,0 +1,295 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+
4
+ export interface CoherenceMark {
5
+ matchesPrior: boolean;
6
+ matchesRecursive: boolean;
7
+ promotionAllowed: boolean;
8
+ reason: string;
9
+ }
10
+
11
+ export interface RolloutEntry {
12
+ rolloutId: string;
13
+ timestamp: string;
14
+ priorWinner?: string;
15
+ searchSpace: string;
16
+ trialCount: number;
17
+ topCandidates: string[];
18
+ decisionMark: "accept" | "watch" | "reject" | "decay";
19
+ coherenceMark: CoherenceMark;
20
+ }
21
+
22
+ /**
23
+ * Get the ledger file path for a given run ID.
24
+ */
25
+ function getLedgerPath(runId: string): string {
26
+ return `.crew/state/runs/${runId}/decision-ledger.jsonl`;
27
+ }
28
+
29
+ /**
30
+ * Compute coherence marks based on existing ledger entries.
31
+ */
32
+ function computeCoherence(entry: RolloutEntry, ledger: RolloutEntry[]): CoherenceMark {
33
+ if (ledger.length === 0) {
34
+ return {
35
+ matchesPrior: false,
36
+ matchesRecursive: false,
37
+ promotionAllowed: true,
38
+ reason: "No prior entries - first rollout, promotion allowed",
39
+ };
40
+ }
41
+
42
+ const previousEntry = ledger[ledger.length - 1];
43
+ const matchesPrior: boolean =
44
+ entry.decisionMark === previousEntry.decisionMark ||
45
+ Boolean(entry.priorWinner && entry.topCandidates.includes(entry.priorWinner));
46
+
47
+ // Check last 3 entries for recursive pattern
48
+ const recentEntries = ledger.slice(-3);
49
+ const recentDecisions = recentEntries.map((e) => e.decisionMark);
50
+ const currentDecision = entry.decisionMark;
51
+
52
+ const recursiveMatches = recentDecisions.filter((d) => d === currentDecision).length;
53
+ const matchesRecursive = recursiveMatches >= 2;
54
+
55
+ const promotionAllowed = matchesPrior || matchesRecursive;
56
+
57
+ let reason: string;
58
+ if (matchesPrior && matchesRecursive) {
59
+ reason = `Matches prior winner and recursive pattern (${recursiveMatches}/3 recent decisions)`;
60
+ } else if (matchesPrior) {
61
+ reason = `Matches prior winner decision`;
62
+ } else if (matchesRecursive) {
63
+ reason = `Matches recursive pattern (${recursiveMatches}/3 recent decisions)`;
64
+ } else {
65
+ reason = `No match with prior or recursive pattern - requires human review`;
66
+ }
67
+
68
+ return {
69
+ matchesPrior,
70
+ matchesRecursive,
71
+ promotionAllowed,
72
+ reason,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Initialize a new decision ledger for a run.
78
+ * Creates the directory and ledger file if they don't exist.
79
+ */
80
+ export function initLedger(runId: string): void {
81
+ const ledgerPath = getLedgerPath(runId);
82
+ const dir = dirname(ledgerPath);
83
+
84
+ if (!existsSync(dir)) {
85
+ mkdirSync(dir, { recursive: true });
86
+ }
87
+
88
+ // Create empty file if it doesn't exist
89
+ if (!existsSync(ledgerPath)) {
90
+ writeFileSync(ledgerPath, "", "utf-8");
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Append a new entry to the decision ledger.
96
+ * Automatically computes and adds coherence marks.
97
+ */
98
+ export function appendEntry(runId: string, entry: RolloutEntry): RolloutEntry {
99
+ const ledgerPath = getLedgerPath(runId);
100
+
101
+ // Ensure directory exists
102
+ const dir = dirname(ledgerPath);
103
+ if (!existsSync(dir)) {
104
+ mkdirSync(dir, { recursive: true });
105
+ }
106
+
107
+ // Get existing entries to compute coherence
108
+ const ledger = getLedger(runId);
109
+
110
+ // Compute coherence marks
111
+ const coherenceMark = computeCoherence(entry, ledger);
112
+ const entryWithCoherence: RolloutEntry = {
113
+ ...entry,
114
+ coherenceMark,
115
+ };
116
+
117
+ // Append to JSONL file
118
+ const line = JSON.stringify(entryWithCoherence) + "\n";
119
+ writeFileSync(ledgerPath, line, { flag: "a", encoding: "utf-8" });
120
+ return entryWithCoherence;
121
+ }
122
+
123
+ /**
124
+ * Read all entries from the decision ledger.
125
+ */
126
+ export function getLedger(runId: string): RolloutEntry[] {
127
+ const ledgerPath = getLedgerPath(runId);
128
+
129
+ if (!existsSync(ledgerPath)) {
130
+ return [];
131
+ }
132
+
133
+ const content = readFileSync(ledgerPath, "utf-8");
134
+ if (!content.trim()) {
135
+ return [];
136
+ }
137
+
138
+ return content
139
+ .split("\n")
140
+ .filter((line) => line.trim())
141
+ .map((line) => JSON.parse(line) as RolloutEntry);
142
+ }
143
+
144
+ /**
145
+ * Get the most recent entry from the decision ledger.
146
+ */
147
+ export function getLatestDecision(runId: string): RolloutEntry | null {
148
+ const ledger = getLedger(runId);
149
+ if (ledger.length === 0) {
150
+ return null;
151
+ }
152
+ return ledger[ledger.length - 1];
153
+ }
154
+
155
+ /**
156
+ * Generate a human-readable markdown summary of the ledger.
157
+ */
158
+ export function summarizeLedger(runId: string): string {
159
+ const ledger = getLedger(runId);
160
+
161
+ if (ledger.length === 0) {
162
+ return "# Decision Ledger Summary\n\n*No entries recorded yet.*";
163
+ }
164
+
165
+ const lines: string[] = [
166
+ "# Decision Ledger Summary",
167
+ "",
168
+ `Run ID: ${runId}`,
169
+ `Total Entries: ${ledger.length}`,
170
+ "",
171
+ "## Entries",
172
+ "",
173
+ ];
174
+
175
+ for (let i = 0; i < ledger.length; i++) {
176
+ const entry = ledger[i];
177
+ lines.push(`### ${i + 1}. ${entry.rolloutId}`);
178
+ lines.push("");
179
+ lines.push(`- **Timestamp**: ${entry.timestamp}`);
180
+ lines.push(`- **Search Space**: ${entry.searchSpace}`);
181
+ lines.push(`- **Trial Count**: ${entry.trialCount}`);
182
+ lines.push(`- **Decision**: ${entry.decisionMark}`);
183
+
184
+ if (entry.priorWinner) {
185
+ lines.push(`- **Prior Winner**: ${entry.priorWinner}`);
186
+ }
187
+
188
+ lines.push(`- **Top Candidates**: ${entry.topCandidates.join(", ") || "(none)"}`);
189
+ lines.push("");
190
+ lines.push("#### Coherence");
191
+ lines.push(`- **Matches Prior**: ${entry.coherenceMark.matchesPrior ? "✓" : "✗"}`);
192
+ lines.push(`- **Matches Recursive**: ${entry.coherenceMark.matchesRecursive ? "✓" : "✗"}`);
193
+ lines.push(`- **Promotion Allowed**: ${entry.coherenceMark.promotionAllowed ? "✓" : "✗"}`);
194
+ lines.push(`- **Reason**: ${entry.coherenceMark.reason}`);
195
+ lines.push("");
196
+ }
197
+
198
+ // Summary statistics
199
+ const decisions = ledger.map((e) => e.decisionMark);
200
+ const acceptCount = decisions.filter((d) => d === "accept").length;
201
+ const watchCount = decisions.filter((d) => d === "watch").length;
202
+ const rejectCount = decisions.filter((d) => d === "reject").length;
203
+ const decayCount = decisions.filter((d) => d === "decay").length;
204
+
205
+ lines.push("## Summary");
206
+ lines.push("");
207
+ lines.push(`| Decision | Count |`);
208
+ lines.push(`|----------|-------|`);
209
+ lines.push(`| Accept | ${acceptCount} |`);
210
+ lines.push(`| Watch | ${watchCount} |`);
211
+ lines.push(`| Reject | ${rejectCount} |`);
212
+ lines.push(`| Decay | ${decayCount} |`);
213
+ lines.push("");
214
+
215
+ const promotedCount = ledger.filter((e) => e.coherenceMark.promotionAllowed).length;
216
+ lines.push(`**Promotion Rate**: ${promotedCount}/${ledger.length} (${((promotedCount / ledger.length) * 100).toFixed(1)}%)`);
217
+
218
+ return lines.join("\n");
219
+ }
220
+
221
+ /**
222
+ * Promote a candidate by marking it as accepted with proper coherence.
223
+ */
224
+ export function promoteCandidate(runId: string, candidate: string): RolloutEntry {
225
+ const latestDecision = getLatestDecision(runId);
226
+
227
+ const entry: RolloutEntry = {
228
+ rolloutId: `promote-${Date.now()}`,
229
+ timestamp: new Date().toISOString(),
230
+ priorWinner: latestDecision?.topCandidates[0],
231
+ searchSpace: latestDecision?.searchSpace || "unknown",
232
+ trialCount: (latestDecision?.trialCount || 0) + 1,
233
+ topCandidates: [candidate],
234
+ decisionMark: "accept",
235
+ coherenceMark: {
236
+ matchesPrior: false,
237
+ matchesRecursive: false,
238
+ promotionAllowed: true,
239
+ reason: "Manual promotion by user",
240
+ },
241
+ };
242
+
243
+ // Persist via appendEntry so ledger is consistent.
244
+ appendEntry(runId, entry);
245
+ const manualCoherence: import("./types.js").CoherenceMark = {
246
+ matchesPrior: false,
247
+ matchesRecursive: false,
248
+ promotionAllowed: true,
249
+ reason: "Manual promotion by user",
250
+ };
251
+ // Manually override the last line in the JSONL to reflect the coherent
252
+ // decision we want, bypassing appendEntry's auto-compute for the returned value.
253
+ const lastLine = readFileSync(getLedgerPath(runId), "utf-8").trim().split("\n").filter(Boolean).at(-1)!;
254
+ const overridden: RolloutEntry = { ...JSON.parse(lastLine), coherenceMark: manualCoherence };
255
+ writeFileSync(getLedgerPath(runId), JSON.stringify(overridden) + "\n", "utf-8");
256
+ return overridden;
257
+ }
258
+
259
+ /**
260
+ * Decay a candidate by marking it as decayed with proper coherence.
261
+ */
262
+ export function decayCandidate(runId: string, candidate: string): RolloutEntry {
263
+ const latestDecision = getLatestDecision(runId);
264
+
265
+ const entry: RolloutEntry = {
266
+ rolloutId: `decay-${Date.now()}`,
267
+ timestamp: new Date().toISOString(),
268
+ priorWinner: latestDecision?.topCandidates[0],
269
+ searchSpace: latestDecision?.searchSpace || "unknown",
270
+ trialCount: (latestDecision?.trialCount || 0) + 1,
271
+ topCandidates: [candidate],
272
+ decisionMark: "decay",
273
+ coherenceMark: {
274
+ matchesPrior: false,
275
+ matchesRecursive: false,
276
+ promotionAllowed: false,
277
+ reason: "Manual decay by user",
278
+ },
279
+ };
280
+
281
+ // Persist via appendEntry so ledger is consistent.
282
+ appendEntry(runId, entry);
283
+ const manualCoherence: import("./types.js").CoherenceMark = {
284
+ matchesPrior: false,
285
+ matchesRecursive: false,
286
+ promotionAllowed: false,
287
+ reason: "Manual decay by user",
288
+ };
289
+ // Manually override the last line in the JSONL to reflect the coherent
290
+ // decision we want, bypassing appendEntry's auto-compute for the returned value.
291
+ const lastLine = readFileSync(getLedgerPath(runId), "utf-8").trim().split("\n").filter(Boolean).at(-1)!;
292
+ const overridden: RolloutEntry = { ...JSON.parse(lastLine), coherenceMark: manualCoherence };
293
+ writeFileSync(getLedgerPath(runId), JSON.stringify(overridden) + "\n", "utf-8");
294
+ return overridden;
295
+ }