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.
Files changed (81) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +13 -11
  3. package/docs/patterns/command-agent-skill.md +71 -0
  4. package/package.json +1 -1
  5. package/skills/council/SKILL.md +163 -0
  6. package/src/agents/agent-config.ts +4 -1
  7. package/src/agents/discover-agents.ts +1 -0
  8. package/src/benchmark/feedback-loop.ts +4 -2
  9. package/src/extension/cross-extension-rpc.ts +48 -0
  10. package/src/extension/registration/commands.ts +2 -1
  11. package/src/extension/registration/subagent-tools.ts +2 -0
  12. package/src/extension/registration/team-tool.ts +2 -0
  13. package/src/extension/registration/viewers.ts +1 -0
  14. package/src/extension/run-export.ts +16 -1
  15. package/src/extension/run-import.ts +16 -0
  16. package/src/extension/team-tool/anchor.ts +5 -1
  17. package/src/extension/team-tool/api.ts +9 -4
  18. package/src/extension/team-tool/config-patch.ts +15 -1
  19. package/src/extension/team-tool.ts +2 -1
  20. package/src/hooks/registry.ts +9 -1
  21. package/src/hooks/types.ts +14 -0
  22. package/src/i18n.ts +15 -2
  23. package/src/observability/exporters/otlp-exporter.ts +73 -0
  24. package/src/runtime/adaptive-plan.ts +24 -0
  25. package/src/runtime/agent-control.ts +6 -3
  26. package/src/runtime/async-runner.ts +58 -3
  27. package/src/runtime/background-runner.ts +1 -1
  28. package/src/runtime/chain-parser.ts +192 -0
  29. package/src/runtime/chain-runner.ts +58 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crew-agent-records.ts +4 -3
  32. package/src/runtime/cross-extension-rpc.ts +34 -8
  33. package/src/runtime/diagnostic-export.ts +3 -4
  34. package/src/runtime/dynamic-script-runner.ts +7 -7
  35. package/src/runtime/foreground-watchdog.ts +2 -2
  36. package/src/runtime/intercom-bridge.ts +178 -0
  37. package/src/runtime/live-agent-manager.ts +6 -3
  38. package/src/runtime/live-irc.ts +4 -2
  39. package/src/runtime/parallel-utils.ts +2 -1
  40. package/src/runtime/plan-templates.ts +200 -0
  41. package/src/runtime/post-checks.ts +10 -3
  42. package/src/runtime/run-drift.ts +220 -0
  43. package/src/runtime/sandbox.ts +26 -20
  44. package/src/runtime/semaphore.ts +2 -1
  45. package/src/runtime/settings-store.ts +14 -2
  46. package/src/runtime/skill-effectiveness.ts +4 -2
  47. package/src/runtime/skill-instructions.ts +4 -1
  48. package/src/runtime/subagent-manager.ts +20 -2
  49. package/src/runtime/subprocess-tool-registry.ts +2 -2
  50. package/src/runtime/task-graph.ts +79 -0
  51. package/src/runtime/task-id.ts +148 -0
  52. package/src/runtime/task-packet.ts +13 -1
  53. package/src/runtime/task-runner/context-retrieval.ts +172 -0
  54. package/src/runtime/task-runner.ts +39 -1
  55. package/src/runtime/team-runner.ts +7 -0
  56. package/src/runtime/usage-tracker.ts +4 -2
  57. package/src/runtime/verification-gates.ts +36 -9
  58. package/src/state/contracts.ts +2 -1
  59. package/src/state/event-log.ts +16 -5
  60. package/src/state/hook-instinct-bridge.ts +2 -1
  61. package/src/state/locks.ts +9 -2
  62. package/src/state/memory-store.ts +244 -0
  63. package/src/state/observation-store.ts +177 -0
  64. package/src/state/state-store.ts +4 -2
  65. package/src/state/task-claims.ts +9 -2
  66. package/src/tools/safe-bash.ts +69 -20
  67. package/src/types/new-api-types.ts +10 -5
  68. package/src/ui/keybinding-map.ts +2 -1
  69. package/src/ui/run-action-dispatcher.ts +2 -1
  70. package/src/ui/status-colors.ts +2 -1
  71. package/src/ui/syntax-highlight.ts +2 -1
  72. package/src/ui/tool-render.ts +13 -3
  73. package/src/utils/fingerprint.ts +183 -0
  74. package/src/utils/fs-watch.ts +4 -2
  75. package/src/utils/gh-protocol.ts +2 -1
  76. package/src/utils/safe-paths.ts +6 -0
  77. package/src/workflows/discover-workflows.ts +5 -1
  78. package/src/workflows/intermediate-store.ts +173 -0
  79. package/src/workflows/workflow-config.ts +8 -0
  80. package/src/worktree/cleanup.ts +8 -5
  81. 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
+ }
@@ -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
- * Whitelist of allowed identifiers for strict mode.
28
- * Only these identifiers can be used in sandboxed code.
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
- const ALLOWED_IDENTIFIERS = new Set([
31
- // Built-in constructors
32
- "Array", "Boolean", "Date", "Error", "Function", "JSON", "Map", "Number", "Object", "Promise", "RegExp", "Set", "String", "Symbol",
33
- // Static methods
34
- "ArrayBuffer", "Uint8Array", "parseInt", "parseFloat", "isNaN", "isFinite",
35
- // URI encoding
36
- "encodeURI", "decodeURI", "encodeURIComponent", "decodeURIComponent",
37
- // Math (read-only)
38
- "Math",
39
- // Console (safe methods only)
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(code)) {
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(code)) {
221
+ if (/import\.meta/.test(normalized)) {
216
222
  throw new Error("import.meta is not allowed in sandboxed code");
217
223
  }
218
224
 
@@ -88,7 +88,8 @@ export interface ParallelResult<R> {
88
88
  *
89
89
  * Adapted from oh-my-pi's `mapWithConcurrencyLimit`.
90
90
  */
91
- export async function mapWithFailFast<T, R>(
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 as opaque array (validated by crewScheduler.add)
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
- export function filterSkillsByConfidence(
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
- export function generateSkillEffectivenessReport(
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 section = `${header}\n\n${compactSkillContent(loaded.content)}`;
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 parsed = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
94
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as SubagentRecord : undefined;
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
- /** H3: Reset the global singleton registry (for test isolation). */
65
- export function resetSubprocessToolRegistry(): void {
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}", input.manifest.goal),
97
+ objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
86
98
  scope,
87
99
  scopePath,
88
100
  repo: path.basename(input.manifest.cwd) || input.manifest.cwd,