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.
- package/CHANGELOG.md +51 -1
- package/README.md +1 -1
- package/docs/actions-reference.md +87 -0
- package/docs/commands-reference.md +5 -0
- package/docs/pi-crew-bugs.md +6 -0
- package/index.ts +1 -1
- package/package.json +18 -16
- package/src/benchmark/benchmark-runner.ts +245 -0
- package/src/benchmark/feedback-loop.ts +66 -0
- package/src/extension/async-notifier.ts +1 -1
- package/src/extension/autonomous-policy.ts +1 -1
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/plan-orchestrate.ts +322 -0
- package/src/extension/register.ts +31 -41
- package/src/extension/registration/command-utils.ts +1 -1
- package/src/extension/registration/commands.ts +1 -1
- package/src/extension/registration/compaction-guard.ts +1 -1
- package/src/extension/registration/subagent-helpers.ts +1 -1
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +1 -1
- package/src/extension/session-summary.ts +1 -1
- package/src/extension/team-manager-command.ts +1 -1
- package/src/extension/team-onboard.ts +1 -3
- package/src/extension/team-tool/context.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +183 -0
- package/src/extension/team-tool/orchestrate.ts +102 -0
- package/src/extension/team-tool/run.ts +215 -28
- package/src/extension/team-tool.ts +115 -0
- package/src/extension/tool-result.ts +1 -1
- package/src/i18n.ts +1 -1
- package/src/observability/event-to-metric.ts +1 -1
- package/src/prompt/prompt-runtime.ts +1 -1
- package/src/runtime/background-runner.ts +27 -5
- package/src/runtime/crash-recovery.ts +1 -1
- package/src/runtime/crew-hooks.ts +240 -0
- package/src/runtime/custom-tools/irc-tool.ts +1 -1
- package/src/runtime/custom-tools/submit-result-tool.ts +1 -1
- package/src/runtime/diagnostic-export.ts +38 -2
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/live-session-runtime.ts +1 -1
- package/src/runtime/mcp-proxy.ts +1 -1
- package/src/runtime/pi-spawn.ts +20 -4
- package/src/runtime/process-status.ts +15 -2
- package/src/runtime/runtime-resolver.ts +1 -1
- package/src/runtime/session-resources.ts +1 -1
- package/src/runtime/task-runner.ts +31 -1
- package/src/runtime/team-runner.ts +6 -0
- package/src/schema/team-tool-schema.ts +36 -1
- package/src/state/crew-init.ts +56 -38
- package/src/state/decision-ledger.ts +295 -0
- package/src/state/hook-instinct-bridge.ts +90 -0
- package/src/state/hook-integrations.ts +51 -0
- package/src/state/instinct-store.ts +249 -0
- package/src/state/run-graph.ts +5 -24
- package/src/state/run-metrics.ts +135 -0
- package/src/state/tiered-eval.ts +471 -0
- package/src/state/types-eval.ts +58 -0
- package/src/state/types.ts +3 -0
- package/src/tools/safe-bash-extension.ts +5 -5
- package/src/ui/crew-widget.ts +1 -1
- package/src/ui/pi-ui-compat.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +1 -1
- package/src/ui/tool-render.ts +2 -2
- package/src/utils/bm25-search.ts +0 -2
- package/src/utils/project-detector.ts +160 -0
- package/test-bugs-all.mjs +1 -1
- package/skills/.gitkeep +0 -0
- package/skills/REFERENCE.md +0 -136
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
81
|
-
|
|
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("@
|
|
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");
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/state/crew-init.ts
CHANGED
|
@@ -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
|
-
*
|
|
45
|
-
*
|
|
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 =
|
|
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
|
|
85
|
-
|
|
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
|
+
}
|