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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime drift detectors — detect state anomalies in pi-crew runs.
|
|
3
|
+
*
|
|
4
|
+
* Pattern origin: GSD-2 ADR-017 drift detection & state reconciliation.
|
|
5
|
+
* Each detector checks for a specific anomaly and returns a report.
|
|
6
|
+
* Repair handlers are idempotent — safe to run multiple times.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
12
|
+
|
|
13
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type DriftKind =
|
|
16
|
+
| "stale-process" // Heartbeat timeout (existing)
|
|
17
|
+
| "orphaned-claim" // Task claim without task
|
|
18
|
+
| "orphaned-worktree" // Worktree dir without active run
|
|
19
|
+
| "missing-timestamps" // State files without timestamps
|
|
20
|
+
| "status-divergence" // Manifest status ≠ status file
|
|
21
|
+
| "unregistered-run"; // State dir but no manifest
|
|
22
|
+
|
|
23
|
+
export interface DriftReport {
|
|
24
|
+
kind: DriftKind;
|
|
25
|
+
runId: string;
|
|
26
|
+
details: string;
|
|
27
|
+
repaired: boolean;
|
|
28
|
+
repairResult?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DriftContext {
|
|
32
|
+
/** Root directory for crew state (.crew/) */
|
|
33
|
+
crewRoot: string;
|
|
34
|
+
/** Active run IDs (from registry) */
|
|
35
|
+
activeRunIds: Set<string>;
|
|
36
|
+
/** Manifest content if available */
|
|
37
|
+
manifest?: { runId: string; status: string; cwd: string; [k: string]: unknown };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Detectors ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect task claims that reference tasks not in the manifest.
|
|
44
|
+
*/
|
|
45
|
+
export function detectOrphanedClaim(ctx: DriftContext): DriftReport | null {
|
|
46
|
+
if (!ctx.manifest) return null;
|
|
47
|
+
const claimsDir = path.join(ctx.crewRoot, "state", "task-claims");
|
|
48
|
+
if (!existsSync(claimsDir)) return null;
|
|
49
|
+
|
|
50
|
+
const claimFiles = readdirSync(claimsDir).filter((f) => f.endsWith(".json"));
|
|
51
|
+
for (const file of claimFiles) {
|
|
52
|
+
try {
|
|
53
|
+
const claim = JSON.parse(readFileSync(path.join(claimsDir, file), "utf-8"));
|
|
54
|
+
if (claim.runId === ctx.manifest.runId && claim.taskId) {
|
|
55
|
+
// Check if task exists in manifest tasks array
|
|
56
|
+
const tasks = (ctx.manifest as Record<string, unknown>).tasks;
|
|
57
|
+
if (Array.isArray(tasks) && !tasks.some((t: Record<string, unknown>) => t.id === claim.taskId)) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "orphaned-claim",
|
|
60
|
+
runId: ctx.manifest.runId,
|
|
61
|
+
details: `Task claim '${claim.taskId}' references non-existent task`,
|
|
62
|
+
repaired: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Malformed claim file — skip
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Detect worktree directories that don't belong to any active run.
|
|
75
|
+
*/
|
|
76
|
+
export function detectOrphanedWorktree(ctx: DriftContext): DriftReport | null {
|
|
77
|
+
const worktreesDir = path.join(ctx.crewRoot, "worktrees");
|
|
78
|
+
if (!existsSync(worktreesDir)) return null;
|
|
79
|
+
|
|
80
|
+
const dirs = readdirSync(worktreesDir, { withFileTypes: true })
|
|
81
|
+
.filter((d) => d.isDirectory())
|
|
82
|
+
.map((d) => d.name);
|
|
83
|
+
|
|
84
|
+
for (const dir of dirs) {
|
|
85
|
+
// Extract run ID from worktree dir name (format: <runId>-<taskId> or <runId>)
|
|
86
|
+
const runId = dir.split("-").slice(0, 5).join("-"); // heuristic: run IDs are timestamp-based
|
|
87
|
+
if (!ctx.activeRunIds.has(runId) && !ctx.activeRunIds.has(dir)) {
|
|
88
|
+
return {
|
|
89
|
+
kind: "orphaned-worktree",
|
|
90
|
+
runId: dir,
|
|
91
|
+
details: `Worktree '${dir}' has no active run`,
|
|
92
|
+
repaired: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Detect state files missing required timestamps.
|
|
101
|
+
*/
|
|
102
|
+
export function detectMissingTimestamps(ctx: DriftContext): DriftReport | null {
|
|
103
|
+
if (!ctx.manifest) return null;
|
|
104
|
+
const stateDir = path.join(ctx.crewRoot, "state");
|
|
105
|
+
if (!existsSync(stateDir)) return null;
|
|
106
|
+
|
|
107
|
+
// Check manifest has createdAt/updatedAt
|
|
108
|
+
const m = ctx.manifest as Record<string, unknown>;
|
|
109
|
+
if (!m.createdAt && !m.updatedAt) {
|
|
110
|
+
return {
|
|
111
|
+
kind: "missing-timestamps",
|
|
112
|
+
runId: ctx.manifest.runId,
|
|
113
|
+
details: "Manifest missing createdAt/updatedAt timestamps",
|
|
114
|
+
repaired: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Detect divergence between manifest status and individual task status files.
|
|
122
|
+
*/
|
|
123
|
+
export function detectStatusDivergence(ctx: DriftContext): DriftReport | null {
|
|
124
|
+
if (!ctx.manifest) return null;
|
|
125
|
+
const statusPath = path.join(ctx.crewRoot, "state", `${ctx.manifest.runId}.status`);
|
|
126
|
+
if (!existsSync(statusPath)) return null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const status = readFileSync(statusPath, "utf-8").trim();
|
|
130
|
+
if (status !== ctx.manifest.status) {
|
|
131
|
+
return {
|
|
132
|
+
kind: "status-divergence",
|
|
133
|
+
runId: ctx.manifest.runId,
|
|
134
|
+
details: `Manifest says '${ctx.manifest.status}' but status file says '${status}'`,
|
|
135
|
+
repaired: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Can't read status file — not drift, might be permissions
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Detect state directories that have no corresponding manifest.
|
|
146
|
+
*/
|
|
147
|
+
export function detectUnregisteredRun(ctx: DriftContext): DriftReport | null {
|
|
148
|
+
const runsDir = path.join(ctx.crewRoot, "runs");
|
|
149
|
+
if (!existsSync(runsDir)) return null;
|
|
150
|
+
|
|
151
|
+
const runDirs = readdirSync(runsDir, { withFileTypes: true })
|
|
152
|
+
.filter((d) => d.isDirectory())
|
|
153
|
+
.map((d) => d.name);
|
|
154
|
+
|
|
155
|
+
for (const runId of runDirs) {
|
|
156
|
+
if (!ctx.activeRunIds.has(runId)) {
|
|
157
|
+
// Check if it has state files (manifest exists)
|
|
158
|
+
const manifestPath = path.join(runsDir, runId, "manifest.json");
|
|
159
|
+
if (existsSync(manifestPath)) {
|
|
160
|
+
try {
|
|
161
|
+
const stat = statSync(manifestPath);
|
|
162
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
163
|
+
// Only flag if older than 1 hour (might be in-progress)
|
|
164
|
+
if (ageMs > 60 * 60 * 1000) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "unregistered-run",
|
|
167
|
+
runId,
|
|
168
|
+
details: `Run '${runId}' has manifest but is not in active registry (age: ${Math.round(ageMs / 60000)}m)`,
|
|
169
|
+
repaired: false,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Can't stat — skip
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Reconciliation Loop ─────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
const ALL_DETECTORS = [
|
|
184
|
+
detectOrphanedClaim,
|
|
185
|
+
detectOrphanedWorktree,
|
|
186
|
+
detectMissingTimestamps,
|
|
187
|
+
detectStatusDivergence,
|
|
188
|
+
detectUnregisteredRun,
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run all drift detectors and collect reports.
|
|
193
|
+
* Capped at maxPasses repair attempts.
|
|
194
|
+
*
|
|
195
|
+
* Pattern origin: GSD-2 ADR-017 — capped at 2 retry passes.
|
|
196
|
+
*/
|
|
197
|
+
export function runDriftDetection(ctx: DriftContext, maxPasses = 2): DriftReport[] {
|
|
198
|
+
const reports: DriftReport[] = [];
|
|
199
|
+
|
|
200
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
201
|
+
let newFindings = 0;
|
|
202
|
+
|
|
203
|
+
for (const detector of ALL_DETECTORS) {
|
|
204
|
+
try {
|
|
205
|
+
const report = detector(ctx);
|
|
206
|
+
if (report) {
|
|
207
|
+
reports.push(report);
|
|
208
|
+
newFindings++;
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logInternalError("run-drift", error, `detector=${detector.name} runId=${ctx.manifest?.runId}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// If no new findings, stop early
|
|
216
|
+
if (newFindings === 0) break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return reports;
|
|
220
|
+
}
|
package/src/runtime/sandbox.ts
CHANGED
|
@@ -21,28 +21,32 @@ const FORBIDDEN_PATTERNS = [
|
|
|
21
21
|
// Global escape vectors
|
|
22
22
|
/\bglobalThis\b/, // globalThis reference
|
|
23
23
|
/\bglobal\b/, // global reference (Node.js)
|
|
24
|
+
/\bconstructor\b/, // Block constructor chain escape: [].constructor.constructor("return process")()
|
|
24
25
|
] as const;
|
|
25
26
|
|
|
27
|
+
Object.freeze(FORBIDDEN_PATTERNS);
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
30
|
+
* SECURITY (HIGH #3 fix): Normalize source code before forbidden-pattern checks
|
|
31
|
+
* to prevent unicode-escape bypasses.
|
|
32
|
+
*
|
|
33
|
+
* Attackers can write `import\u0028"fs"\u0029` which compiles as
|
|
34
|
+
* `import("fs")` but does not match the regex `/import\s*\(/`.
|
|
35
|
+
*
|
|
36
|
+
* This function:
|
|
37
|
+
* 1. Strips null bytes (used to split keywords across boundaries)
|
|
38
|
+
* 2. Decodes \uXXXX escape sequences so regexes see the actual characters
|
|
29
39
|
*/
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"console",
|
|
41
|
-
// Process (limited)
|
|
42
|
-
"process",
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
Object.freeze(FORBIDDEN_PATTERNS);
|
|
40
|
+
export function normalizeCodeForValidation(code: string): string {
|
|
41
|
+
// Strip null bytes
|
|
42
|
+
let normalized = code.replace(/\0/g, "");
|
|
43
|
+
// Decode common unicode escapes: \u0028 → (
|
|
44
|
+
normalized = normalized.replace(
|
|
45
|
+
/\\u([0-9a-fA-F]{4})/g,
|
|
46
|
+
(_, hex) => String.fromCharCode(Number.parseInt(hex, 16)),
|
|
47
|
+
);
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
46
50
|
|
|
47
51
|
export interface SandboxOptions {
|
|
48
52
|
timeout?: number;
|
|
@@ -204,15 +208,17 @@ export class WorkflowSandbox {
|
|
|
204
208
|
* ensure compilation is safe.
|
|
205
209
|
*/
|
|
206
210
|
private validateScript(code: string): void {
|
|
211
|
+
// SECURITY (HIGH #3 fix): Normalize unicode escapes before pattern matching
|
|
212
|
+
const normalized = normalizeCodeForValidation(code);
|
|
207
213
|
// Check for ESM/module patterns
|
|
208
214
|
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
209
|
-
if (pattern.test(
|
|
215
|
+
if (pattern.test(normalized)) {
|
|
210
216
|
throw new Error(`Forbidden pattern detected: ${pattern.source}`);
|
|
211
217
|
}
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
// Check for import.meta specifically (C4)
|
|
215
|
-
if (/import\.meta/.test(
|
|
221
|
+
if (/import\.meta/.test(normalized)) {
|
|
216
222
|
throw new Error("import.meta is not allowed in sandboxed code");
|
|
217
223
|
}
|
|
218
224
|
|
package/src/runtime/semaphore.ts
CHANGED
|
@@ -88,7 +88,8 @@ export interface ParallelResult<R> {
|
|
|
88
88
|
*
|
|
89
89
|
* Adapted from oh-my-pi's `mapWithConcurrencyLimit`.
|
|
90
90
|
*/
|
|
91
|
-
|
|
91
|
+
/** @internal */
|
|
92
|
+
async function mapWithFailFast<T, R>(
|
|
92
93
|
items: T[],
|
|
93
94
|
concurrency: number,
|
|
94
95
|
fn: (item: T, index: number, signal: AbortSignal) => Promise<R>,
|
|
@@ -20,6 +20,18 @@ const MAX_TURNS_CEILING = 10_000;
|
|
|
20
20
|
const GRACE_TURNS_CEILING = 1_000;
|
|
21
21
|
const VALID_JOIN_MODES = new Set<JoinMode>(["async", "group", "smart"]);
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* M2: Validate that a scheduled job object has required fields before passing to scheduler.
|
|
25
|
+
* Prevents opaque unknown[] from reaching CrewScheduler.add() without validation.
|
|
26
|
+
*/
|
|
27
|
+
function validateScheduledJob(job: unknown): boolean {
|
|
28
|
+
if (!job || typeof job !== "object") return false;
|
|
29
|
+
const obj = job as Record<string, unknown>;
|
|
30
|
+
return typeof obj.id === "string" && obj.id.length > 0
|
|
31
|
+
&& typeof obj.scheduleType === "string"
|
|
32
|
+
&& typeof obj.enabled === "boolean";
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
function sanitizeSettings(raw: unknown): CrewSettings {
|
|
24
36
|
if (!raw || typeof raw !== "object") return {};
|
|
25
37
|
const r = raw as Record<string, unknown>;
|
|
@@ -57,9 +69,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
|
|
|
57
69
|
if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
|
|
58
70
|
out.notifierIntervalMs = r.notifierIntervalMs;
|
|
59
71
|
}
|
|
60
|
-
// Pass through scheduledJobs
|
|
72
|
+
// Pass through scheduledJobs after basic validation
|
|
61
73
|
if (Array.isArray(r.scheduledJobs)) {
|
|
62
|
-
out.scheduledJobs = r.scheduledJobs;
|
|
74
|
+
out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
|
|
63
75
|
}
|
|
64
76
|
return out;
|
|
65
77
|
}
|
|
@@ -374,7 +374,8 @@ export function getWeightedSkillsForRole(
|
|
|
374
374
|
* Filter skills by confidence threshold.
|
|
375
375
|
* Skills below threshold are marked as "suggest" only.
|
|
376
376
|
*/
|
|
377
|
-
|
|
377
|
+
/** @internal */
|
|
378
|
+
function filterSkillsByConfidence(
|
|
378
379
|
skillIds: string[],
|
|
379
380
|
runId: string,
|
|
380
381
|
threshold: keyof typeof CONFIDENCE_THRESHOLDS = "MODERATE",
|
|
@@ -431,7 +432,8 @@ export function registerSkillEffectivenessHooks(): void {
|
|
|
431
432
|
/**
|
|
432
433
|
* Generate a skill effectiveness report for a run.
|
|
433
434
|
*/
|
|
434
|
-
|
|
435
|
+
/** @internal */
|
|
436
|
+
function generateSkillEffectivenessReport(
|
|
435
437
|
runId: string,
|
|
436
438
|
skillIds: string[],
|
|
437
439
|
): string {
|
|
@@ -244,7 +244,10 @@ export function renderSkillInstructions(input: RenderSkillInstructionsInput & {
|
|
|
244
244
|
const confidenceNote = weighted ? ` [Confidence: ${(weighted.confidence * 100).toFixed(0)}% — ${weighted.threshold}]` : "";
|
|
245
245
|
|
|
246
246
|
const header = [`## ${safeName}`, description ? `Description: ${description}${confidenceNote}` : undefined, `Source: ${source}`].filter(Boolean).join("\n");
|
|
247
|
-
const
|
|
247
|
+
const rawContent = compactSkillContent(loaded.content);
|
|
248
|
+
// Wrap skill content with provenance markers to help LLMs distinguish skill instructions
|
|
249
|
+
const wrappedContent = `<!-- skill: ${safeName} -->\n${rawContent}\n<!-- end-skill: ${safeName} -->`;
|
|
250
|
+
const section = `${header}\n\n${wrappedContent}`;
|
|
248
251
|
if (!pushSection(section)) omittedCount += 1;
|
|
249
252
|
}
|
|
250
253
|
if (omittedCount > 0) {
|
|
@@ -88,10 +88,28 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const ALLOWED_RECORD_FIELDS = new Set([
|
|
92
|
+
"agentId", "agentName", "subagentType", "status", "spawnedAt",
|
|
93
|
+
"completedAt", "model", "runId", "cwd", "taskId", "taskId",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
|
|
97
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
98
|
+
const obj = raw as Record<string, unknown>;
|
|
99
|
+
if (typeof obj.agentId !== "string" || !obj.agentId) return undefined;
|
|
100
|
+
const clean: Record<string, unknown> = { agentId: obj.agentId };
|
|
101
|
+
for (const key of Object.keys(obj)) {
|
|
102
|
+
if (ALLOWED_RECORD_FIELDS.has(key) && (typeof obj[key] === "string" || typeof obj[key] === "number" || typeof obj[key] === "boolean")) {
|
|
103
|
+
clean[key] = obj[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return clean as unknown as SubagentRecord;
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRecord | undefined {
|
|
92
110
|
try {
|
|
93
|
-
const
|
|
94
|
-
return
|
|
111
|
+
const raw = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
|
|
112
|
+
return sanitizePersistedRecord(raw);
|
|
95
113
|
} catch {
|
|
96
114
|
return undefined;
|
|
97
115
|
}
|
|
@@ -61,7 +61,7 @@ class SubprocessToolRegistryImpl implements SubprocessToolRegistry {
|
|
|
61
61
|
|
|
62
62
|
export const subprocessToolRegistry: SubprocessToolRegistry = new SubprocessToolRegistryImpl();
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/** @internal Reset the global singleton registry (for test isolation). */
|
|
65
|
+
function resetSubprocessToolRegistry(): void {
|
|
66
66
|
subprocessToolRegistry.clear();
|
|
67
67
|
}
|
|
@@ -215,3 +215,82 @@ export function detectCycles(tasks: TaskNode[]): string[][] {
|
|
|
215
215
|
|
|
216
216
|
return cycles;
|
|
217
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Find tasks that are blocked (not completed, have incomplete dependencies).
|
|
221
|
+
*
|
|
222
|
+
* Pattern origin: pi-blueprint dependency-graph.ts findBlockedTasks()
|
|
223
|
+
*
|
|
224
|
+
* @param tasks - All task nodes
|
|
225
|
+
* @param completedIds - Set of completed task IDs
|
|
226
|
+
* @returns Array of blocked task IDs
|
|
227
|
+
*/
|
|
228
|
+
export function findBlockedTasks(tasks: TaskNode[], completedIds: Set<string>): string[] {
|
|
229
|
+
return tasks
|
|
230
|
+
.filter((t) => !completedIds.has(t.id))
|
|
231
|
+
.filter((t) => t.dependsOn.some((dep) => !completedIds.has(dep)))
|
|
232
|
+
.map((t) => t.id);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get specific incomplete dependencies blocking a task.
|
|
237
|
+
*
|
|
238
|
+
* Pattern origin: pi-blueprint dependency-graph.ts getBlockingTasks()
|
|
239
|
+
*
|
|
240
|
+
* @param tasks - All task nodes
|
|
241
|
+
* @param taskId - The task to check
|
|
242
|
+
* @param completedIds - Set of completed task IDs
|
|
243
|
+
* @returns Array of blocking task IDs
|
|
244
|
+
*/
|
|
245
|
+
export function getBlockingTasks(tasks: TaskNode[], taskId: string, completedIds: Set<string>): string[] {
|
|
246
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
247
|
+
if (!task) return [];
|
|
248
|
+
return task.dependsOn.filter((dep) => !completedIds.has(dep));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Topological sort using Kahn's BFS algorithm.
|
|
253
|
+
*
|
|
254
|
+
* Pattern origin: pi-blueprint dependency-graph.ts topologicalSort()
|
|
255
|
+
*
|
|
256
|
+
* @param tasks - All task nodes
|
|
257
|
+
* @returns Ordered array of task IDs (dependencies first)
|
|
258
|
+
*/
|
|
259
|
+
export function topologicalSort(tasks: TaskNode[]): string[] {
|
|
260
|
+
if (tasks.length === 0) return [];
|
|
261
|
+
|
|
262
|
+
const idSet = new Set(tasks.map((t) => t.id));
|
|
263
|
+
const inDegree = new Map<string, number>();
|
|
264
|
+
const adjacency = new Map<string, string[]>();
|
|
265
|
+
|
|
266
|
+
for (const task of tasks) {
|
|
267
|
+
inDegree.set(task.id, 0);
|
|
268
|
+
adjacency.set(task.id, []);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const task of tasks) {
|
|
272
|
+
for (const dep of task.dependsOn) {
|
|
273
|
+
if (!idSet.has(dep)) continue;
|
|
274
|
+
adjacency.get(dep)!.push(task.id);
|
|
275
|
+
inDegree.set(task.id, (inDegree.get(task.id) ?? 0) + 1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const queue: string[] = [];
|
|
280
|
+
for (const [id, deg] of inDegree) {
|
|
281
|
+
if (deg === 0) queue.push(id);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const result: string[] = [];
|
|
285
|
+
while (queue.length > 0) {
|
|
286
|
+
const id = queue.shift()!;
|
|
287
|
+
result.push(id);
|
|
288
|
+
for (const dependent of adjacency.get(id) ?? []) {
|
|
289
|
+
const deg = inDegree.get(dependent)! - 1;
|
|
290
|
+
inDegree.set(dependent, deg);
|
|
291
|
+
if (deg === 0) queue.push(dependent);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash-based task ID generation with adaptive length and hierarchical decomposition.
|
|
3
|
+
*
|
|
4
|
+
* Pattern origin: beads/internal/idgen/hash.go — SHA-256 → base36 encoding
|
|
5
|
+
* with birthday-paradox collision probability adaptation.
|
|
6
|
+
*
|
|
7
|
+
* IDs look like: `pc-a1b2` (prefix + base36 hash)
|
|
8
|
+
* Hierarchical: `pc-a1b2.1` (parent.child)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
// ── Configuration ────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PREFIX = "pc";
|
|
16
|
+
const BASE36_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
17
|
+
|
|
18
|
+
interface AdaptiveIDConfig {
|
|
19
|
+
maxCollisionProbability: number;
|
|
20
|
+
minLength: number;
|
|
21
|
+
maxLength: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CONFIG: AdaptiveIDConfig = {
|
|
25
|
+
maxCollisionProbability: 0.25,
|
|
26
|
+
minLength: 3,
|
|
27
|
+
maxLength: 8,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── Core Functions ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a hash-based ID using SHA-256 → base36 encoding.
|
|
34
|
+
*
|
|
35
|
+
* @param content - String content to hash
|
|
36
|
+
* @param length - Desired hash length (3–8 chars)
|
|
37
|
+
* @returns Base36 hash string
|
|
38
|
+
*/
|
|
39
|
+
export function hashToBase36(content: string, length: number): string {
|
|
40
|
+
const hash = createHash("sha256").update(content).digest();
|
|
41
|
+
let result = "";
|
|
42
|
+
for (let i = 0; i < hash.length && result.length < length; i++) {
|
|
43
|
+
const byte = hash[i]!;
|
|
44
|
+
// Use modulo to map byte to base36
|
|
45
|
+
result += BASE36_CHARS[byte % 36]!;
|
|
46
|
+
}
|
|
47
|
+
return result.padEnd(length, "0").slice(0, length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Calculate adaptive hash length based on existing ID count.
|
|
52
|
+
*
|
|
53
|
+
* Uses birthday-paradox formula: P(collision) ≈ 1 - e^(-n² / (2 * 36^L))
|
|
54
|
+
*
|
|
55
|
+
* @param existingCount - Number of existing IDs with the same prefix
|
|
56
|
+
* @param config - Adaptive configuration
|
|
57
|
+
* @returns Recommended hash length
|
|
58
|
+
*/
|
|
59
|
+
export function calculateAdaptiveLength(
|
|
60
|
+
existingCount: number,
|
|
61
|
+
config: AdaptiveIDConfig = DEFAULT_CONFIG,
|
|
62
|
+
): number {
|
|
63
|
+
for (let length = config.minLength; length <= config.maxLength; length++) {
|
|
64
|
+
const totalPossibilities = Math.pow(36, length);
|
|
65
|
+
const probability = 1 - Math.exp(-(existingCount * existingCount) / (2 * totalPossibilities));
|
|
66
|
+
if (probability <= config.maxCollisionProbability) {
|
|
67
|
+
return length;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return config.maxLength;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate a deterministic hash-based task ID.
|
|
75
|
+
*
|
|
76
|
+
* @param parts - Content parts to hash (title, description, etc.)
|
|
77
|
+
* @param prefix - ID prefix (default: "pc")
|
|
78
|
+
* @param existingCount - Number of existing IDs (for adaptive length)
|
|
79
|
+
* @returns Hash-based ID like "pc-a1b2"
|
|
80
|
+
*/
|
|
81
|
+
export function generateTaskHashId(
|
|
82
|
+
parts: string[],
|
|
83
|
+
prefix = DEFAULT_PREFIX,
|
|
84
|
+
existingCount = 0,
|
|
85
|
+
): string {
|
|
86
|
+
const content = parts.join("|");
|
|
87
|
+
const length = calculateAdaptiveLength(existingCount);
|
|
88
|
+
const hash = hashToBase36(content, length);
|
|
89
|
+
return `${prefix}-${hash}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Hierarchical IDs ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface ParsedHierarchicalId {
|
|
95
|
+
parentId: string;
|
|
96
|
+
childNum: number;
|
|
97
|
+
isHierarchical: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse a hierarchical ID into parent and child number.
|
|
102
|
+
*
|
|
103
|
+
* Example: "pc-a1b2.3" → { parentId: "pc-a1b2", childNum: 3, isHierarchical: true }
|
|
104
|
+
*/
|
|
105
|
+
export function parseHierarchicalId(id: string): ParsedHierarchicalId {
|
|
106
|
+
const dotIndex = id.lastIndexOf(".");
|
|
107
|
+
if (dotIndex === -1 || dotIndex < 3) {
|
|
108
|
+
return { parentId: id, childNum: 0, isHierarchical: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parentId = id.slice(0, dotIndex);
|
|
112
|
+
const childStr = id.slice(dotIndex + 1);
|
|
113
|
+
const childNum = Number.parseInt(childStr, 10);
|
|
114
|
+
|
|
115
|
+
if (!Number.isFinite(childNum) || childNum < 1) {
|
|
116
|
+
return { parentId: id, childNum: 0, isHierarchical: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { parentId, childNum, isHierarchical: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Generate a child ID from a parent ID and child number.
|
|
124
|
+
*
|
|
125
|
+
* Example: childId("pc-a1b2", 3) → "pc-a1b2.3"
|
|
126
|
+
*/
|
|
127
|
+
export function childId(parentId: string, childNum: number): string {
|
|
128
|
+
return `${parentId}.${childNum}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Dependency Types ─────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rich dependency types for task relationships.
|
|
135
|
+
*
|
|
136
|
+
* Pattern origin: beads/internal/types/types.go — 19 DependencyType constants
|
|
137
|
+
* Only "blocks" and "parent-child" affect execution ordering.
|
|
138
|
+
*/
|
|
139
|
+
export type DependencyType =
|
|
140
|
+
| "blocks" // A must complete before B starts
|
|
141
|
+
| "parent-child" // Hierarchical relationship
|
|
142
|
+
| "conditional-blocks" // B runs only if A fails
|
|
143
|
+
| "waits-for" // Fanout gate: wait for dynamic children
|
|
144
|
+
| "related" // Association only (no ordering)
|
|
145
|
+
| "supersedes" // A replaces B
|
|
146
|
+
| "duplicates" // A duplicates B
|
|
147
|
+
| "delegated-from" // A was delegated from B
|
|
148
|
+
| "validates"; // A validates B's output
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { TeamRunManifest, TaskPacket, TaskScope, VerificationContract } from "../state/types.ts";
|
|
3
3
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
4
|
+
import { generateTaskHashId } from "./task-id.ts";
|
|
4
5
|
|
|
5
6
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
7
|
// SEC-007 Fix: Workflow Step Task Sanitization
|
|
@@ -81,8 +82,19 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
|
|
|
81
82
|
const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
|
|
82
83
|
// SEC-007: Sanitize task text before inserting into task packet
|
|
83
84
|
const sanitizedTask = sanitizeTaskText(input.step.task);
|
|
85
|
+
const sanitizedGoal = sanitizeTaskText(input.manifest.goal);
|
|
86
|
+
|
|
87
|
+
// Generate a deterministic hash-based task ID for traceability and logging.
|
|
88
|
+
// Uses goal + step ID + run ID as content parts.
|
|
89
|
+
// TODO: Once TaskPacket type gains a hashId field, include this in the packet.
|
|
90
|
+
const _taskHashId = generateTaskHashId([
|
|
91
|
+
input.manifest.goal,
|
|
92
|
+
input.step.id,
|
|
93
|
+
input.manifest.runId,
|
|
94
|
+
]);
|
|
95
|
+
|
|
84
96
|
return {
|
|
85
|
-
objective: sanitizedTask.replaceAll("{goal}",
|
|
97
|
+
objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
|
|
86
98
|
scope,
|
|
87
99
|
scopePath,
|
|
88
100
|
repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
|