@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.
- package/dist/hooks/candidate-scorer.d.ts +28 -0
- package/dist/hooks/candidate-scorer.d.ts.map +1 -0
- package/dist/hooks/candidate-scorer.js +20 -0
- package/dist/hooks/candidate-scorer.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/planning/plan-lifecycle.d.ts.map +1 -1
- package/dist/planning/plan-lifecycle.js +6 -1
- package/dist/planning/plan-lifecycle.js.map +1 -1
- package/package.json +1 -1
- package/src/brain/brain.ts +120 -46
- package/src/brain/intelligence.ts +42 -34
- package/src/chat/agent-loop.ts +1 -1
- package/src/chat/notifications.ts +4 -0
- package/src/control/intent-router.ts +10 -8
- package/src/curator/curator.ts +145 -29
- package/src/hooks/candidate-scorer.test.ts +76 -0
- package/src/hooks/candidate-scorer.ts +39 -0
- package/src/hooks/index.ts +6 -0
- package/src/index.ts +2 -0
- package/src/llm/llm-client.ts +1 -0
- package/src/persistence/sqlite-provider.ts +1 -0
- package/src/planning/github-projection.ts +48 -44
- package/src/planning/plan-lifecycle.ts +14 -1
- package/src/queue/pipeline-runner.ts +4 -0
- package/src/runtime/curator-extra-ops.test.ts +7 -0
- package/src/runtime/curator-extra-ops.ts +10 -1
- package/src/runtime/facades/curator-facade.test.ts +7 -0
- package/src/runtime/facades/memory-facade.ts +187 -0
- package/src/runtime/orchestrate-ops.ts +3 -3
- package/src/runtime/runtime.test.ts +50 -2
- package/src/runtime/runtime.ts +117 -89
- package/src/runtime/shutdown-registry.test.ts +151 -0
- package/src/runtime/shutdown-registry.ts +85 -0
- package/src/runtime/types.ts +4 -1
- package/src/transport/http-server.ts +50 -3
- package/src/transport/ws-server.ts +8 -0
- package/src/vault/linking.test.ts +12 -0
- package/src/vault/linking.ts +90 -44
- package/src/vault/vault-maintenance.ts +11 -18
- package/src/vault/vault-memories.ts +21 -13
- package/src/vault/vault-schema.ts +21 -0
- package/src/vault/vault.ts +8 -3
package/src/curator/curator.ts
CHANGED
|
@@ -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
|
|
155
|
-
// Filter out dismissed pairs
|
|
158
|
+
const effectiveThreshold = threshold ?? DEFAULT_DUPLICATE_THRESHOLD;
|
|
156
159
|
const dismissed = this.getDismissedPairs();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
264
|
-
groomedCount:
|
|
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
|
-
|
|
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
|
+
}
|
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,
|
package/src/llm/llm-client.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
96
|
+
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
|
|
94
97
|
cwd: projectPath,
|
|
95
|
-
encoding: 'utf-8',
|
|
96
98
|
timeout: 5000,
|
|
97
|
-
|
|
98
|
-
})
|
|
99
|
-
return parseGitHubRemote(
|
|
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
|
-
|
|
111
|
-
encoding: 'utf-8',
|
|
112
|
+
await execFileAsync('gh', ['auth', 'status'], {
|
|
112
113
|
timeout: 5000,
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
141
|
+
signal: AbortSignal.timeout(10000),
|
|
142
142
|
},
|
|
143
|
-
)
|
|
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(
|
|
161
|
+
export async function listOpenIssues(
|
|
162
|
+
repo: GitHubRepo,
|
|
163
|
+
limit: number = 100,
|
|
164
|
+
): Promise<GitHubIssue[]> {
|
|
161
165
|
try {
|
|
162
|
-
const
|
|
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
|
-
|
|
182
|
+
signal: AbortSignal.timeout(10000),
|
|
180
183
|
},
|
|
181
|
-
)
|
|
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
|
|
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
|
-
|
|
204
|
+
signal: AbortSignal.timeout(10000),
|
|
202
205
|
},
|
|
203
|
-
)
|
|
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 =
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
403
|
-
encoding: 'utf-8',
|
|
408
|
+
const { stdout } = await execFileAsync('gh', args, {
|
|
404
409
|
timeout: 15000,
|
|
405
|
-
|
|
406
|
-
})
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
/**
|