@soleri/core 9.4.0 → 9.5.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.
Files changed (45) hide show
  1. package/dist/hooks/candidate-scorer.d.ts +28 -0
  2. package/dist/hooks/candidate-scorer.d.ts.map +1 -0
  3. package/dist/hooks/candidate-scorer.js +20 -0
  4. package/dist/hooks/candidate-scorer.js.map +1 -0
  5. package/dist/hooks/index.d.ts +2 -0
  6. package/dist/hooks/index.d.ts.map +1 -0
  7. package/dist/hooks/index.js +2 -0
  8. package/dist/hooks/index.js.map +1 -0
  9. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  10. package/dist/planning/plan-lifecycle.js +6 -1
  11. package/dist/planning/plan-lifecycle.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/brain/brain.ts +120 -46
  14. package/src/brain/intelligence.ts +42 -34
  15. package/src/chat/agent-loop.ts +1 -1
  16. package/src/chat/notifications.ts +4 -0
  17. package/src/control/intent-router.ts +10 -8
  18. package/src/curator/curator.ts +145 -29
  19. package/src/hooks/candidate-scorer.test.ts +76 -0
  20. package/src/hooks/candidate-scorer.ts +39 -0
  21. package/src/hooks/index.ts +6 -0
  22. package/src/index.ts +2 -0
  23. package/src/llm/llm-client.ts +1 -0
  24. package/src/persistence/sqlite-provider.ts +1 -0
  25. package/src/planning/github-projection.ts +48 -44
  26. package/src/planning/plan-lifecycle.ts +14 -1
  27. package/src/queue/pipeline-runner.ts +4 -0
  28. package/src/runtime/curator-extra-ops.test.ts +7 -0
  29. package/src/runtime/curator-extra-ops.ts +10 -1
  30. package/src/runtime/facades/curator-facade.test.ts +7 -0
  31. package/src/runtime/facades/memory-facade.ts +187 -0
  32. package/src/runtime/orchestrate-ops.ts +3 -3
  33. package/src/runtime/runtime.test.ts +50 -2
  34. package/src/runtime/runtime.ts +117 -89
  35. package/src/runtime/shutdown-registry.test.ts +151 -0
  36. package/src/runtime/shutdown-registry.ts +85 -0
  37. package/src/runtime/types.ts +4 -1
  38. package/src/transport/http-server.ts +50 -3
  39. package/src/transport/ws-server.ts +8 -0
  40. package/src/vault/linking.test.ts +12 -0
  41. package/src/vault/linking.ts +90 -44
  42. package/src/vault/vault-maintenance.ts +11 -18
  43. package/src/vault/vault-memories.ts +21 -13
  44. package/src/vault/vault-schema.ts +21 -0
  45. package/src/vault/vault.ts +8 -3
@@ -17,14 +17,17 @@ import type {
17
17
  } from './types.js';
18
18
 
19
19
  import {
20
- detectDuplicates as detectDuplicatesPure,
21
20
  DEFAULT_DUPLICATE_THRESHOLD,
21
+ MERGE_SUGGESTION_THRESHOLD,
22
+ buildVocabulary,
23
+ entryToText,
22
24
  } from './duplicate-detector.js';
23
25
  import {
24
26
  findContradictions,
25
27
  DEFAULT_CONTRADICTION_THRESHOLD,
26
28
  type ContradictionCandidate,
27
29
  } from './contradiction-detector.js';
