@soleri/core 7.0.0 → 8.0.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/agency/agency-manager.d.ts +27 -1
- package/dist/agency/agency-manager.d.ts.map +1 -1
- package/dist/agency/agency-manager.js +180 -9
- package/dist/agency/agency-manager.js.map +1 -1
- package/dist/agency/default-rules.d.ts +7 -0
- package/dist/agency/default-rules.d.ts.map +1 -0
- package/dist/agency/default-rules.js +79 -0
- package/dist/agency/default-rules.js.map +1 -0
- package/dist/agency/types.d.ts +48 -0
- package/dist/agency/types.d.ts.map +1 -1
- package/dist/brain/brain.d.ts +17 -2
- package/dist/brain/brain.d.ts.map +1 -1
- package/dist/brain/brain.js +118 -8
- package/dist/brain/brain.js.map +1 -1
- package/dist/brain/knowledge-synthesizer.d.ts +37 -0
- package/dist/brain/knowledge-synthesizer.d.ts.map +1 -0
- package/dist/brain/knowledge-synthesizer.js +161 -0
- package/dist/brain/knowledge-synthesizer.js.map +1 -0
- package/dist/brain/learning-radar.d.ts +96 -0
- package/dist/brain/learning-radar.d.ts.map +1 -0
- package/dist/brain/learning-radar.js +202 -0
- package/dist/brain/learning-radar.js.map +1 -0
- package/dist/brain/types.d.ts +15 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/context/context-engine.d.ts.map +1 -1
- package/dist/context/context-engine.js +82 -17
- package/dist/context/context-engine.js.map +1 -1
- package/dist/context/types.d.ts +5 -0
- package/dist/context/types.d.ts.map +1 -1
- package/dist/control/intent-router.d.ts +12 -1
- package/dist/control/intent-router.d.ts.map +1 -1
- package/dist/control/intent-router.js +68 -0
- package/dist/control/intent-router.js.map +1 -1
- package/dist/control/types.d.ts +17 -0
- package/dist/control/types.d.ts.map +1 -1
- package/dist/curator/classifier.d.ts +18 -0
- package/dist/curator/classifier.d.ts.map +1 -0
- package/dist/curator/classifier.js +61 -0
- package/dist/curator/classifier.js.map +1 -0
- package/dist/curator/quality-gate.d.ts +29 -0
- package/dist/curator/quality-gate.d.ts.map +1 -0
- package/dist/curator/quality-gate.js +88 -0
- package/dist/curator/quality-gate.js.map +1 -0
- package/dist/engine/bin/soleri-engine.js +1 -0
- package/dist/engine/bin/soleri-engine.js.map +1 -1
- package/dist/events/event-bus.d.ts +30 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +51 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/flows/chain-runner.d.ts +46 -0
- package/dist/flows/chain-runner.d.ts.map +1 -0
- package/dist/flows/chain-runner.js +271 -0
- package/dist/flows/chain-runner.js.map +1 -0
- package/dist/flows/chain-types.d.ts +103 -0
- package/dist/flows/chain-types.d.ts.map +1 -0
- package/dist/flows/chain-types.js +23 -0
- package/dist/flows/chain-types.js.map +1 -0
- package/dist/health/doctor-checks.d.ts +15 -0
- package/dist/health/doctor-checks.d.ts.map +1 -0
- package/dist/health/doctor-checks.js +98 -0
- package/dist/health/doctor-checks.js.map +1 -0
- package/dist/intake/text-ingester.d.ts +52 -0
- package/dist/intake/text-ingester.d.ts.map +1 -0
- package/dist/intake/text-ingester.js +181 -0
- package/dist/intake/text-ingester.js.map +1 -0
- package/dist/llm/llm-client.d.ts.map +1 -1
- package/dist/llm/llm-client.js +37 -1
- package/dist/llm/llm-client.js.map +1 -1
- package/dist/llm/oauth-discovery.d.ts +26 -0
- package/dist/llm/oauth-discovery.d.ts.map +1 -0
- package/dist/llm/oauth-discovery.js +149 -0
- package/dist/llm/oauth-discovery.js.map +1 -0
- package/dist/planning/evidence-collector.d.ts +41 -0
- package/dist/planning/evidence-collector.d.ts.map +1 -0
- package/dist/planning/evidence-collector.js +194 -0
- package/dist/planning/evidence-collector.js.map +1 -0
- package/dist/planning/planner.d.ts +4 -0
- package/dist/planning/planner.d.ts.map +1 -1
- package/dist/planning/planner.js +11 -0
- package/dist/planning/planner.js.map +1 -1
- package/dist/queue/job-queue.d.ts +92 -0
- package/dist/queue/job-queue.d.ts.map +1 -0
- package/dist/queue/job-queue.js +180 -0
- package/dist/queue/job-queue.js.map +1 -0
- package/dist/queue/pipeline-runner.d.ts +62 -0
- package/dist/queue/pipeline-runner.d.ts.map +1 -0
- package/dist/queue/pipeline-runner.js +126 -0
- package/dist/queue/pipeline-runner.js.map +1 -0
- package/dist/runtime/admin-setup-ops.d.ts +20 -0
- package/dist/runtime/admin-setup-ops.d.ts.map +1 -0
- package/dist/runtime/admin-setup-ops.js +583 -0
- package/dist/runtime/admin-setup-ops.js.map +1 -0
- package/dist/runtime/chain-ops.d.ts +9 -0
- package/dist/runtime/chain-ops.d.ts.map +1 -0
- package/dist/runtime/chain-ops.js +107 -0
- package/dist/runtime/chain-ops.js.map +1 -0
- package/dist/runtime/claude-md-helpers.d.ts +65 -0
- package/dist/runtime/claude-md-helpers.d.ts.map +1 -0
- package/dist/runtime/claude-md-helpers.js +173 -0
- package/dist/runtime/claude-md-helpers.js.map +1 -0
- package/dist/runtime/curator-extra-ops.d.ts +3 -2
- package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
- package/dist/runtime/curator-extra-ops.js +81 -3
- package/dist/runtime/curator-extra-ops.js.map +1 -1
- package/dist/runtime/facades/admin-facade.d.ts.map +1 -1
- package/dist/runtime/facades/admin-facade.js +4 -0
- package/dist/runtime/facades/admin-facade.js.map +1 -1
- package/dist/runtime/facades/agency-facade.d.ts.map +1 -1
- package/dist/runtime/facades/agency-facade.js +64 -0
- package/dist/runtime/facades/agency-facade.js.map +1 -1
- package/dist/runtime/facades/brain-facade.d.ts.map +1 -1
- package/dist/runtime/facades/brain-facade.js +122 -1
- package/dist/runtime/facades/brain-facade.js.map +1 -1
- package/dist/runtime/facades/control-facade.d.ts.map +1 -1
- package/dist/runtime/facades/control-facade.js +42 -0
- package/dist/runtime/facades/control-facade.js.map +1 -1
- package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
- package/dist/runtime/facades/memory-facade.js +20 -2
- package/dist/runtime/facades/memory-facade.js.map +1 -1
- package/dist/runtime/facades/plan-facade.d.ts.map +1 -1
- package/dist/runtime/facades/plan-facade.js +2 -0
- package/dist/runtime/facades/plan-facade.js.map +1 -1
- package/dist/runtime/facades/vault-facade.d.ts.map +1 -1
- package/dist/runtime/facades/vault-facade.js +25 -5
- package/dist/runtime/facades/vault-facade.js.map +1 -1
- package/dist/runtime/intake-ops.d.ts +7 -5
- package/dist/runtime/intake-ops.d.ts.map +1 -1
- package/dist/runtime/intake-ops.js +98 -5
- package/dist/runtime/intake-ops.js.map +1 -1
- package/dist/runtime/memory-extra-ops.d.ts +6 -3
- package/dist/runtime/memory-extra-ops.d.ts.map +1 -1
- package/dist/runtime/memory-extra-ops.js +292 -4
- package/dist/runtime/memory-extra-ops.js.map +1 -1
- package/dist/runtime/planning-extra-ops.d.ts.map +1 -1
- package/dist/runtime/planning-extra-ops.js +85 -0
- package/dist/runtime/planning-extra-ops.js.map +1 -1
- package/dist/runtime/playbook-ops.js +1 -1
- package/dist/runtime/playbook-ops.js.map +1 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +143 -2
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/session-briefing.d.ts +23 -0
- package/dist/runtime/session-briefing.d.ts.map +1 -0
- package/dist/runtime/session-briefing.js +140 -0
- package/dist/runtime/session-briefing.js.map +1 -0
- package/dist/runtime/types.d.ts +23 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/vault-linking-ops.d.ts.map +1 -1
- package/dist/runtime/vault-linking-ops.js +1 -3
- package/dist/runtime/vault-linking-ops.js.map +1 -1
- package/dist/vault/vault.d.ts +25 -0
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +67 -3
- package/dist/vault/vault.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/admin-setup-ops.test.ts +355 -0
- package/src/__tests__/async-infrastructure.test.ts +307 -0
- package/src/__tests__/cognee-client-gaps.test.ts +6 -2
- package/src/__tests__/cognee-hybrid-search.test.ts +49 -35
- package/src/__tests__/cognee-sync-manager-deep.test.ts +89 -65
- package/src/__tests__/curator-extra-ops.test.ts +6 -2
- package/src/__tests__/curator-pipeline-e2e.test.ts +358 -0
- package/src/__tests__/memory-extra-ops.test.ts +2 -2
- package/src/__tests__/planning-extra-ops.test.ts +2 -2
- package/src/__tests__/second-brain-features.test.ts +583 -0
- package/src/agency/agency-manager.ts +217 -9
- package/src/agency/default-rules.ts +83 -0
- package/src/agency/types.ts +61 -0
- package/src/brain/brain.ts +110 -8
- package/src/brain/knowledge-synthesizer.ts +218 -0
- package/src/brain/learning-radar.ts +340 -0
- package/src/brain/types.ts +16 -0
- package/src/context/context-engine.ts +114 -15
- package/src/context/types.ts +5 -0
- package/src/control/intent-router.ts +107 -0
- package/src/control/types.ts +10 -0
- package/src/curator/classifier.ts +88 -0
- package/src/curator/quality-gate.ts +129 -0
- package/src/engine/bin/soleri-engine.ts +1 -0
- package/src/events/event-bus.ts +58 -0
- package/src/flows/chain-runner.ts +369 -0
- package/src/flows/chain-types.ts +57 -0
- package/src/health/doctor-checks.ts +115 -0
- package/src/intake/text-ingester.ts +234 -0
- package/src/llm/llm-client.ts +38 -1
- package/src/llm/oauth-discovery.ts +169 -0
- package/src/planning/evidence-collector.ts +247 -0
- package/src/planning/planner.ts +11 -0
- package/src/queue/job-queue.ts +281 -0
- package/src/queue/pipeline-runner.ts +149 -0
- package/src/runtime/admin-setup-ops.ts +664 -0
- package/src/runtime/chain-ops.ts +121 -0
- package/src/runtime/claude-md-helpers.ts +236 -0
- package/src/runtime/curator-extra-ops.ts +86 -3
- package/src/runtime/facades/admin-facade.ts +4 -0
- package/src/runtime/facades/agency-facade.ts +68 -0
- package/src/runtime/facades/brain-facade.ts +142 -1
- package/src/runtime/facades/control-facade.ts +45 -0
- package/src/runtime/facades/memory-facade.ts +20 -2
- package/src/runtime/facades/plan-facade.ts +2 -0
- package/src/runtime/facades/vault-facade.ts +28 -5
- package/src/runtime/intake-ops.ts +107 -5
- package/src/runtime/memory-extra-ops.ts +312 -4
- package/src/runtime/planning-extra-ops.ts +94 -0
- package/src/runtime/playbook-ops.ts +1 -1
- package/src/runtime/runtime.ts +138 -2
- package/src/runtime/session-briefing.ts +161 -0
- package/src/runtime/types.ts +23 -0
- package/src/runtime/vault-linking-ops.ts +1 -3
- package/src/vault/vault.ts +79 -4
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Ingester — ingest articles, transcripts, and plain text into the vault.
|
|
3
|
+
*
|
|
4
|
+
* Reuses existing content-classifier (LLM extraction) and dedup-gate (TF-IDF).
|
|
5
|
+
* No new dependencies — fetch() is built-in, HTML stripping is regex-based.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Vault } from '../vault/vault.js';
|
|
9
|
+
import type { LLMClient } from '../llm/llm-client.js';
|
|
10
|
+
import type { IntelligenceEntry } from '../intelligence/types.js';
|
|
11
|
+
import type { ClassifiedItem } from './types.js';
|
|
12
|
+
import { classifyChunk } from './content-classifier.js';
|
|
13
|
+
import { dedupItems } from './dedup-gate.js';
|
|
14
|
+
|
|
15
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface IngestSource {
|
|
18
|
+
type: 'article' | 'transcript' | 'notes' | 'documentation';
|
|
19
|
+
title: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
author?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IngestOptions {
|
|
25
|
+
domain?: string;
|
|
26
|
+
tags?: string[];
|
|
27
|
+
/** Max chars per chunk for LLM classification. Default 4000. */
|
|
28
|
+
chunkSize?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IngestResult {
|
|
32
|
+
source: IngestSource;
|
|
33
|
+
ingested: number;
|
|
34
|
+
duplicates: number;
|
|
35
|
+
entries: Array<{ id: string; title: string; type: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const DEFAULT_CHUNK_SIZE = 4000;
|
|
41
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
42
|
+
|
|
43
|
+
// ─── Class ───────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export class TextIngester {
|
|
46
|
+
private vault: Vault;
|
|
47
|
+
private llm: LLMClient | null;
|
|
48
|
+
|
|
49
|
+
constructor(vault: Vault, llm: LLMClient | null) {
|
|
50
|
+
this.vault = vault;
|
|
51
|
+
this.llm = llm;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ingest a URL — fetch, strip HTML, classify, dedup, store.
|
|
56
|
+
*/
|
|
57
|
+
async ingestUrl(url: string, opts?: IngestOptions): Promise<IngestResult> {
|
|
58
|
+
if (!this.llm) {
|
|
59
|
+
return { source: { type: 'article', title: url }, ingested: 0, duplicates: 0, entries: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let text: string;
|
|
63
|
+
let title = url;
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(url, {
|
|
66
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
67
|
+
headers: { 'User-Agent': 'Soleri/1.0 (knowledge ingestion)' },
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
return { source: { type: 'article', title }, ingested: 0, duplicates: 0, entries: [] };
|
|
71
|
+
}
|
|
72
|
+
const html = await response.text();
|
|
73
|
+
title = extractTitle(html) ?? url;
|
|
74
|
+
text = stripHtml(html);
|
|
75
|
+
} catch {
|
|
76
|
+
return { source: { type: 'article', title }, ingested: 0, duplicates: 0, entries: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (text.length < 50) {
|
|
80
|
+
return { source: { type: 'article', title }, ingested: 0, duplicates: 0, entries: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const source: IngestSource = { type: 'article', title, url };
|
|
84
|
+
return this.ingestText(text, source, opts);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ingest raw text — classify, dedup, store.
|
|
89
|
+
*/
|
|
90
|
+
async ingestText(
|
|
91
|
+
text: string,
|
|
92
|
+
source: IngestSource,
|
|
93
|
+
opts?: IngestOptions,
|
|
94
|
+
): Promise<IngestResult> {
|
|
95
|
+
if (!this.llm) {
|
|
96
|
+
return { source, ingested: 0, duplicates: 0, entries: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const chunkSize = opts?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
100
|
+
const chunks = splitIntoChunks(text, chunkSize);
|
|
101
|
+
const domain = opts?.domain ?? 'general';
|
|
102
|
+
const extraTags = opts?.tags ?? [];
|
|
103
|
+
|
|
104
|
+
// Classify all chunks
|
|
105
|
+
const allItems: ClassifiedItem[] = [];
|
|
106
|
+
for (const chunk of chunks) {
|
|
107
|
+
const items = await classifyChunk(this.llm, chunk, `${source.type}: ${source.title}`);
|
|
108
|
+
allItems.push(...items);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (allItems.length === 0) {
|
|
112
|
+
return { source, ingested: 0, duplicates: 0, entries: [] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Dedup against vault
|
|
116
|
+
const dedupResults = dedupItems(allItems, this.vault);
|
|
117
|
+
const unique = dedupResults.filter((r) => !r.isDuplicate).map((r) => r.item);
|
|
118
|
+
const duplicateCount = dedupResults.filter((r) => r.isDuplicate).length;
|
|
119
|
+
|
|
120
|
+
// Build source attribution for context field
|
|
121
|
+
const attribution = buildAttribution(source);
|
|
122
|
+
|
|
123
|
+
// Store in vault
|
|
124
|
+
const entries: IntelligenceEntry[] = unique.map((item, i) => ({
|
|
125
|
+
id: `ingest-${source.type}-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 6)}`,
|
|
126
|
+
type: mapType(item.type),
|
|
127
|
+
domain,
|
|
128
|
+
title: item.title,
|
|
129
|
+
description: item.description,
|
|
130
|
+
severity: mapSeverity(item.severity),
|
|
131
|
+
tags: [...(item.tags ?? []), ...extraTags, 'ingested', source.type],
|
|
132
|
+
context: attribution,
|
|
133
|
+
origin: 'user' as const,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
if (entries.length > 0) {
|
|
137
|
+
this.vault.seed(entries);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
source,
|
|
142
|
+
ingested: entries.length,
|
|
143
|
+
duplicates: duplicateCount,
|
|
144
|
+
entries: entries.map((e) => ({ id: e.id, title: e.title, type: e.type })),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Ingest multiple items in sequence.
|
|
150
|
+
*/
|
|
151
|
+
async ingestBatch(
|
|
152
|
+
items: Array<{ text: string; source: IngestSource; opts?: IngestOptions }>,
|
|
153
|
+
): Promise<IngestResult[]> {
|
|
154
|
+
const results: IngestResult[] = [];
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
const result = await this.ingestText(item.text, item.source, item.opts);
|
|
157
|
+
results.push(result);
|
|
158
|
+
}
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function stripHtml(html: string): string {
|
|
166
|
+
return (
|
|
167
|
+
html
|
|
168
|
+
// Remove script and style blocks
|
|
169
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
170
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
171
|
+
// Remove nav, header, footer, aside
|
|
172
|
+
.replace(/<(nav|header|footer|aside)[\s\S]*?<\/\1>/gi, '')
|
|
173
|
+
// Remove all HTML tags
|
|
174
|
+
.replace(/<[^>]+>/g, ' ')
|
|
175
|
+
// Decode common entities
|
|
176
|
+
.replace(/&/g, '&')
|
|
177
|
+
.replace(/</g, '<')
|
|
178
|
+
.replace(/>/g, '>')
|
|
179
|
+
.replace(/"/g, '"')
|
|
180
|
+
.replace(/'/g, "'")
|
|
181
|
+
.replace(/ /g, ' ')
|
|
182
|
+
// Collapse whitespace
|
|
183
|
+
.replace(/\s+/g, ' ')
|
|
184
|
+
.trim()
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractTitle(html: string): string | null {
|
|
189
|
+
const match = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
|
190
|
+
if (match) {
|
|
191
|
+
return match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function splitIntoChunks(text: string, chunkSize: number): string[] {
|
|
197
|
+
if (text.length <= chunkSize) return [text];
|
|
198
|
+
|
|
199
|
+
const chunks: string[] = [];
|
|
200
|
+
let start = 0;
|
|
201
|
+
while (start < text.length) {
|
|
202
|
+
let end = start + chunkSize;
|
|
203
|
+
// Try to break at a sentence boundary
|
|
204
|
+
if (end < text.length) {
|
|
205
|
+
const lastPeriod = text.lastIndexOf('. ', end);
|
|
206
|
+
if (lastPeriod > start + chunkSize * 0.5) {
|
|
207
|
+
end = lastPeriod + 2;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
chunks.push(text.slice(start, end).trim());
|
|
211
|
+
start = end;
|
|
212
|
+
}
|
|
213
|
+
return chunks.filter((c) => c.length > 0);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildAttribution(source: IngestSource): string {
|
|
217
|
+
const parts = [`Source: ${source.type}`];
|
|
218
|
+
if (source.title) parts.push(`Title: ${source.title}`);
|
|
219
|
+
if (source.url) parts.push(`URL: ${source.url}`);
|
|
220
|
+
if (source.author) parts.push(`Author: ${source.author}`);
|
|
221
|
+
return parts.join(' | ');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function mapType(type: string): IntelligenceEntry['type'] {
|
|
225
|
+
if (type === 'pattern') return 'pattern';
|
|
226
|
+
if (type === 'anti-pattern') return 'anti-pattern';
|
|
227
|
+
return 'rule';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function mapSeverity(severity: string | undefined): IntelligenceEntry['severity'] {
|
|
231
|
+
if (severity === 'critical') return 'critical';
|
|
232
|
+
if (severity === 'warning') return 'warning';
|
|
233
|
+
return 'suggestion';
|
|
234
|
+
}
|
package/src/llm/llm-client.ts
CHANGED
|
@@ -25,8 +25,45 @@ const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
|
|
25
25
|
// =============================================================================
|
|
26
26
|
|
|
27
27
|
function loadRoutingConfig(agentId: string): RoutingConfig {
|
|
28
|
+
// Default task→model routing: cheap models for routine, powerful for reasoning.
|
|
29
|
+
// Anthropic routes use extended thinking for quality decisions when available.
|
|
30
|
+
// Agents can override via ~/.{agentId}/model-routing.json.
|
|
31
|
+
const defaultRoutes: RouteEntry[] = [
|
|
32
|
+
// OpenAI routes (default — works without Anthropic key)
|
|
33
|
+
{ caller: 'quality-gate', task: 'evaluate', model: 'gpt-4o', provider: 'openai' },
|
|
34
|
+
{ caller: 'classifier', task: 'classify', model: 'gpt-4o-mini', provider: 'openai' },
|
|
35
|
+
{ caller: 'knowledge-synthesizer', task: 'synthesize', model: 'gpt-4o', provider: 'openai' },
|
|
36
|
+
{ caller: 'content-classifier', model: 'gpt-4o-mini', provider: 'openai' },
|
|
37
|
+
{ caller: 'vault-linking', task: 'evaluate-links', model: 'gpt-4o-mini', provider: 'openai' },
|
|
38
|
+
// Anthropic routes (higher quality when key available — extended thinking capable)
|
|
39
|
+
{
|
|
40
|
+
caller: 'quality-gate-anthropic',
|
|
41
|
+
task: 'evaluate',
|
|
42
|
+
model: 'claude-sonnet-4-20250514',
|
|
43
|
+
provider: 'anthropic',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
caller: 'contradiction-evaluator',
|
|
47
|
+
task: 'evaluate',
|
|
48
|
+
model: 'claude-sonnet-4-20250514',
|
|
49
|
+
provider: 'anthropic',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
caller: 'knowledge-synthesizer-anthropic',
|
|
53
|
+
task: 'synthesize',
|
|
54
|
+
model: 'claude-sonnet-4-20250514',
|
|
55
|
+
provider: 'anthropic',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
caller: 'classifier-anthropic',
|
|
59
|
+
task: 'classify',
|
|
60
|
+
model: 'claude-haiku-4-5-20251001',
|
|
61
|
+
provider: 'anthropic',
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
28
65
|
const defaultConfig: RoutingConfig = {
|
|
29
|
-
routes:
|
|
66
|
+
routes: defaultRoutes,
|
|
30
67
|
defaultOpenAIModel: 'gpt-4o-mini',
|
|
31
68
|
defaultAnthropicModel: 'claude-sonnet-4-20250514',
|
|
32
69
|
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Discovery — find Claude Code OAuth tokens on macOS and Linux.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. ANTHROPIC_API_KEY env var (explicit, highest priority)
|
|
6
|
+
* 2. Claude Code credentials file (~/.claude/.credentials.json or similar)
|
|
7
|
+
* 3. macOS Keychain (security find-generic-password)
|
|
8
|
+
* 4. Linux GNOME Keyring (secret-tool lookup)
|
|
9
|
+
* 5. null (graceful fallback → use OpenAI or no LLM)
|
|
10
|
+
*
|
|
11
|
+
* Cached for 5 minutes to avoid repeated I/O.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir, platform } from 'node:os';
|
|
18
|
+
|
|
19
|
+
// ─── Cache ───────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
let cachedToken: string | null = null;
|
|
22
|
+
let cacheTimestamp = 0;
|
|
23
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
// ─── Public API ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Discover an Anthropic API token. Returns null if none found.
|
|
29
|
+
* Results cached for 5 minutes.
|
|
30
|
+
*/
|
|
31
|
+
export function discoverAnthropicToken(): string | null {
|
|
32
|
+
if (cachedToken && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
|
33
|
+
return cachedToken;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const token = tryEnvVar() ?? tryCredentialsFile() ?? tryPlatformKeychain();
|
|
37
|
+
|
|
38
|
+
if (token) {
|
|
39
|
+
cachedToken = token;
|
|
40
|
+
cacheTimestamp = Date.now();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return token;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clear the cached token (for testing or rotation).
|
|
48
|
+
*/
|
|
49
|
+
export function resetTokenCache(): void {
|
|
50
|
+
cachedToken = null;
|
|
51
|
+
cacheTimestamp = 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get discovery source info (for diagnostics).
|
|
56
|
+
*/
|
|
57
|
+
export function getTokenSource(): string {
|
|
58
|
+
if (process.env.ANTHROPIC_API_KEY) return 'env:ANTHROPIC_API_KEY';
|
|
59
|
+
if (tryCredentialsFile()) return 'file:credentials';
|
|
60
|
+
if (tryPlatformKeychain()) return `keychain:${platform()}`;
|
|
61
|
+
return 'none';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Discovery Methods ───────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function tryEnvVar(): string | null {
|
|
67
|
+
return process.env.ANTHROPIC_API_KEY ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function tryCredentialsFile(): string | null {
|
|
71
|
+
const candidates = [
|
|
72
|
+
join(homedir(), '.claude', '.credentials.json'),
|
|
73
|
+
join(homedir(), '.claude', 'credentials.json'),
|
|
74
|
+
join(homedir(), '.config', 'claude', 'credentials.json'),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
for (const path of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
if (!existsSync(path)) continue;
|
|
80
|
+
const raw = readFileSync(path, 'utf-8');
|
|
81
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
82
|
+
|
|
83
|
+
// Claude Code OAuth format: { claudeAiOauth: { accessToken: "..." } }
|
|
84
|
+
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
|
|
85
|
+
if (oauth?.accessToken && typeof oauth.accessToken === 'string') {
|
|
86
|
+
return oauth.accessToken;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Alternative: direct token field
|
|
90
|
+
if (parsed.accessToken && typeof parsed.accessToken === 'string') {
|
|
91
|
+
return parsed.accessToken as string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Alternative: API key field
|
|
95
|
+
if (parsed.apiKey && typeof parsed.apiKey === 'string') {
|
|
96
|
+
return parsed.apiKey as string;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tryPlatformKeychain(): string | null {
|
|
107
|
+
const os = platform();
|
|
108
|
+
|
|
109
|
+
if (os === 'darwin') return tryMacKeychain();
|
|
110
|
+
if (os === 'linux') return tryLinuxKeyring();
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function tryMacKeychain(): string | null {
|
|
116
|
+
try {
|
|
117
|
+
const raw = execFileSync(
|
|
118
|
+
'security',
|
|
119
|
+
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
|
120
|
+
{ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
121
|
+
).trim();
|
|
122
|
+
|
|
123
|
+
if (!raw) return null;
|
|
124
|
+
|
|
125
|
+
// Try JSON parse
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
128
|
+
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
|
|
129
|
+
if (oauth?.accessToken && typeof oauth.accessToken === 'string') {
|
|
130
|
+
return oauth.accessToken;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// JSON might be truncated — try regex fallback
|
|
134
|
+
const match = raw.match(/"accessToken"\s*:\s*"([^"]+)"/);
|
|
135
|
+
if (match) return match[1];
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Keychain not available or no entry
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function tryLinuxKeyring(): string | null {
|
|
145
|
+
try {
|
|
146
|
+
// GNOME Keyring via secret-tool
|
|
147
|
+
const token = execFileSync(
|
|
148
|
+
'secret-tool',
|
|
149
|
+
['lookup', 'service', 'Claude Code', 'type', 'credentials'],
|
|
150
|
+
{ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
151
|
+
).trim();
|
|
152
|
+
|
|
153
|
+
if (token) {
|
|
154
|
+
// May be JSON or raw token
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(token) as Record<string, unknown>;
|
|
157
|
+
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
|
|
158
|
+
if (oauth?.accessToken) return oauth.accessToken as string;
|
|
159
|
+
} catch {
|
|
160
|
+
// Treat as raw token
|
|
161
|
+
if (token.length > 20) return token;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// secret-tool not available or no entry
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence Collector — cross-references plan tasks against git reality.
|
|
3
|
+
*
|
|
4
|
+
* Runs `git diff` to find what actually changed, then matches file changes
|
|
5
|
+
* against planned tasks to produce an evidence-based drift report.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import type { Plan, PlanTask } from './planner.js';
|
|
10
|
+
|
|
11
|
+
export interface FileChange {
|
|
12
|
+
path: string;
|
|
13
|
+
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GitTaskEvidence {
|
|
17
|
+
taskId: string;
|
|
18
|
+
taskTitle: string;
|
|
19
|
+
plannedStatus: string;
|
|
20
|
+
matchedFiles: FileChange[];
|
|
21
|
+
verdict: 'DONE' | 'PARTIAL' | 'MISSING' | 'SKIPPED';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UnplannedChange {
|
|
25
|
+
file: FileChange;
|
|
26
|
+
possibleReason: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface EvidenceReport {
|
|
30
|
+
planId: string;
|
|
31
|
+
planObjective: string;
|
|
32
|
+
accuracy: number;
|
|
33
|
+
evidenceSources: string[];
|
|
34
|
+
taskEvidence: GitTaskEvidence[];
|
|
35
|
+
unplannedChanges: UnplannedChange[];
|
|
36
|
+
missingWork: GitTaskEvidence[];
|
|
37
|
+
summary: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Collect git diff evidence for a plan.
|
|
42
|
+
*
|
|
43
|
+
* @param plan - The plan to verify
|
|
44
|
+
* @param projectPath - Project root (must be a git repo)
|
|
45
|
+
* @param baseBranch - Compare against this branch (default: 'main')
|
|
46
|
+
*/
|
|
47
|
+
export function collectGitEvidence(
|
|
48
|
+
plan: Plan,
|
|
49
|
+
projectPath: string,
|
|
50
|
+
baseBranch: string = 'main',
|
|
51
|
+
): EvidenceReport {
|
|
52
|
+
const fileChanges = getGitDiff(projectPath, baseBranch);
|
|
53
|
+
const taskEvidence: GitTaskEvidence[] = [];
|
|
54
|
+
const matchedFiles = new Set<string>();
|
|
55
|
+
|
|
56
|
+
for (const task of plan.tasks) {
|
|
57
|
+
const matches = findMatchingFiles(task, fileChanges);
|
|
58
|
+
for (const m of matches) matchedFiles.add(m.path);
|
|
59
|
+
|
|
60
|
+
const verdict = determineVerdict(task, matches);
|
|
61
|
+
taskEvidence.push({
|
|
62
|
+
taskId: task.id,
|
|
63
|
+
taskTitle: task.title,
|
|
64
|
+
plannedStatus: task.status,
|
|
65
|
+
matchedFiles: matches,
|
|
66
|
+
verdict,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const unplannedChanges: UnplannedChange[] = fileChanges
|
|
71
|
+
.filter((f) => !matchedFiles.has(f.path))
|
|
72
|
+
.map((f) => ({
|
|
73
|
+
file: f,
|
|
74
|
+
possibleReason: inferReason(f),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const missingWork = taskEvidence.filter((te) => te.verdict === 'MISSING');
|
|
78
|
+
|
|
79
|
+
const totalTasks = taskEvidence.length;
|
|
80
|
+
const doneTasks = taskEvidence.filter((te) => te.verdict === 'DONE').length;
|
|
81
|
+
const partialTasks = taskEvidence.filter((te) => te.verdict === 'PARTIAL').length;
|
|
82
|
+
const skippedTasks = taskEvidence.filter((te) => te.verdict === 'SKIPPED').length;
|
|
83
|
+
const accuracy =
|
|
84
|
+
totalTasks > 0
|
|
85
|
+
? Math.round(((doneTasks + partialTasks * 0.5 + skippedTasks * 0.25) / totalTasks) * 100)
|
|
86
|
+
: 100;
|
|
87
|
+
|
|
88
|
+
const summary = buildSummary(
|
|
89
|
+
totalTasks,
|
|
90
|
+
doneTasks,
|
|
91
|
+
partialTasks,
|
|
92
|
+
missingWork.length,
|
|
93
|
+
unplannedChanges.length,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
planId: plan.id,
|
|
98
|
+
planObjective: plan.objective,
|
|
99
|
+
accuracy,
|
|
100
|
+
evidenceSources: ['git'],
|
|
101
|
+
taskEvidence,
|
|
102
|
+
unplannedChanges,
|
|
103
|
+
missingWork,
|
|
104
|
+
summary,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getGitDiff(projectPath: string, baseBranch: string): FileChange[] {
|
|
109
|
+
try {
|
|
110
|
+
const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
111
|
+
cwd: projectPath,
|
|
112
|
+
encoding: 'utf-8',
|
|
113
|
+
timeout: 5000,
|
|
114
|
+
}).trim();
|
|
115
|
+
|
|
116
|
+
const diffTarget = currentBranch === baseBranch ? 'HEAD~10' : baseBranch;
|
|
117
|
+
|
|
118
|
+
let output: string;
|
|
119
|
+
try {
|
|
120
|
+
output = execFileSync('git', ['diff', '--name-status', `${diffTarget}...HEAD`], {
|
|
121
|
+
cwd: projectPath,
|
|
122
|
+
encoding: 'utf-8',
|
|
123
|
+
timeout: 10000,
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
output = execFileSync('git', ['diff', '--name-status', 'HEAD~5'], {
|
|
127
|
+
cwd: projectPath,
|
|
128
|
+
encoding: 'utf-8',
|
|
129
|
+
timeout: 10000,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return output
|
|
134
|
+
.trim()
|
|
135
|
+
.split('\n')
|
|
136
|
+
.filter((line) => line.length > 0)
|
|
137
|
+
.map(parseGitDiffLine)
|
|
138
|
+
.filter((f): f is FileChange => f !== null);
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseGitDiffLine(line: string): FileChange | null {
|
|
145
|
+
const match = line.match(/^([AMDRC])\d*\t(.+?)(?:\t(.+))?$/);
|
|
146
|
+
if (!match) return null;
|
|
147
|
+
|
|
148
|
+
const statusChar = match[1];
|
|
149
|
+
const path = match[3] ?? match[2];
|
|
150
|
+
|
|
151
|
+
const statusMap: Record<string, FileChange['status']> = {
|
|
152
|
+
A: 'added',
|
|
153
|
+
M: 'modified',
|
|
154
|
+
D: 'deleted',
|
|
155
|
+
R: 'renamed',
|
|
156
|
+
C: 'added',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return { path, status: statusMap[statusChar] ?? 'modified' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function findMatchingFiles(task: PlanTask, files: FileChange[]): FileChange[] {
|
|
163
|
+
const keywords = extractKeywords(task.title + ' ' + task.description);
|
|
164
|
+
if (keywords.length === 0) return [];
|
|
165
|
+
|
|
166
|
+
return files.filter((f) => {
|
|
167
|
+
const pathLower = f.path.toLowerCase();
|
|
168
|
+
return keywords.some((kw) => pathLower.includes(kw));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractKeywords(text: string): string[] {
|
|
173
|
+
const stopWords = new Set([
|
|
174
|
+
'the',
|
|
175
|
+
'and',
|
|
176
|
+
'for',
|
|
177
|
+
'with',
|
|
178
|
+
'that',
|
|
179
|
+
'this',
|
|
180
|
+
'from',
|
|
181
|
+
'into',
|
|
182
|
+
'add',
|
|
183
|
+
'create',
|
|
184
|
+
'implement',
|
|
185
|
+
'update',
|
|
186
|
+
'fix',
|
|
187
|
+
'remove',
|
|
188
|
+
'delete',
|
|
189
|
+
'new',
|
|
190
|
+
'use',
|
|
191
|
+
'should',
|
|
192
|
+
'must',
|
|
193
|
+
'will',
|
|
194
|
+
'can',
|
|
195
|
+
'all',
|
|
196
|
+
'each',
|
|
197
|
+
'when',
|
|
198
|
+
'not',
|
|
199
|
+
'are',
|
|
200
|
+
'has',
|
|
201
|
+
'have',
|
|
202
|
+
'been',
|
|
203
|
+
'was',
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
const words = text
|
|
207
|
+
.toLowerCase()
|
|
208
|
+
.replace(/[^a-z0-9\s\-_/.]/g, ' ')
|
|
209
|
+
.split(/[\s\-_/]+/)
|
|
210
|
+
.filter((w) => w.length >= 3 && !stopWords.has(w));
|
|
211
|
+
|
|
212
|
+
return [...new Set(words)];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function determineVerdict(task: PlanTask, matches: FileChange[]): GitTaskEvidence['verdict'] {
|
|
216
|
+
if (task.status === 'skipped') return 'SKIPPED';
|
|
217
|
+
if (matches.length === 0) return 'MISSING';
|
|
218
|
+
if (task.status === 'completed') return 'DONE';
|
|
219
|
+
if (matches.length > 0) return 'PARTIAL';
|
|
220
|
+
return 'MISSING';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function inferReason(file: FileChange): string {
|
|
224
|
+
const path = file.path.toLowerCase();
|
|
225
|
+
if (path.includes('index.') || path.includes('barrel')) return 'likely re-export update';
|
|
226
|
+
if (path.includes('config') || path.includes('.env')) return 'configuration change';
|
|
227
|
+
if (path.includes('test') || path.includes('spec')) return 'test file';
|
|
228
|
+
if (path.includes('package.json') || path.includes('lock')) return 'dependency update';
|
|
229
|
+
if (path.includes('readme') || path.includes('.md')) return 'documentation';
|
|
230
|
+
if (path.includes('types') || path.includes('.d.ts')) return 'type definition update';
|
|
231
|
+
return 'unplanned scope';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildSummary(
|
|
235
|
+
total: number,
|
|
236
|
+
done: number,
|
|
237
|
+
partial: number,
|
|
238
|
+
missing: number,
|
|
239
|
+
unplanned: number,
|
|
240
|
+
): string {
|
|
241
|
+
const parts: string[] = [];
|
|
242
|
+
parts.push(`${done}/${total} tasks verified by git evidence`);
|
|
243
|
+
if (partial > 0) parts.push(`${partial} partially done`);
|
|
244
|
+
if (missing > 0) parts.push(`${missing} with no file evidence`);
|
|
245
|
+
if (unplanned > 0) parts.push(`${unplanned} unplanned file changes`);
|
|
246
|
+
return parts.join(', ');
|
|
247
|
+
}
|