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.
- package/CHANGELOG.md +99 -0
- package/README.md +13 -11
- package/docs/patterns/command-agent-skill.md +71 -0
- package/package.json +1 -1
- package/skills/council/SKILL.md +163 -0
- package/src/agents/agent-config.ts +4 -1
- package/src/agents/discover-agents.ts +1 -0
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/registration/commands.ts +2 -1
- package/src/extension/registration/subagent-tools.ts +2 -0
- package/src/extension/registration/team-tool.ts +2 -0
- package/src/extension/registration/viewers.ts +1 -0
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +9 -4
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool.ts +2 -1
- package/src/hooks/registry.ts +9 -1
- package/src/hooks/types.ts +14 -0
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +58 -3
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/chain-parser.ts +192 -0
- package/src/runtime/chain-runner.ts +58 -0
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/crew-agent-records.ts +4 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +3 -4
- package/src/runtime/dynamic-script-runner.ts +7 -7
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/intercom-bridge.ts +178 -0
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/plan-templates.ts +200 -0
- package/src/runtime/post-checks.ts +10 -3
- package/src/runtime/run-drift.ts +220 -0
- package/src/runtime/sandbox.ts +26 -20
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +4 -2
- package/src/runtime/skill-instructions.ts +4 -1
- package/src/runtime/subagent-manager.ts +20 -2
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-graph.ts +79 -0
- package/src/runtime/task-id.ts +148 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/context-retrieval.ts +172 -0
- package/src/runtime/task-runner.ts +39 -1
- package/src/runtime/team-runner.ts +7 -0
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log.ts +16 -5
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +9 -2
- package/src/state/memory-store.ts +244 -0
- package/src/state/observation-store.ts +177 -0
- package/src/state/state-store.ts +4 -2
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/fingerprint.ts +183 -0
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/safe-paths.ts +6 -0
- package/src/workflows/discover-workflows.ts +5 -1
- package/src/workflows/intermediate-store.ts +173 -0
- package/src/workflows/workflow-config.ts +8 -0
- package/src/worktree/cleanup.ts +8 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
{ name: "typecheck", command: "npx tsc --noEmit 2>&1
|
|
43
|
-
{ name: "lint", command: "npm run lint 2>&1
|
|
44
|
-
{ name: "tests", command: "npm test 2>&1
|
|
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
|
|
52
|
-
{ name: "test", command: "cargo test 2>&1
|
|
53
|
-
{ name: "clippy", command: "cargo clippy 2>&1
|
|
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
|
-
|
|
342
|
+
/** @internal */
|
|
343
|
+
function createVerificationGateReport(
|
|
317
344
|
taskId: string,
|
|
318
345
|
contract: VerificationContract,
|
|
319
346
|
results: VerificationCommandResult[],
|
package/src/state/contracts.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
+
/** @internal */
|
|
32
|
+
const TEAM_EVENT_TYPES = [
|
|
32
33
|
"run.created",
|
|
33
34
|
"run.queued",
|
|
34
35
|
"run.planning",
|
package/src/state/event-log.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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);
|
package/src/state/locks.ts
CHANGED
|
@@ -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
|
|
115
|
+
if (stored === undefined || timingSafeTokenMatch(stored, token)) {
|
|
109
116
|
try {
|
|
110
117
|
fs.rmSync(filePath, { force: true });
|
|
111
118
|
} catch {
|