30
+ import { tokenize, calculateTfIdf, cosineSimilarity } from '../text/similarity.js';
28
31
  import {
29
32
  normalizeTag as normalizeTagPure,
30
33
  normalizeAndDedup,
@@ -40,6 +43,7 @@ import { enrichEntryMetadata } from './metadata-enricher.js';
40
43
  // ─── Constants ──────────────────────────────────────────────────────
41
44
 
42
45
  const DEFAULT_STALE_DAYS = 90;
46
+ const DEFAULT_BATCH_SIZE = 100;
43
47
 
44
48
  // ─── Curator Class ──────────────────────────────────────────────────
45
49
 
@@ -151,19 +155,101 @@ export class Curator {
151
155
  // ─── Duplicates (delegates to duplicate-detector) ─────────────
152
156
 
153
157
  detectDuplicates(entryId?: string, threshold?: number): DuplicateDetectionResult[] {
154
- const results = detectDuplicatesPure(this.vault.list({ limit: 100000 }), entryId, threshold);
155
- // Filter out dismissed pairs
158
+ const effectiveThreshold = threshold ?? DEFAULT_DUPLICATE_THRESHOLD;
156
159
  const dismissed = this.getDismissedPairs();
157
- if (dismissed.size === 0) return results;
158
- return results
159
- .map((r) => ({
160
- ...r,
161
- matches: r.matches.filter((m) => {
162
- const key = [r.entryId, m.entryId].sort().join('::');
163
- return !dismissed.has(key);
164
- }),
165
- }))
166
- .filter((r) => r.matches.length > 0);
160
+
161
+ // --- Phase 1: Content-hash exact duplicates (O(n) via GROUP BY) ---
162
+ const exactDupes = this.provider.all<{ content_hash: string; ids: string }>(
163
+ `SELECT content_hash, GROUP_CONCAT(id) as ids FROM entries
164
+ WHERE content_hash IS NOT NULL
165
+ GROUP BY content_hash HAVING COUNT(*) > 1`,
166
+ );
167
+
168
+ const results: DuplicateDetectionResult[] = [];
169
+ const seenPairs = new Set<string>();
170
+
171
+ for (const { ids } of exactDupes) {
172
+ const idList = ids.split(',');
173
+ if (entryId && !idList.includes(entryId)) continue;
174
+ const targets = entryId ? [entryId] : idList;
175
+ for (const targetId of targets) {
176
+ const matches: DuplicateCandidate[] = [];
177
+ for (const otherId of idList) {
178
+ if (otherId === targetId) continue;
179
+ const pairKey = [targetId, otherId].sort().join('::');
180
+ if (dismissed.has(pairKey) || seenPairs.has(pairKey)) continue;
181
+ seenPairs.add(pairKey);
182
+ const other = this.vault.get(otherId);
183
+ if (!other) continue;
184
+ matches.push({
185
+ entryId: otherId,
186
+ title: other.title,
187
+ similarity: 1.0,
188
+ suggestMerge: true,
189
+ });
190
+ }
191
+ if (matches.length > 0) {
192
+ results.push({ entryId: targetId, matches, scannedCount: idList.length - 1 });
193
+ }
194
+ }
195
+ }
196
+
197
+ // --- Phase 2: FTS5 fuzzy candidate matching ---
198
+ // For each entry, use FTS5 MATCH to find top candidates, then TF-IDF cosine similarity.
199
+ // Complexity is O(n * k) where k = FTS5 candidate limit (10), not O(n^2).
200
+ const exactDupeEntryIds = new Set(results.map((r) => r.entryId));
201
+ const targetEntries = entryId
202
+ ? ([this.vault.get(entryId)].filter(Boolean) as IntelligenceEntry[])
203
+ : this.listBatched();
204
+
205
+ // Build vocabulary from all entries (batched)
206
+ const allEntries = entryId ? this.listBatched() : targetEntries;
207
+ const vocabulary = buildVocabulary(allEntries);
208
+
209
+ for (const entry of targetEntries) {
210
+ // Skip entries already fully handled by exact-hash matches
211
+ if (exactDupeEntryIds.has(entry.id)) continue;
212
+
213
+ // FTS5 candidate retrieval: find top-10 similar entries
214
+ let candidates: IntelligenceEntry[];
215
+ try {
216
+ candidates = this.vault
217
+ .search(entry.title, { domain: entry.domain, limit: 10 })
218
+ .map((r) => r.entry)
219
+ .filter((c) => c.id !== entry.id);
220
+ } catch {
221
+ candidates = [];
222
+ }
223
+ if (candidates.length === 0) continue;
224
+
225
+ const entryVec = calculateTfIdf(tokenize(entryToText(entry)), vocabulary);
226
+ const matches: DuplicateCandidate[] = [];
227
+
228
+ for (const candidate of candidates) {
229
+ // Skip cross-domain pairs
230
+ if (entry.domain !== candidate.domain) continue;
231
+ const pairKey = [entry.id, candidate.id].sort().join('::');
232
+ if (dismissed.has(pairKey)) continue;
233
+
234
+ const candidateVec = calculateTfIdf(tokenize(entryToText(candidate)), vocabulary);
235
+ const similarity = cosineSimilarity(entryVec, candidateVec);
236
+ if (similarity >= effectiveThreshold) {
237
+ matches.push({
238
+ entryId: candidate.id,
239
+ title: candidate.title,
240
+ similarity,
241
+ suggestMerge: similarity >= MERGE_SUGGESTION_THRESHOLD,
242
+ });
243
+ }
244
+ }
245
+
246
+ if (matches.length > 0) {
247
+ matches.sort((a, b) => b.similarity - a.similarity);
248
+ results.push({ entryId: entry.id, matches, scannedCount: candidates.length });
249
+ }
250
+ }
251
+
252
+ return results;
167
253
  }
168
254
 
169
255
  dismissDuplicate(entryIdA: string, entryIdB: string, reason?: string): { dismissed: boolean } {
@@ -187,9 +273,11 @@ export class Curator {
187
273
  detectContradictions(threshold?: number): Contradiction[] {
188
274
  const searchFn = (title: string) =>
189
275
  this.vault.search(title, { type: 'pattern', limit: 20 }).map((r) => r.entry);
190
- return this.persistContradictions(
191
- findContradictions(this.vault.list({ limit: 100000 }), threshold, searchFn),
192
- );
276
+ // Load only anti-patterns and patterns (bounded by type), not the entire vault
277
+ const antipatterns = this.vault.list({ type: 'anti-pattern', limit: 10000 });
278
+ const patterns = this.vault.list({ type: 'pattern', limit: 10000 });
279
+ const entries = [...antipatterns, ...patterns];
280
+ return this.persistContradictions(findContradictions(entries, threshold, searchFn));
193
281
  }
194
282
 
195
283
  getContradictions(status?: ContradictionStatus): Contradiction[] {
@@ -218,10 +306,12 @@ export class Curator {
218
306
  ): Promise<{ contradictions: Contradiction[]; method: 'tfidf-only' }> {
219
307
  const searchFn = (title: string) =>
220
308
  this.vault.search(title, { type: 'pattern', limit: 20 }).map((r) => r.entry);
309
+ // Load only anti-patterns and patterns (bounded by type), not the entire vault
310
+ const antipatterns = this.vault.list({ type: 'anti-pattern', limit: 10000 });
311
+ const patterns = this.vault.list({ type: 'pattern', limit: 10000 });
312
+ const entries = [...antipatterns, ...patterns];
221
313
  return {
222
- contradictions: this.persistContradictions(
223
- findContradictions(this.vault.list({ limit: 100000 }), threshold, searchFn),
224
- ),
314
+ contradictions: this.persistContradictions(findContradictions(entries, threshold, searchFn)),
225
315
  method: 'tfidf-only',
226
316
  };
227
317
  }
@@ -249,19 +339,28 @@ export class Curator {
249
339
 
250
340
  groomAll(): GroomAllResult {
251
341
  const start = Date.now();
252
- const entries = this.vault.list({ limit: 100000 });
253
342
  let tagsNormalized = 0,
254
- staleCount = 0;
255
- for (const entry of entries) {
256
- const result = this.groomEntry(entry.id);
257
- if (result) {
258
- tagsNormalized += result.tagsNormalized.filter((t) => t.wasAliased).length;
259
- if (result.stale) staleCount++;
343
+ staleCount = 0,
344
+ totalEntries = 0;
345
+ // Batch pagination — process entries in batches instead of loading all at once
346
+ let offset = 0;
347
+ while (true) {
348
+ const batch = this.vault.list({ limit: DEFAULT_BATCH_SIZE, offset });
349
+ if (batch.length === 0) break;
350
+ totalEntries += batch.length;
351
+ for (const entry of batch) {
352
+ const result = this.groomEntry(entry.id);
353
+ if (result) {
354
+ tagsNormalized += result.tagsNormalized.filter((t) => t.wasAliased).length;
355
+ if (result.stale) staleCount++;
356
+ }
260
357
  }
358
+ if (batch.length < DEFAULT_BATCH_SIZE) break;
359
+ offset += DEFAULT_BATCH_SIZE;
261
360
  }
262
361
  return {
263
- totalEntries: entries.length,
264
- groomedCount: entries.length,
362
+ totalEntries,
363
+ groomedCount: totalEntries,
265
364
  tagsNormalized,
266
365
  staleCount,
267
366
  durationMs: Date.now() - start,
@@ -343,7 +442,8 @@ export class Curator {
343
442
  // ─── Health Audit (delegates to health-audit) ─────────────────
344
443
 
345
444
  healthAudit(): HealthAuditResult {
346
- const entries = this.vault.list({ limit: 100000 });
445
+ // Load entries in batches instead of all at once
446
+ const entries = this.listBatched();
347
447
  const dataProvider: HealthDataProvider = {
348
448
  getStaleCount: (threshold) =>
349
449
  (
@@ -480,6 +580,22 @@ export class Curator {
480
580
 
481
581
  // ─── Private Helpers ──────────────────────────────────────────
482
582
 
583
+ /**
584
+ * Load all vault entries using batched pagination instead of a single 100k query.
585
+ */
586
+ private listBatched(batchSize: number = DEFAULT_BATCH_SIZE): IntelligenceEntry[] {
587
+ const all: IntelligenceEntry[] = [];
588
+ let offset = 0;
589
+ while (true) {
590
+ const batch = this.vault.list({ limit: batchSize, offset });
591
+ if (batch.length === 0) break;
592
+ all.push(...batch);
593
+ if (batch.length < batchSize) break;
594
+ offset += batchSize;
595
+ }
596
+ return all;
597
+ }
598
+
483
599
  private persistContradictions(candidates: ContradictionCandidate[]): Contradiction[] {
484
600
  const detected: Contradiction[] = [];
485
601
  for (const c of candidates) {
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { scoreCandidateForConversion } from './candidate-scorer.js';
3
+ import type { CandidateDimensions } from './candidate-scorer.js';
4
+
5
+ describe('scoreCandidateForConversion', () => {
6
+ it('should identify strong candidate (4/4 HIGH)', () => {
7
+ const dimensions: CandidateDimensions = {
8
+ frequency: 'HIGH',
9
+ eventCorrelation: 'HIGH',
10
+ determinism: 'HIGH',
11
+ autonomy: 'HIGH',
12
+ };
13
+ const result = scoreCandidateForConversion(dimensions);
14
+ expect(result.highCount).toBe(4);
15
+ expect(result.candidate).toBe(true);
16
+ });
17
+
18
+ it('should identify borderline candidate (3/4 HIGH)', () => {
19
+ const dimensions: CandidateDimensions = {
20
+ frequency: 'HIGH',
21
+ eventCorrelation: 'HIGH',
22
+ determinism: 'HIGH',
23
+ autonomy: 'LOW',
24
+ };
25
+ const result = scoreCandidateForConversion(dimensions);
26
+ expect(result.highCount).toBe(3);
27
+ expect(result.candidate).toBe(true);
28
+ });
29
+
30
+ it('should reject non-candidate (2/4 HIGH)', () => {
31
+ const dimensions: CandidateDimensions = {
32
+ frequency: 'HIGH',
33
+ eventCorrelation: 'LOW',
34
+ determinism: 'HIGH',
35
+ autonomy: 'LOW',
36
+ };
37
+ const result = scoreCandidateForConversion(dimensions);
38
+ expect(result.highCount).toBe(2);
39
+ expect(result.candidate).toBe(false);
40
+ });
41
+
42
+ it('should reject weak candidate (1/4 HIGH)', () => {
43
+ const dimensions: CandidateDimensions = {
44
+ frequency: 'LOW',
45
+ eventCorrelation: 'LOW',
46
+ determinism: 'LOW',
47
+ autonomy: 'HIGH',
48
+ };
49
+ const result = scoreCandidateForConversion(dimensions);
50
+ expect(result.highCount).toBe(1);
51
+ expect(result.candidate).toBe(false);
52
+ });
53
+
54
+ it('should reject zero score (0/4 HIGH)', () => {
55
+ const dimensions: CandidateDimensions = {
56
+ frequency: 'LOW',
57
+ eventCorrelation: 'LOW',
58
+ determinism: 'LOW',
59
+ autonomy: 'LOW',
60
+ };
61
+ const result = scoreCandidateForConversion(dimensions);
62
+ expect(result.highCount).toBe(0);
63
+ expect(result.candidate).toBe(false);
64
+ });
65
+
66
+ it('should preserve dimension values in result', () => {
67
+ const dimensions: CandidateDimensions = {
68
+ frequency: 'HIGH',
69
+ eventCorrelation: 'LOW',
70
+ determinism: 'HIGH',
71
+ autonomy: 'HIGH',
72
+ };
73
+ const result = scoreCandidateForConversion(dimensions);
74
+ expect(result.dimensions).toEqual(dimensions);
75
+ });
76
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Candidate scoring rubric for skill-to-hook conversion.
3
+ * 4-dimension scoring: frequency, eventCorrelation, determinism, autonomy.
4
+ * Threshold: >= 3 HIGH dimensions = candidate for conversion.
5
+ */
6
+
7
+ export type DimensionLevel = 'HIGH' | 'LOW';
8
+
9
+ export interface CandidateDimensions {
10
+ /** 3+ manual calls per session for same event type → HIGH */
11
+ frequency: DimensionLevel;
12
+ /** Skill consistently triggers on a recognizable hook event → HIGH */
13
+ eventCorrelation: DimensionLevel;
14
+ /** Skill produces consistent, non-exploratory guidance → HIGH */
15
+ determinism: DimensionLevel;
16
+ /** Skill requires no interactive user decisions mid-execution → HIGH */
17
+ autonomy: DimensionLevel;
18
+ }
19
+
20
+ export interface CandidateScore {
21
+ dimensions: CandidateDimensions;
22
+ highCount: number;
23
+ candidate: boolean;
24
+ }
25
+
26
+ /**
27
+ * Score a skill for hook conversion candidacy.
28
+ * @param dimensions - The 4 scored dimensions (each HIGH or LOW)
29
+ * @returns Score with candidate boolean (true if >= 3 HIGH)
30
+ */
31
+ export function scoreCandidateForConversion(dimensions: CandidateDimensions): CandidateScore {
32
+ const values = Object.values(dimensions);
33
+ const highCount = values.filter((v) => v === 'HIGH').length;
34
+ return {
35
+ dimensions,
36
+ highCount,
37
+ candidate: highCount >= 3,
38
+ };
39
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ scoreCandidateForConversion,
3
+ type CandidateDimensions,
4
+ type CandidateScore,
5
+ type DimensionLevel,
6
+ } from './candidate-scorer.js';
package/src/index.ts CHANGED
@@ -551,6 +551,8 @@ export { createSemanticFacades } from './runtime/facades/index.js';
551
551
  export { createDomainFacade, createDomainFacades } from './runtime/domain-ops.js';
552
552
  export { FeatureFlags } from './runtime/feature-flags.js';
553
553
  export type { FlagDefinition } from './runtime/feature-flags.js';
554
+ export { ShutdownRegistry } from './runtime/shutdown-registry.js';
555
+ export type { ShutdownCallback } from './runtime/shutdown-registry.js';
554
556
  export type { AgentRuntimeConfig, AgentRuntime } from './runtime/types.js';
555
557
  export {
556
558
  deprecationWarning,
@@ -237,6 +237,7 @@ export class LLMClient {
237
237
  temperature: options.temperature ?? 0.3,
238
238
  max_completion_tokens: options.maxTokens ?? 500,
239
239
  }),
240
+ signal: AbortSignal.timeout(60_000),
240
241
  });
241
242
 
242
243
  if (response.headers) {
@@ -20,6 +20,7 @@ export function applyPerformancePragmas(db: Database.Database): void {
20
20
  db.pragma('cache_size = -64000'); // 64MB
21
21
  db.pragma('temp_store = MEMORY');
22
22
  db.pragma('mmap_size = 268435456'); // 256MB
23
+ db.pragma('synchronous = NORMAL');
23
24
  }
24
25
 
25
26
  export class SQLitePersistenceProvider implements PersistenceProvider {
@@ -7,7 +7,10 @@
7
7
  * The plan is the source of truth; GitHub issues are the projection.
8
8
  */
9
9
 
10
- import { execFileSync } from 'node:child_process';
10
+ import { execFile } from 'node:child_process';
11
+ import { promisify } from 'node:util';
12
+
13
+ const execFileAsync = promisify(execFile);
11
14
 
12
15
  // ---------------------------------------------------------------------------
13
16
  // Types
@@ -88,15 +91,14 @@ export function parseGitHubRemote(remoteUrl: string): GitHubRepo | null {
88
91
  * Detect the GitHub remote from a project directory.
89
92
  * Returns null if no GitHub remote found or not a git repo.
90
93
  */
91
- export function detectGitHubRemote(projectPath: string): GitHubRepo | null {
94
+ export async function detectGitHubRemote(projectPath: string): Promise<GitHubRepo | null> {
92
95
  try {
93
- const output = execFileSync('git', ['remote', 'get-url', 'origin'], {
96
+ const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
94
97
  cwd: projectPath,
95
- encoding: 'utf-8',
96
98
  timeout: 5000,
97
- stdio: ['pipe', 'pipe', 'pipe'],
98
- }).trim();
99
- return parseGitHubRemote(output);
99
+ signal: AbortSignal.timeout(5000),
100
+ });
101
+ return parseGitHubRemote(stdout.trim());
100
102
  } catch {
101
103
  return null;
102
104
  }
@@ -105,12 +107,11 @@ export function detectGitHubRemote(projectPath: string): GitHubRepo | null {
105
107
  /**
106
108
  * Check if the `gh` CLI is authenticated.
107
109
  */
108
- export function isGhAuthenticated(): boolean {
110
+ export async function isGhAuthenticated(): Promise<boolean> {
109
111
  try {
110
- execFileSync('gh', ['auth', 'status'], {
111
- encoding: 'utf-8',
112
+ await execFileAsync('gh', ['auth', 'status'], {
112
113
  timeout: 5000,
113
- stdio: ['pipe', 'pipe', 'pipe'],
114
+ signal: AbortSignal.timeout(5000),
114
115
  });
115
116
  return true;
116
117
  } catch {
@@ -125,9 +126,9 @@ export function isGhAuthenticated(): boolean {
125
126
  /**
126
127
  * List milestones for a GitHub repo.
127
128
  */
128
- export function listMilestones(repo: GitHubRepo): GitHubMilestone[] {
129
+ export async function listMilestones(repo: GitHubRepo): Promise<GitHubMilestone[]> {
129
130
  try {
130
- const output = execFileSync(
131
+ const { stdout } = await execFileAsync(
131
132
  'gh',
132
133
  [
133
134
  'api',
@@ -136,12 +137,12 @@ export function listMilestones(repo: GitHubRepo): GitHubMilestone[] {
136
137
  '.[] | {number, title, state}',
137
138
  ],
138
139
  {
139
- encoding: 'utf-8',
140
140
  timeout: 10000,
141
- stdio: ['pipe', 'pipe', 'pipe'],
141
+ signal: AbortSignal.timeout(10000),
142
142
  },
143
- ).trim();
143
+ );
144
144
 
145
+ const output = stdout.trim();
145
146
  if (!output) return [];
146
147
 
147
148
  // gh --jq outputs one JSON object per line
@@ -157,9 +158,12 @@ export function listMilestones(repo: GitHubRepo): GitHubMilestone[] {
157
158
  /**
158
159
  * List open issues for a GitHub repo.
159
160
  */
160
- export function listOpenIssues(repo: GitHubRepo, limit: number = 100): GitHubIssue[] {
161
+ export async function listOpenIssues(
162
+ repo: GitHubRepo,
163
+ limit: number = 100,
164
+ ): Promise<GitHubIssue[]> {
161
165
  try {
162
- const output = execFileSync(
166
+ const { stdout } = await execFileAsync(
163
167
  'gh',
164
168
  [
165
169
  'issue',
@@ -174,12 +178,12 @@ export function listOpenIssues(repo: GitHubRepo, limit: number = 100): GitHubIss
174
178
  'number,title,state,body',
175
179
  ],
176
180
  {
177
- encoding: 'utf-8',
178
181
  timeout: 10000,
179
- stdio: ['pipe', 'pipe', 'pipe'],
182
+ signal: AbortSignal.timeout(10000),
180
183
  },
181
- ).trim();
184
+ );
182
185
 
186
+ const output = stdout.trim();
183
187
  if (!output) return [];
184
188
  return JSON.parse(output) as GitHubIssue[];
185
189
  } catch {
@@ -190,18 +194,18 @@ export function listOpenIssues(repo: GitHubRepo, limit: number = 100): GitHubIss
190
194
  /**
191
195
  * List labels for a GitHub repo.
192
196
  */
193
- export function listLabels(repo: GitHubRepo): GitHubLabel[] {
197
+ export async function listLabels(repo: GitHubRepo): Promise<GitHubLabel[]> {
194
198
  try {
195
- const output = execFileSync(
199
+ const { stdout } = await execFileAsync(
196
200
  'gh',
197
201
  ['label', 'list', '--repo', `${repo.owner}/${repo.repo}`, '--json', 'name,color'],
198
202
  {
199
- encoding: 'utf-8',
200
203
  timeout: 10000,
201
- stdio: ['pipe', 'pipe', 'pipe'],
204
+ signal: AbortSignal.timeout(10000),
202
205
  },
203
- ).trim();
206
+ );
204
207
 
208
+ const output = stdout.trim();
205
209
  if (!output) return [];
206
210
  return JSON.parse(output) as GitHubLabel[];
207
211
  } catch {
@@ -217,16 +221,18 @@ export function listLabels(repo: GitHubRepo): GitHubLabel[] {
217
221
  * Detect full GitHub context for a project.
218
222
  * Returns null if not a GitHub project or gh CLI not available.
219
223
  */
220
- export function detectGitHubContext(projectPath: string): GitHubContext | null {
221
- const repo = detectGitHubRemote(projectPath);
224
+ export async function detectGitHubContext(projectPath: string): Promise<GitHubContext | null> {
225
+ const repo = await detectGitHubRemote(projectPath);
222
226
  if (!repo) return null;
223
227
 
224
- const authenticated = isGhAuthenticated();
228
+ const authenticated = await isGhAuthenticated();
225
229
  if (!authenticated) return null;
226
230
 
227
- const milestones = listMilestones(repo);
228
- const existingIssues = listOpenIssues(repo);
229
- const labels = listLabels(repo);
231
+ const [milestones, existingIssues, labels] = await Promise.all([
232
+ listMilestones(repo),
233
+ listOpenIssues(repo),
234
+ listLabels(repo),
235
+ ]);
230
236
 
231
237
  return { repo, authenticated, milestones, existingIssues, labels };
232
238
  }
@@ -370,7 +376,7 @@ export function formatIssueBody(
370
376
  * Create a GitHub issue using the `gh` CLI.
371
377
  * Returns the issue number, or null on failure.
372
378
  */
373
- export function createGitHubIssue(
379
+ export async function createGitHubIssue(
374
380
  repo: GitHubRepo,
375
381
  title: string,
376
382
  body: string,
@@ -378,7 +384,7 @@ export function createGitHubIssue(
378
384
  milestone?: number;
379
385
  labels?: string[];
380
386
  },
381
- ): number | null {
387
+ ): Promise<number | null> {
382
388
  try {
383
389
  const args = [
384
390
  'issue',
@@ -399,14 +405,13 @@ export function createGitHubIssue(
399
405
  args.push('--label', options.labels.join(','));
400
406
  }
401
407
 
402
- const output = execFileSync('gh', args, {
403
- encoding: 'utf-8',
408
+ const { stdout } = await execFileAsync('gh', args, {
404
409
  timeout: 15000,
405
- stdio: ['pipe', 'pipe', 'pipe'],
406
- }).trim();
410
+ signal: AbortSignal.timeout(15000),
411
+ });
407
412
 
408
413
  // gh issue create returns the issue URL: https://github.com/owner/repo/issues/123
409
- const match = output.match(/\/issues\/(\d+)/);
414
+ const match = stdout.trim().match(/\/issues\/(\d+)/);
410
415
  return match ? parseInt(match[1], 10) : null;
411
416
  } catch {
412
417
  return null;
@@ -416,13 +421,13 @@ export function createGitHubIssue(
416
421
  /**
417
422
  * Update an existing GitHub issue body (for linking plans to existing issues).
418
423
  */
419
- export function updateGitHubIssueBody(
424
+ export async function updateGitHubIssueBody(
420
425
  repo: GitHubRepo,
421
426
  issueNumber: number,
422
427
  body: string,
423
- ): boolean {
428
+ ): Promise<boolean> {
424
429
  try {
425
- execFileSync(
430
+ await execFileAsync(
426
431
  'gh',
427
432
  [
428
433
  'issue',
@@ -434,9 +439,8 @@ export function updateGitHubIssueBody(
434
439
  body,
435
440
  ],
436
441
  {
437
- encoding: 'utf-8',
438
442
  timeout: 15000,
439
- stdio: ['pipe', 'pipe', 'pipe'],
443
+ signal: AbortSignal.timeout(15000),
440
444
  },
441
445
  );
442
446
  return true;
@@ -380,7 +380,20 @@ export function createPlanObject(params: {
380
380
  scope: params.scope,
381
381
  status: params.initialStatus ?? 'draft',
382
382
  decisions: params.decisions ?? [],
383
- tasks: (params.tasks ?? []).map((t, i) => (Object.assign({id:`task-${i+1}`,title:t.title,description:t.description,status:`pending` as TaskStatus}, t.phase!==undefined&&{phase:t.phase}, t.milestone!==undefined&&{milestone:t.milestone}, t.parentTaskId!==undefined&&{parentTaskId:t.parentTaskId}, {updatedAt:now}))),
383
+ tasks: (params.tasks ?? []).map((t, i) =>
384
+ Object.assign(
385
+ {
386
+ id: `task-${i + 1}`,
387
+ title: t.title,
388
+ description: t.description,
389
+ status: `pending` as TaskStatus,
390
+ },
391
+ t.phase !== undefined && { phase: t.phase },
392
+ t.milestone !== undefined && { milestone: t.milestone },
393
+ t.parentTaskId !== undefined && { parentTaskId: t.parentTaskId },
394
+ { updatedAt: now },
395
+ ),
396
+ ),
384
397
  ...(params.approach !== undefined && { approach: params.approach }),
385
398
  ...(params.context !== undefined && { context: params.context }),
386
399
  ...(params.success_criteria !== undefined && { success_criteria: params.success_criteria }),
@@ -58,6 +58,10 @@ export class PipelineRunner {
58
58
  if (this.running) return;
59
59
  this.running = true;
60
60
  this.timer = setInterval(() => this.tick(), this.pollIntervalMs);
61
+ // Don't prevent process exit for background polling
62
+ if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
63
+ (this.timer as NodeJS.Timeout).unref();
64
+ }
61
65
  }
62
66
 
63
67
  /**
@@ -28,6 +28,13 @@ function mockRuntime() {
28
28
  start: vi.fn(),
29
29
  stop: vi.fn(),
30
30
  },
31
+ shutdownRegistry: {
32
+ register: vi.fn(),
33
+ closeAll: vi.fn(),
34
+ closeAllSync: vi.fn(),
35
+ size: 0,
36
+ isClosed: false,
37
+ },
31
38
  } as unknown as AgentRuntime;
32
39
  }
33
40