pi-crew 0.5.24 → 0.6.0

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.
@@ -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("drift-detectors", 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
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Intercom bridge — workers can escalate questions to the orchestrator.
3
+ *
4
+ * Pattern origin: pi-subagents/src/intercom-bridge.ts — contact_supervisor tool
5
+ * for child agents to escalate decisions, report progress, or ask questions.
6
+ *
7
+ * This module provides the message queue and correlation logic.
8
+ * The actual tool registration happens in task-runner.ts.
9
+ */
10
+
11
+ import { logInternalError } from "../utils/internal-error.ts";
12
+
13
+ // ── Types ────────────────────────────────────────────────────────────────
14
+
15
+ export type IntercomUrgency = "low" | "medium" | "high" | "critical";
16
+ export type IntercomType = "question" | "escalation" | "progress" | "block";
17
+
18
+ export interface IntercomMessage {
19
+ type: IntercomType;
20
+ taskStepId: string;
21
+ content: string;
22
+ urgency: IntercomUrgency;
23
+ timestamp: number;
24
+ timeout?: number; // ms to wait for response
25
+ }
26
+
27
+ export interface IntercomResponse {
28
+ answer: string;
29
+ source: "orchestrator" | "human" | "timeout";
30
+ timestamp: number;
31
+ messageId: string;
32
+ }
33
+
34
+ // ── Message Queue ────────────────────────────────────────────────────────
35
+
36
+ interface PendingMessage {
37
+ message: IntercomMessage;
38
+ id: string;
39
+ resolve: (response: IntercomResponse) => void;
40
+ timer?: ReturnType<typeof setTimeout>;
41
+ }
42
+
43
+ const MAX_QUEUE_SIZE = 100;
44
+
45
+ /**
46
+ * In-process intercom queue for worker→orchestrator communication.
47
+ *
48
+ * Each message gets a unique ID. Callers await a response via a Promise.
49
+ * If no response arrives within the timeout, resolves with source="timeout".
50
+ */
51
+ export class IntercomQueue {
52
+ private pending = new Map<string, PendingMessage>();
53
+ private queue: IntercomMessage[] = [];
54
+
55
+ /**
56
+ * Enqueue a message and return a promise that resolves when the
57
+ * orchestrator responds (or times out).
58
+ */
59
+ enqueue(message: IntercomMessage): Promise<IntercomResponse> {
60
+ if (this.pending.size >= MAX_QUEUE_SIZE) {
61
+ // Evict oldest
62
+ const firstKey = this.pending.keys().next().value;
63
+ if (firstKey) this.evict(firstKey, "queue_full");
64
+ }
65
+
66
+ const id = `icm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
67
+
68
+ return new Promise<IntercomResponse>((resolve) => {
69
+ const entry: PendingMessage = { message, id, resolve };
70
+
71
+ // Set timeout if specified
72
+ if (message.timeout && message.timeout > 0) {
73
+ entry.timer = setTimeout(() => {
74
+ resolve({
75
+ answer: "No response received within timeout",
76
+ source: "timeout",
77
+ timestamp: Date.now(),
78
+ messageId: id,
79
+ });
80
+ this.pending.delete(id);
81
+ }, message.timeout);
82
+ }
83
+
84
+ this.pending.set(id, entry);
85
+ this.queue.push({ ...message });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Respond to a pending message by ID.
91
+ */
92
+ respond(messageId: string, answer: string, source: "orchestrator" | "human" = "orchestrator"): boolean {
93
+ const entry = this.pending.get(messageId);
94
+ if (!entry) return false;
95
+
96
+ if (entry.timer) clearTimeout(entry.timer);
97
+
98
+ entry.resolve({
99
+ answer,
100
+ source,
101
+ timestamp: Date.now(),
102
+ messageId,
103
+ });
104
+
105
+ this.pending.delete(messageId);
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Get all pending messages (for orchestrator to process).
111
+ */
112
+ getPending(): Array<IntercomMessage & { id: string }> {
113
+ return [...this.pending.entries()].map(([id, entry]) => ({
114
+ ...entry.message,
115
+ id,
116
+ }));
117
+ }
118
+
119
+ /**
120
+ * Number of pending messages awaiting response.
121
+ */
122
+ get pendingCount(): number {
123
+ return this.pending.size;
124
+ }
125
+
126
+ /**
127
+ * Clean up all pending messages (e.g., on run completion).
128
+ */
129
+ clear(): void {
130
+ for (const [id, entry] of this.pending) {
131
+ this.evict(id, "run_complete");
132
+ }
133
+ this.queue = [];
134
+ }
135
+
136
+ private evict(id: string, reason: string): void {
137
+ const entry = this.pending.get(id);
138
+ if (!entry) return;
139
+
140
+ if (entry.timer) clearTimeout(entry.timer);
141
+
142
+ entry.resolve({
143
+ answer: `Message evicted: ${reason}`,
144
+ source: "timeout",
145
+ timestamp: Date.now(),
146
+ messageId: id,
147
+ });
148
+
149
+ this.pending.delete(id);
150
+ }
151
+ }
152
+
153
+ // ── Singleton per run ────────────────────────────────────────────────────
154
+
155
+ const queues = new Map<string, IntercomQueue>();
156
+
157
+ /**
158
+ * Get or create an intercom queue for a run.
159
+ */
160
+ export function getIntercomQueue(runId: string): IntercomQueue {
161
+ let queue = queues.get(runId);
162
+ if (!queue) {
163
+ queue = new IntercomQueue();
164
+ queues.set(runId, queue);
165
+ }
166
+ return queue;
167
+ }
168
+
169
+ /**
170
+ * Clean up intercom queue for a completed run.
171
+ */
172
+ export function cleanupIntercomQueue(runId: string): void {
173
+ const queue = queues.get(runId);
174
+ if (queue) {
175
+ queue.clear();
176
+ queues.delete(runId);
177
+ }
178
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Structured planning engine — template-based plan generation with verification.
3
+ *
4
+ * Pattern origin: plannotator/ — plan templates with task decomposition,
5
+ * verification constraints, and pre-execution plan verification.
6
+ *
7
+ * Templates provide reusable plan structures that can be specialized
8
+ * for different project types, replacing pure LLM-generated plans with
9
+ * deterministic scaffolding + LLM refinement.
10
+ */
11
+
12
+ import { logInternalError } from "../utils/internal-error.ts";
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────────
15
+
16
+ export interface PlanTemplate {
17
+ /** Template name (e.g., "standard-review", "full-implementation") */
18
+ name: string;
19
+ /** One-line description */
20
+ description: string;
21
+ /** Template phases */
22
+ phases: PlanPhase[];
23
+ /** Verification commands per phase (phaseName → command) */
24
+ verificationCommands: Record<string, string>;
25
+ }
26
+
27
+ export interface PlanPhase {
28
+ /** Phase name (e.g., "explore", "plan", "execute", "verify") */
29
+ name: string;
30
+ /** Agent role for this phase */
31
+ role: string;
32
+ /** Task description template — {{variables}} are substituted */
33
+ taskTemplate: string;
34
+ /** Maximum number of tasks in this phase */
35
+ maxTasks: number;
36
+ /** Dependencies on other phases */
37
+ dependsOn: string[];
38
+ /** Optional verification command */
39
+ verificationCommand?: string;
40
+ }
41
+
42
+ export interface RenderedPlan {
43
+ templateName: string;
44
+ phases: RenderedPhase[];
45
+ variables: Record<string, string>;
46
+ }
47
+
48
+ export interface RenderedPhase {
49
+ name: string;
50
+ role: string;
51
+ task: string;
52
+ dependsOn: string[];
53
+ verificationCommand?: string;
54
+ }
55
+
56
+ // ── Template Registry ────────────────────────────────────────────────────
57
+
58
+ const templates = new Map<string, PlanTemplate>();
59
+
60
+ /**
61
+ * Register a plan template.
62
+ */
63
+ export function registerPlanTemplate(template: PlanTemplate): void {
64
+ templates.set(template.name, template);
65
+ }
66
+
67
+ /**
68
+ * Get a registered template by name.
69
+ */
70
+ export function getPlanTemplate(name: string): PlanTemplate | undefined {
71
+ return templates.get(name);
72
+ }
73
+
74
+ /**
75
+ * List all registered template names.
76
+ */
77
+ export function listPlanTemplates(): string[] {
78
+ return [...templates.keys()];
79
+ }
80
+
81
+ // ── Rendering ────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Render a plan template with variable substitution.
85
+ *
86
+ * Variables in task templates use {{variableName}} syntax.
87
+ *
88
+ * @param templateName - Name of the registered template
89
+ * @param variables - Key-value pairs for substitution
90
+ * @returns Rendered plan, or undefined if template not found
91
+ */
92
+ export function renderPlanTemplate(
93
+ templateName: string,
94
+ variables: Record<string, string>,
95
+ ): RenderedPlan | undefined {
96
+ const template = templates.get(templateName);
97
+ if (!template) {
98
+ logInternalError("plan-templates", new Error(`Template not found: ${templateName}`));
99
+ return undefined;
100
+ }
101
+
102
+ const phases: RenderedPhase[] = template.phases.map((phase) => ({
103
+ name: phase.name,
104
+ role: phase.role,
105
+ task: substituteVariables(phase.taskTemplate, variables),
106
+ dependsOn: phase.dependsOn,
107
+ verificationCommand: phase.verificationCommand ?? template.verificationCommands[phase.name],
108
+ }));
109
+
110
+ return { templateName, phases, variables };
111
+ }
112
+
113
+ /**
114
+ * Substitute {{variable}} placeholders in a template string.
115
+ */
116
+ function substituteVariables(template: string, variables: Record<string, string>): string {
117
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
118
+ return variables[key] ?? match;
119
+ });
120
+ }
121
+
122
+ // ── Built-in Templates ───────────────────────────────────────────────────
123
+
124
+ registerPlanTemplate({
125
+ name: "standard-review",
126
+ description: "Standard code review workflow: explore → review → verify",
127
+ phases: [
128
+ {
129
+ name: "explore",
130
+ role: "explorer",
131
+ taskTemplate: "Map the codebase and identify the key files related to: {{goal}}. Focus on: {{focusAreas}}.",
132
+ maxTasks: 1,
133
+ dependsOn: [],
134
+ },
135
+ {
136
+ name: "review",
137
+ role: "reviewer",
138
+ taskTemplate: "Review the code identified in the explore phase for: {{goal}}. Check correctness, maintainability, and security.",
139
+ maxTasks: 1,
140
+ dependsOn: ["explore"],
141
+ },
142
+ {
143
+ name: "verify",
144
+ role: "verifier",
145
+ taskTemplate: "Verify that all review findings are addressed. Run tests if applicable. Confirm: {{goal}} is achieved.",
146
+ maxTasks: 1,
147
+ dependsOn: ["review"],
148
+ verificationCommand: "npm test",
149
+ },
150
+ ],
151
+ verificationCommands: {
152
+ verify: "npm test",
153
+ },
154
+ });
155
+
156
+ registerPlanTemplate({
157
+ name: "full-implementation",
158
+ description: "Full implementation workflow: explore → plan → execute → review → verify",
159
+ phases: [
160
+ {
161
+ name: "explore",
162
+ role: "explorer",
163
+ taskTemplate: "Explore the codebase to understand the current state relevant to: {{goal}}. Identify affected files and patterns.",
164
+ maxTasks: 1,
165
+ dependsOn: [],
166
+ },
167
+ {
168
+ name: "plan",
169
+ role: "planner",
170
+ taskTemplate: "Create a detailed implementation plan for: {{goal}}. Break down into concrete steps with file-level changes.",
171
+ maxTasks: 1,
172
+ dependsOn: ["explore"],
173
+ },
174
+ {
175
+ name: "execute",
176
+ role: "executor",
177
+ taskTemplate: "Implement the plan for: {{goal}}. Make all planned changes, write tests, and ensure TypeScript compiles.",
178
+ maxTasks: 3,
179
+ dependsOn: ["plan"],
180
+ },
181
+ {
182
+ name: "review",
183
+ role: "reviewer",
184
+ taskTemplate: "Review the implementation of: {{goal}}. Check for correctness, security, performance, and code quality.",
185
+ maxTasks: 1,
186
+ dependsOn: ["execute"],
187
+ },
188
+ {
189
+ name: "verify",
190
+ role: "verifier",
191
+ taskTemplate: "Verify the complete implementation of: {{goal}}. Run tests, check types, validate all acceptance criteria.",
192
+ maxTasks: 1,
193
+ dependsOn: ["review"],
194
+ verificationCommand: "npm test && npx tsc --noEmit",
195
+ },
196
+ ],
197
+ verificationCommands: {
198
+ verify: "npm test && npx tsc --noEmit",
199
+ },
200
+ });
@@ -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
+ }