@dzhechkov/harness-core 0.3.78 → 0.3.82
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/agentdb-index.d.ts +35 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +67 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/patterns.d.ts +32 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +0 -0
- package/dist/patterns.js.map +1 -1
- package/dist/skill-drift.d.ts +99 -0
- package/dist/skill-drift.d.ts.map +1 -0
- package/dist/skill-drift.js +210 -0
- package/dist/skill-drift.js.map +1 -0
- package/dist/vector-tier.d.ts +139 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +400 -3
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/src/agentdb-index.ts +100 -2
- package/src/index.ts +18 -4
- package/src/patterns.ts +0 -0
- package/src/skill-drift.ts +273 -0
- package/src/vector-tier.ts +503 -2
package/src/agentdb-index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* @packageDocumentation
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
15
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
16
16
|
import { join, dirname } from 'node:path';
|
|
17
17
|
import { pathToFileURL } from 'node:url';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
@@ -218,7 +218,12 @@ function hasDzTables(db: ReadonlyDb): boolean {
|
|
|
218
218
|
return t('reasoning_patterns') && t('pattern_embeddings');
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Cosine similarity in [-1, 1] over two embeddings. Exported (was file-private) so
|
|
223
|
+
* `harmonizeVectorStore` scores near-duplicate pairs with the IDENTICAL math the semantic search
|
|
224
|
+
* path uses — one cosine implementation, no drift between search and harmonize.
|
|
225
|
+
*/
|
|
226
|
+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
222
227
|
const n = Math.min(a.length, b.length);
|
|
223
228
|
let dot = 0;
|
|
224
229
|
let na = 0;
|
|
@@ -328,3 +333,96 @@ export async function listAgentdbDzIds(
|
|
|
328
333
|
db.close();
|
|
329
334
|
}
|
|
330
335
|
}
|
|
336
|
+
|
|
337
|
+
/* ------------------------------------------------------------------ */
|
|
338
|
+
/* IMPORT half (dz-vector-harmonize-import M0.4): upsert-by-dzId */
|
|
339
|
+
/* ------------------------------------------------------------------ */
|
|
340
|
+
|
|
341
|
+
/** One precomputed vector to upsert by its content-addressed `dzId`. */
|
|
342
|
+
export interface AgentdbImportRow {
|
|
343
|
+
/** Join key — the canonical `MemoryRecord.id`; the upsert key. */
|
|
344
|
+
readonly dzId: string;
|
|
345
|
+
/** The embedding to store VERBATIM (the checkpoint's space, preserved). */
|
|
346
|
+
readonly vector: Float32Array;
|
|
347
|
+
/** Pattern text (`approach`) — used only when INSERTing a dzId not yet present. */
|
|
348
|
+
readonly text: string;
|
|
349
|
+
readonly taskType: string;
|
|
350
|
+
/** Stored as `success_rate` on insert; clamped to [0,1]. */
|
|
351
|
+
readonly score: number;
|
|
352
|
+
readonly metadata?: Record<string, unknown>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Minimal better-sqlite3 surface the upsert path uses (run + get + transaction). */
|
|
356
|
+
interface UpsertDb {
|
|
357
|
+
pragma: (s: string) => void;
|
|
358
|
+
exec: (s: string) => void;
|
|
359
|
+
prepare: (q: string) => {
|
|
360
|
+
run: (...a: unknown[]) => { lastInsertRowid: number | bigint };
|
|
361
|
+
get: (...a: unknown[]) => unknown;
|
|
362
|
+
};
|
|
363
|
+
transaction: <T>(fn: () => T) => () => T;
|
|
364
|
+
close: () => void;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* UPSERT precomputed vectors into the shared AgentDB store, keyed on `metadata.dzId` — the write
|
|
369
|
+
* half of `dz vector import`. For each row: look up the existing `reasoning_patterns` row for the
|
|
370
|
+
* dzId; if found, REPLACE its `pattern_embeddings` BLOB in place (never a new row); if absent,
|
|
371
|
+
* INSERT both the pattern row (`approach = text`, `metadata.dzId`) and its embedding. The vector is
|
|
372
|
+
* stored VERBATIM. NON-DESTRUCTIVE: only the imported dzIds are inserted/replaced — every other
|
|
373
|
+
* dzId's vector and pattern are left untouched (no blind table overwrite). Idempotent — re-importing
|
|
374
|
+
* the same dzIds REPLACEs in place, adding 0 rows. Same dynamic `better-sqlite3` resolve + WAL +
|
|
375
|
+
* `busy_timeout 5000` as {@link indexPatternsToAgentdb}. Best-effort: honest `{ error }`, never a throw.
|
|
376
|
+
*/
|
|
377
|
+
export async function importVectorsToAgentdb(
|
|
378
|
+
projectRoot: string,
|
|
379
|
+
rows: readonly AgentdbImportRow[],
|
|
380
|
+
opts: { dbPath?: string } = {},
|
|
381
|
+
): Promise<{ imported: number; error?: string }> {
|
|
382
|
+
if (rows.length === 0) return { imported: 0 };
|
|
383
|
+
let sqliteUrl: string;
|
|
384
|
+
try {
|
|
385
|
+
const req = createRequire(join(projectRoot, 'package.json'));
|
|
386
|
+
sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
|
|
387
|
+
} catch {
|
|
388
|
+
return { imported: 0, error: DEPS_MISSING };
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb };
|
|
392
|
+
const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
|
|
393
|
+
mkdirSync(dirname(dbFile), { recursive: true }); // better-sqlite3 won't create the parent dir
|
|
394
|
+
const db = new Database(dbFile);
|
|
395
|
+
try {
|
|
396
|
+
db.pragma('journal_mode = WAL');
|
|
397
|
+
db.pragma('busy_timeout = 5000'); // wait out a brief MCP-server write lock instead of failing
|
|
398
|
+
db.exec(REASONING_BANK_SCHEMA);
|
|
399
|
+
const findByDzId = db.prepare("SELECT id FROM reasoning_patterns WHERE json_extract(metadata, '$.dzId') = ?");
|
|
400
|
+
const insPattern = db.prepare('INSERT INTO reasoning_patterns (task_type, approach, success_rate, uses, avg_reward, tags, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
401
|
+
const upsertEmb = db.prepare('INSERT OR REPLACE INTO pattern_embeddings (pattern_id, embedding) VALUES (?, ?)');
|
|
402
|
+
const commit = db.transaction(() => {
|
|
403
|
+
let imported = 0;
|
|
404
|
+
for (const row of rows) {
|
|
405
|
+
const buf = Buffer.from(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength);
|
|
406
|
+
const existing = findByDzId.get(row.dzId) as { id: number } | undefined;
|
|
407
|
+
if (existing !== undefined) {
|
|
408
|
+
upsertEmb.run(existing.id, buf); // REPLACE the embedding in place — never a duplicate row
|
|
409
|
+
} else {
|
|
410
|
+
const meta = { ...(row.metadata ?? {}), dzId: row.dzId };
|
|
411
|
+
const r = insPattern.run(
|
|
412
|
+
row.taskType, row.text, Math.max(0, Math.min(1, Number.isFinite(row.score) ? row.score : 0)), 0, 0.0,
|
|
413
|
+
null, JSON.stringify(meta),
|
|
414
|
+
);
|
|
415
|
+
upsertEmb.run(Number(r.lastInsertRowid), buf);
|
|
416
|
+
}
|
|
417
|
+
imported += 1;
|
|
418
|
+
}
|
|
419
|
+
return imported;
|
|
420
|
+
});
|
|
421
|
+
return { imported: commit() };
|
|
422
|
+
} finally {
|
|
423
|
+
db.close();
|
|
424
|
+
}
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return { imported: 0, error: `import failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
427
|
+
}
|
|
428
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,20 +21,24 @@ export { createSkill } from './create-skill.js';
|
|
|
21
21
|
export type { CreateSkillOptions, CreateSkillResult } from './create-skill.js';
|
|
22
22
|
export { checkUpstream, checkAllUpstream, discoverSourcePackages, loadSourcesManifest } from './sync-upstream.js';
|
|
23
23
|
export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePackageInfo } from './sync-upstream.js';
|
|
24
|
+
export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
|
|
25
|
+
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
|
|
24
26
|
export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
|
|
25
27
|
export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs } from './registry.js';
|
|
26
28
|
export { recommend } from './recommend.js';
|
|
27
29
|
export { pretrain } from './pretrain.js';
|
|
28
|
-
export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, BOOST_CAP, recordPattern, loadStorePatternsSync, loadStoreRecords, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns } from './patterns.js';
|
|
29
|
-
export type { PatternRecord, SessionRecord, LearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource, PruneNoiseResult } from './patterns.js';
|
|
30
|
+
export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, BOOST_CAP, recordPattern, loadStorePatternsSync, loadStoreRecords, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore } from './patterns.js';
|
|
31
|
+
export type { PatternRecord, SessionRecord, LearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult } from './patterns.js';
|
|
30
32
|
export {
|
|
31
33
|
DEFAULT_VECTOR_TIMEOUT_MS,
|
|
34
|
+
DEFAULT_HARMONIZE_THRESHOLD,
|
|
32
35
|
withVectorTimeout,
|
|
33
36
|
isVectorNoise,
|
|
34
37
|
patternVectorEntry,
|
|
35
38
|
dreamVectorEntry,
|
|
36
39
|
memoryRecordVectorEntry,
|
|
37
40
|
readVectorEngineMode,
|
|
41
|
+
readHarmonizeThreshold,
|
|
38
42
|
vectorMirrorEnabled,
|
|
39
43
|
resolveVectorEngine,
|
|
40
44
|
mirrorEntriesToVector,
|
|
@@ -43,6 +47,9 @@ export {
|
|
|
43
47
|
mergeHybridHits,
|
|
44
48
|
recallHybrid,
|
|
45
49
|
vectorTierStatus,
|
|
50
|
+
harmonizeVectorStore,
|
|
51
|
+
selectClusterKeeper,
|
|
52
|
+
importRvfCheckpoint,
|
|
46
53
|
} from './vector-tier.js';
|
|
47
54
|
export type {
|
|
48
55
|
VectorEngine,
|
|
@@ -50,6 +57,7 @@ export type {
|
|
|
50
57
|
VectorEngineMode,
|
|
51
58
|
VectorEntry,
|
|
52
59
|
VectorHit,
|
|
60
|
+
ImportVectorRow,
|
|
53
61
|
MirrorReceipt,
|
|
54
62
|
ResolvedVectorEngine,
|
|
55
63
|
HybridRecall,
|
|
@@ -58,12 +66,18 @@ export type {
|
|
|
58
66
|
RankedPattern,
|
|
59
67
|
VectorServiceOptions,
|
|
60
68
|
VectorTierStatus,
|
|
69
|
+
HarmonizeItem,
|
|
70
|
+
HarmonizeCluster,
|
|
71
|
+
HarmonizeReport,
|
|
72
|
+
HarmonizeOptions,
|
|
73
|
+
ImportReport,
|
|
74
|
+
ImportOptions,
|
|
61
75
|
} from './vector-tier.js';
|
|
62
76
|
export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
|
|
63
77
|
export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStatePath } from './statusline.js';
|
|
64
78
|
export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
|
|
65
|
-
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder } from './agentdb-index.js';
|
|
66
|
-
export type { AgentdbSearchHit, AgentdbSearchResult } from './agentdb-index.js';
|
|
79
|
+
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb } from './agentdb-index.js';
|
|
80
|
+
export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
|
|
67
81
|
export { putBookKnowledge, queryBookKnowledge, bookKbPath } from './book-kb.js';
|
|
68
82
|
export type { BookKU, BookKUHit } from './book-kb.js';
|
|
69
83
|
export {
|
package/src/patterns.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intra-monorepo skill-drift guard.
|
|
3
|
+
*
|
|
4
|
+
* The same skill is physically duplicated across many monorepo packages
|
|
5
|
+
* (`packages/@dzhechkov/*/<skill>/` + `.claude/skills/<skill>/`). A fix applied to ONE copy
|
|
6
|
+
* silently leaves the others broken — this is exactly how a CRITICAL `goap-research-ed25519`
|
|
7
|
+
* self-signed-forgery exploit shipped in 10 of 12 copies, and how a `brutal-honesty-review`
|
|
8
|
+
* `set -e` crash reached the PUBLISHED `@dzhechkov/skills-qe`. Both were found only by accident.
|
|
9
|
+
*
|
|
10
|
+
* `dz sync-upstream` only checks against EXTERNAL repos and is structurally blind to this class of
|
|
11
|
+
* drift. This module is the intra-monorepo complement:
|
|
12
|
+
*
|
|
13
|
+
* • `sweepSkillDrift(root)` — detector: which shared skills byte-differ between copies.
|
|
14
|
+
* • `syncCanonicalSkill(root, s)` — healer: overwrite every copy from `skills-meta/<skill>`.
|
|
15
|
+
*
|
|
16
|
+
* Both are PURE functions that return plain data — no printing, no `process.exit`, no throwing on
|
|
17
|
+
* the "canonical missing" / "drift found" business cases. The CLI layer owns exit codes and I/O.
|
|
18
|
+
* Dependency-free: `node:fs` / `node:path` / `node:crypto` only.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
24
|
+
import { join, relative, dirname, basename } from 'node:path';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
/** One shared skill whose copies byte-differ (`driftFiles > 0`). */
|
|
28
|
+
export interface DriftedSkill {
|
|
29
|
+
/** Skill dir basename (e.g. `goap-research-ed25519`). */
|
|
30
|
+
readonly name: string;
|
|
31
|
+
/** How many locations hold this skill. */
|
|
32
|
+
readonly copies: number;
|
|
33
|
+
/** # relative paths where the copies disagree (≥1 ⇒ drift). */
|
|
34
|
+
readonly driftFiles: number;
|
|
35
|
+
/** Size of the union of relative file paths across all copies. */
|
|
36
|
+
readonly totalFiles: number;
|
|
37
|
+
/** # (copy × relative-path) pairs where the file is absent from a copy. */
|
|
38
|
+
readonly missingFiles: number;
|
|
39
|
+
/** Absolute paths of every copy — lets a human / `--json` consumer jump to the drifted dirs. */
|
|
40
|
+
readonly locations: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Options for {@link sweepSkillDrift}. */
|
|
44
|
+
export interface SweepOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Which copies to compare.
|
|
47
|
+
* - `'packages'` (the CI-gate default): PUBLISHED package copies only (`packages/`). The dogfood
|
|
48
|
+
* `.claude/skills/<skill>` dev copies are excluded — the repo's own `dz sync` test treats them as
|
|
49
|
+
* "legitimately lagging" the published version, so counting them makes the gate red-on-arrival.
|
|
50
|
+
* The dangerous drift (goap, brutal-honesty) was always between PUBLISHED packages.
|
|
51
|
+
* - `'all'`: packages + `.claude/skills` (the raw sweep the audit script does).
|
|
52
|
+
*/
|
|
53
|
+
readonly scope?: 'packages' | 'all';
|
|
54
|
+
/** Skill basenames whose drift is ACCEPTED (documented intentional forks) — reported separately, never counted as gate drift. */
|
|
55
|
+
readonly allowlist?: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of a read-only intra-monorepo drift sweep. */
|
|
59
|
+
export interface SweepResult {
|
|
60
|
+
/** # skills present in ≥2 locations (within scope). */
|
|
61
|
+
readonly duplicated: number;
|
|
62
|
+
/** Skills that byte-differ AND are not allowlisted, sorted by `driftFiles` desc — the gate keys on this. */
|
|
63
|
+
readonly drifted: readonly DriftedSkill[];
|
|
64
|
+
/** Skills that byte-differ but are allowlisted (intentional) — surfaced for transparency, not gated. */
|
|
65
|
+
readonly allowlisted: readonly DriftedSkill[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options for {@link syncCanonicalSkill}. */
|
|
69
|
+
export interface SyncCanonicalOptions {
|
|
70
|
+
/** Report drift only, write NOTHING (CI mode). */
|
|
71
|
+
readonly check?: boolean;
|
|
72
|
+
/** Override the canonical source dir (default: `skills-meta/<skill>`). */
|
|
73
|
+
readonly from?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Result of a canonical-wins sync (or a `check:true` dry-run). */
|
|
77
|
+
export interface SyncResult {
|
|
78
|
+
/** Resolved canonical dir (abs). */
|
|
79
|
+
readonly canonical: string;
|
|
80
|
+
/** Whether the canonical source dir exists. `false` ⇒ nothing was done (CLI → non-zero exit). */
|
|
81
|
+
readonly canonicalExists: boolean;
|
|
82
|
+
/** # non-canonical copies found. */
|
|
83
|
+
readonly copies: number;
|
|
84
|
+
/** # copies overwritten (0 when `check:true`). */
|
|
85
|
+
readonly synced: number;
|
|
86
|
+
/** # copies that differ from canonical. */
|
|
87
|
+
readonly drifted: number;
|
|
88
|
+
/** Abs paths of copies written (empty when `check:true` — proves no writes). */
|
|
89
|
+
readonly wrote: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SKILL_MANIFEST = 'SKILL.md';
|
|
93
|
+
const IGNORED_ENTRIES = new Set(['node_modules', '__pycache__', '.DS_Store']);
|
|
94
|
+
|
|
95
|
+
/** md5 of a file's bytes (identical to both prototype scripts ⇒ identical drift verdicts). */
|
|
96
|
+
function md5(path: string): string {
|
|
97
|
+
return createHash('md5').update(readFileSync(path)).digest('hex');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Recursive file list under `dir`; skips `node_modules` / `__pycache__` / `.DS_Store`. */
|
|
101
|
+
function walk(dir: string): string[] {
|
|
102
|
+
const out: string[] = [];
|
|
103
|
+
for (const entry of readdirSync(dir)) {
|
|
104
|
+
if (IGNORED_ENTRIES.has(entry)) continue;
|
|
105
|
+
const p = join(dir, entry);
|
|
106
|
+
let st;
|
|
107
|
+
try {
|
|
108
|
+
st = statSync(p);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (st.isDirectory()) out.push(...walk(p));
|
|
113
|
+
else out.push(p);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every skill dir (a dir containing `SKILL.md`) under `packages/` + `.claude/skills`, excluding
|
|
120
|
+
* `node_modules` / `__pycache__`. Ported verbatim from `scripts/drift-sweep-skills.mjs`.
|
|
121
|
+
*/
|
|
122
|
+
function findSkillDirs(root: string, scope: 'packages' | 'all' = 'all'): string[] {
|
|
123
|
+
const dirs: string[] = [];
|
|
124
|
+
const roots = scope === 'packages' ? [join(root, 'packages')] : [join(root, 'packages'), join(root, '.claude', 'skills')];
|
|
125
|
+
const stack = roots.filter((p) => existsSync(p));
|
|
126
|
+
while (stack.length) {
|
|
127
|
+
const d = stack.pop() as string;
|
|
128
|
+
let entries: string[];
|
|
129
|
+
try {
|
|
130
|
+
entries = readdirSync(d);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (entries.includes(SKILL_MANIFEST)) dirs.push(d);
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
if (IGNORED_ENTRIES.has(e)) continue;
|
|
137
|
+
const p = join(d, e);
|
|
138
|
+
try {
|
|
139
|
+
if (statSync(p).isDirectory()) stack.push(p);
|
|
140
|
+
} catch {
|
|
141
|
+
/* skip unreadable entries */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return dirs;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Detect intra-monorepo skill drift: find every skill duplicated across ≥2 locations and report
|
|
150
|
+
* which copies byte-differ. Pure port of `scripts/drift-sweep-skills.mjs`.
|
|
151
|
+
*
|
|
152
|
+
* `result.drifted.length === 0` is the exact condition the CI gate keys on.
|
|
153
|
+
*/
|
|
154
|
+
export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepResult {
|
|
155
|
+
const scope = opts.scope ?? 'all';
|
|
156
|
+
const allow = new Set(opts.allowlist ?? []);
|
|
157
|
+
|
|
158
|
+
// Group skill dirs by basename → Map<name, locations[]>.
|
|
159
|
+
const byName = new Map<string, string[]>();
|
|
160
|
+
for (const d of findSkillDirs(root, scope)) {
|
|
161
|
+
const name = basename(d);
|
|
162
|
+
const list = byName.get(name);
|
|
163
|
+
if (list) list.push(d);
|
|
164
|
+
else byName.set(name, [d]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let duplicated = 0;
|
|
168
|
+
const drifted: DriftedSkill[] = [];
|
|
169
|
+
const allowlisted: DriftedSkill[] = [];
|
|
170
|
+
|
|
171
|
+
for (const [name, unsorted] of [...byName.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
172
|
+
if (unsorted.length < 2) continue;
|
|
173
|
+
duplicated++;
|
|
174
|
+
const copies = [...unsorted].sort();
|
|
175
|
+
|
|
176
|
+
// Union of relative file paths across every copy.
|
|
177
|
+
const relFiles = new Set<string>();
|
|
178
|
+
for (const c of copies) for (const f of walk(c)) relFiles.add(relative(c, f));
|
|
179
|
+
|
|
180
|
+
let driftFiles = 0;
|
|
181
|
+
let missingFiles = 0;
|
|
182
|
+
for (const rel of relFiles) {
|
|
183
|
+
const hashes = new Set<string>();
|
|
184
|
+
for (const c of copies) {
|
|
185
|
+
const p = join(c, rel);
|
|
186
|
+
if (existsSync(p)) hashes.add(md5(p));
|
|
187
|
+
else {
|
|
188
|
+
missingFiles++;
|
|
189
|
+
hashes.add('__MISSING__');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (hashes.size > 1) driftFiles++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (driftFiles > 0) {
|
|
196
|
+
const entry: DriftedSkill = {
|
|
197
|
+
name,
|
|
198
|
+
copies: copies.length,
|
|
199
|
+
driftFiles,
|
|
200
|
+
totalFiles: relFiles.size,
|
|
201
|
+
missingFiles,
|
|
202
|
+
locations: copies,
|
|
203
|
+
};
|
|
204
|
+
(allow.has(name) ? allowlisted : drifted).push(entry);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const byDrift = (a: DriftedSkill, b: DriftedSkill): number => b.driftFiles - a.driftFiles;
|
|
209
|
+
drifted.sort(byDrift);
|
|
210
|
+
allowlisted.sort(byDrift);
|
|
211
|
+
return { duplicated, drifted, allowlisted };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Heal one skill: treat `from ?? skills-meta/<skill>` as canonical and overwrite every other copy in
|
|
216
|
+
* the monorepo, proving byte-identity. Pure port of `scripts/sync-canonical-skill.mjs`.
|
|
217
|
+
*
|
|
218
|
+
* `check:true` writes NOTHING (`wrote` stays empty) and only reports the drift count.
|
|
219
|
+
* Default overwrites drifting copies; a subsequent {@link sweepSkillDrift} then reports 0 drift.
|
|
220
|
+
* When the canonical dir does not exist, returns `canonicalExists:false` and does nothing (the CLI
|
|
221
|
+
* turns that into a non-zero exit) — this function never throws / never `process.exit`s.
|
|
222
|
+
*/
|
|
223
|
+
export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanonicalOptions = {}): SyncResult {
|
|
224
|
+
const check = opts.check === true;
|
|
225
|
+
const canonical = opts.from ?? join(root, 'packages/@dzhechkov/skills-meta', skill);
|
|
226
|
+
|
|
227
|
+
if (!existsSync(canonical)) {
|
|
228
|
+
return { canonical, canonicalExists: false, copies: 0, synced: 0, drifted: 0, wrote: [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const canonFiles = walk(canonical)
|
|
232
|
+
.map((p) => relative(canonical, p))
|
|
233
|
+
.sort();
|
|
234
|
+
|
|
235
|
+
// The healer heals EXACTLY what the detector sees (same roots via findSkillDirs) — otherwise a copy
|
|
236
|
+
// could be silently healed but never gated, or vice-versa. Every <skill>/ dir except the canonical.
|
|
237
|
+
const copies = findSkillDirs(root, 'all')
|
|
238
|
+
.filter((d) => basename(d) === skill && relative(canonical, d) !== '')
|
|
239
|
+
.sort();
|
|
240
|
+
|
|
241
|
+
let drifted = 0;
|
|
242
|
+
let synced = 0;
|
|
243
|
+
const wrote: string[] = [];
|
|
244
|
+
|
|
245
|
+
for (const copy of copies) {
|
|
246
|
+
const copyFiles = new Set(walk(copy).map((p) => relative(copy, p)));
|
|
247
|
+
let differs = false;
|
|
248
|
+
// Extra files in the copy not present in canonical ⇒ drift.
|
|
249
|
+
for (const f of copyFiles) if (!canonFiles.includes(f)) differs = true;
|
|
250
|
+
for (const f of canonFiles) {
|
|
251
|
+
const src = join(canonical, f);
|
|
252
|
+
const dst = join(copy, f);
|
|
253
|
+
if (!existsSync(dst) || md5(src) !== md5(dst)) differs = true;
|
|
254
|
+
}
|
|
255
|
+
if (!differs) continue;
|
|
256
|
+
drifted++;
|
|
257
|
+
|
|
258
|
+
if (check) continue; // report only — write NOTHING
|
|
259
|
+
|
|
260
|
+
// Overwrite: remove extra files, then copy every canonical file byte-for-byte.
|
|
261
|
+
for (const f of copyFiles) if (!canonFiles.includes(f)) rmSync(join(copy, f));
|
|
262
|
+
for (const f of canonFiles) {
|
|
263
|
+
const src = join(canonical, f);
|
|
264
|
+
const dst = join(copy, f);
|
|
265
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
266
|
+
writeFileSync(dst, readFileSync(src));
|
|
267
|
+
}
|
|
268
|
+
synced++;
|
|
269
|
+
wrote.push(copy);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { canonical, canonicalExists: true, copies: copies.length, synced, drifted, wrote };
|
|
273
|
+
}
|