@dzhechkov/harness-core 0.3.42 → 0.3.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Generic native AgentDB indexer — the reusable primitive behind Option C's learnings-mirror
3
+ * AND the book-knowledge-digitizer's KB indexer (ADR-001 v2, `features/book-knowledge-digitizer`).
4
+ *
5
+ * Writes rows NATIVELY via the project's `better-sqlite3`, replicating `ReasoningBank`'s exact
6
+ * schema (`reasoning_patterns` + `pattern_embeddings`, embed text `${taskType}: ${text}`) so the
7
+ * `agentdb` MCP server's `agentdb_pattern_search` reads what we write. `agentdb` is used ONLY for
8
+ * its `EmbeddingService` — never its `createDatabase` (hardwired to sql.js, whose whole-file save
9
+ * corrupts concurrent native-WAL writers; QE P1). Best-effort: never throws, returns an honest
10
+ * error string when the deps are absent.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { join, dirname } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+
19
+ /** One record to index. `text` is stored as `approach` AND embedded (`${taskType}: ${text}`). */
20
+ export interface AgentdbRow {
21
+ readonly taskType: string;
22
+ readonly text: string;
23
+ /** Stored as `success_rate`; clamped to [0,1]. Use the REAL signal, never a fabricated 1.0. */
24
+ readonly score: number;
25
+ readonly tags?: readonly string[];
26
+ readonly metadata?: Record<string, unknown>;
27
+ }
28
+
29
+ /** Outcome of {@link indexPatternsToAgentdb}. */
30
+ export interface AgentdbIndexResult {
31
+ readonly indexed: number;
32
+ readonly error?: string | undefined;
33
+ }
34
+
35
+ interface NativeDb {
36
+ pragma: (s: string) => void;
37
+ exec: (s: string) => void;
38
+ prepare: (q: string) => { run: (...a: unknown[]) => { lastInsertRowid: number | bigint } };
39
+ transaction: <T>(fn: (...a: unknown[]) => T) => (...a: unknown[]) => T;
40
+ close: () => void;
41
+ }
42
+
43
+ /** Resolve the shared store path: explicit opt → AGENTDB_PATH env → `<project>/.dz/agentdb.db`. */
44
+ export function resolveAgentdbPath(projectRoot: string, dbPath?: string): string {
45
+ if (dbPath !== undefined && dbPath !== '') return dbPath;
46
+ const env = process.env['AGENTDB_PATH'];
47
+ return env !== undefined && env !== '' ? env : join(projectRoot, '.dz', 'agentdb.db');
48
+ }
49
+
50
+ /** ReasoningBank's schema, verbatim — so the MCP server reads exactly what we insert. */
51
+ const REASONING_BANK_SCHEMA = `CREATE TABLE IF NOT EXISTS reasoning_patterns (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ ts INTEGER DEFAULT (strftime('%s', 'now')),
54
+ task_type TEXT NOT NULL,
55
+ approach TEXT NOT NULL,
56
+ success_rate REAL NOT NULL DEFAULT 0.0,
57
+ uses INTEGER DEFAULT 0,
58
+ avg_reward REAL DEFAULT 0.0,
59
+ tags TEXT,
60
+ metadata TEXT
61
+ );
62
+ CREATE INDEX IF NOT EXISTS idx_patterns_task_type ON reasoning_patterns(task_type);
63
+ CREATE INDEX IF NOT EXISTS idx_patterns_success_rate ON reasoning_patterns(success_rate);
64
+ CREATE INDEX IF NOT EXISTS idx_patterns_uses ON reasoning_patterns(uses);
65
+ CREATE TABLE IF NOT EXISTS pattern_embeddings (
66
+ pattern_id INTEGER PRIMARY KEY,
67
+ embedding BLOB NOT NULL,
68
+ FOREIGN KEY (pattern_id) REFERENCES reasoning_patterns(id) ON DELETE CASCADE
69
+ );`;
70
+
71
+ /**
72
+ * Index `rows` into the shared AgentDB vector store. Returns `{indexed:0}` for an empty input and
73
+ * `{indexed:0, error}` when `agentdb`/`better-sqlite3` cannot be resolved from the project.
74
+ */
75
+ export async function indexPatternsToAgentdb(
76
+ projectRoot: string,
77
+ rows: readonly AgentdbRow[],
78
+ opts: { dbPath?: string } = {},
79
+ ): Promise<AgentdbIndexResult> {
80
+ if (rows.length === 0) return { indexed: 0 };
81
+ let sqliteUrl: string;
82
+ let agentdbDir: string;
83
+ try {
84
+ const req = createRequire(join(projectRoot, 'package.json'));
85
+ sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
86
+ agentdbDir = dirname(req.resolve('agentdb'));
87
+ } catch {
88
+ return { indexed: 0, error: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
89
+ }
90
+ try {
91
+ const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => NativeDb };
92
+ const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
93
+ EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
94
+ };
95
+ const emb = new EmbeddingService({ model: 'Xenova/all-MiniLM-L6-v2', dimension: 384, provider: 'transformers' });
96
+ await emb.initialize();
97
+
98
+ const db = new Database(resolveAgentdbPath(projectRoot, opts.dbPath));
99
+ try {
100
+ db.pragma('journal_mode = WAL');
101
+ db.pragma('busy_timeout = 5000'); // wait out a brief MCP-server write lock instead of failing
102
+ db.exec(REASONING_BANK_SCHEMA);
103
+ const insPattern = db.prepare('INSERT INTO reasoning_patterns (task_type, approach, success_rate, uses, avg_reward, tags, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)');
104
+ const insEmb = db.prepare('INSERT OR REPLACE INTO pattern_embeddings (pattern_id, embedding) VALUES (?, ?)');
105
+ // Embeddings are async (can't run inside better-sqlite3's sync transaction) — compute them
106
+ // ALL first, then commit the writes atomically (QE P2: a mid-loop failure must not leave a
107
+ // partial batch reported as indexed:0).
108
+ const prepared: Array<{ row: AgentdbRow; vec: Float32Array }> = [];
109
+ for (const row of rows) prepared.push({ row, vec: await emb.embed(`${row.taskType}: ${row.text}`) });
110
+ const commit = db.transaction(() => {
111
+ for (const { row, vec } of prepared) {
112
+ const r = insPattern.run(
113
+ row.taskType, row.text, Math.max(0, Math.min(1, Number.isFinite(row.score) ? row.score : 0)), 0, 0.0,
114
+ row.tags ? JSON.stringify(row.tags) : null,
115
+ row.metadata ? JSON.stringify(row.metadata) : null,
116
+ );
117
+ insEmb.run(Number(r.lastInsertRowid), Buffer.from(vec.buffer));
118
+ }
119
+ return prepared.length;
120
+ });
121
+ return { indexed: commit() as number };
122
+ } finally {
123
+ db.close();
124
+ }
125
+ } catch (err) {
126
+ return { indexed: 0, error: `index failed: ${err instanceof Error ? err.message : String(err)}` };
127
+ }
128
+ }
package/src/book-kb.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Book Knowledge Base — a lexical (FTS5) namespace for digitized-book Knowledge Units, kept
3
+ * **separate by construction** from the taught-pattern store (`.dz/memory/…`). Because it is its
4
+ * own file (`.dz/memory/books.sqlite`) with its own API, `loadPatterns`/`computePatternBoost`
5
+ * never see book KUs (ADR-001 v2 harness-integration P1: no boost pollution) and there is no
6
+ * retention expiry (books are permanent references, not decaying session patterns).
7
+ *
8
+ * The semantic (vector) layer lives in the shared agentdb store via {@link indexPatternsToAgentdb};
9
+ * this is the lexical layer for `dz recall --books`. Native better-sqlite3; best-effort.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import { join, dirname } from 'node:path';
15
+ import { existsSync, mkdirSync } from 'node:fs';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+
19
+ /** A digitized Knowledge Unit as stored in the book KB. */
20
+ export interface BookKU {
21
+ readonly book: string; // ISBN or book slug (the immutable upstream key)
22
+ readonly kuId: string;
23
+ readonly corpusVersion: string; // invalidation key: a re-ingest bumps this
24
+ readonly type: string;
25
+ readonly name: string;
26
+ readonly problem: string;
27
+ readonly content: string;
28
+ readonly chapter?: string;
29
+ readonly pages?: readonly number[];
30
+ readonly metadata?: Record<string, unknown>;
31
+ }
32
+
33
+ /** A lexical hit from {@link queryBookKnowledge}. */
34
+ export interface BookKUHit {
35
+ readonly book: string;
36
+ readonly kuId: string;
37
+ readonly type: string;
38
+ readonly name: string;
39
+ readonly problem: string;
40
+ readonly content: string;
41
+ readonly chapter?: string;
42
+ readonly pages?: readonly number[];
43
+ }
44
+
45
+ interface NativeDb {
46
+ pragma: (s: string) => void;
47
+ exec: (s: string) => void;
48
+ prepare: (q: string) => {
49
+ run: (...a: unknown[]) => unknown;
50
+ all: (...a: unknown[]) => unknown[];
51
+ };
52
+ transaction: <T>(fn: (...a: unknown[]) => T) => (...a: unknown[]) => T;
53
+ close: () => void;
54
+ }
55
+
56
+ /** Default lexical store path — a `memory/` sibling of the pattern store, never `agentdb.db`. */
57
+ export function bookKbPath(projectRoot: string): string {
58
+ return join(projectRoot, '.dz', 'memory', 'books.sqlite');
59
+ }
60
+
61
+ const SCHEMA = `CREATE VIRTUAL TABLE IF NOT EXISTS book_knowledge USING fts5(
62
+ book UNINDEXED, ku_id UNINDEXED, corpus_version UNINDEXED, type UNINDEXED,
63
+ name, problem, content, chapter UNINDEXED, pages UNINDEXED, metadata UNINDEXED
64
+ );`;
65
+
66
+ async function openDb(projectRoot: string, dbPath?: string): Promise<NativeDb | { error: string }> {
67
+ let sqliteUrl: string;
68
+ try {
69
+ const req = createRequire(join(projectRoot, 'package.json'));
70
+ sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
71
+ } catch {
72
+ return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
73
+ }
74
+ const path = dbPath ?? bookKbPath(projectRoot);
75
+ mkdirSync(dirname(path), { recursive: true });
76
+ const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => NativeDb };
77
+ const db = new Database(path);
78
+ db.pragma('journal_mode = WAL');
79
+ db.pragma('busy_timeout = 5000');
80
+ db.exec(SCHEMA);
81
+ return db;
82
+ }
83
+
84
+ /**
85
+ * Upsert a batch of KUs for a book. Idempotent per (book, kuId): existing rows for the same
86
+ * kuId are replaced, and — since a re-ingest changes `corpusVersion` — every row for the book
87
+ * whose corpus_version differs from this batch's is evicted (stale-corpus cleanup), so the KB
88
+ * mirrors exactly the current ingest. Best-effort: returns an honest error, never throws.
89
+ */
90
+ export async function putBookKnowledge(
91
+ projectRoot: string,
92
+ kus: readonly BookKU[],
93
+ opts: { dbPath?: string } = {},
94
+ ): Promise<{ upserted: number; evicted: number; error?: string }> {
95
+ if (kus.length === 0) return { upserted: 0, evicted: 0 };
96
+ // Precondition (QE P3): a batch must be one book at one corpus_version — the eviction below is
97
+ // keyed on that. Reject a mixed batch instead of silently leaving stale rows.
98
+ const book = kus[0]!.book;
99
+ const corpusVersion = kus[0]!.corpusVersion;
100
+ if (kus.some((k) => k.book !== book || k.corpusVersion !== corpusVersion)) {
101
+ return { upserted: 0, evicted: 0, error: 'putBookKnowledge: batch must share one book + corpus_version' };
102
+ }
103
+ const opened = await openDb(projectRoot, opts.dbPath);
104
+ if ('error' in opened) return { upserted: 0, evicted: 0, error: opened.error };
105
+ const db = opened;
106
+ try {
107
+ // Atomic (QE P2): eviction + all inserts in ONE transaction — a mid-batch failure rolls the
108
+ // eviction back too, so we never lose rows and never leave a half-written book.
109
+ const del = db.prepare('DELETE FROM book_knowledge WHERE book = ? AND corpus_version != ?');
110
+ const delKu = db.prepare('DELETE FROM book_knowledge WHERE book = ? AND ku_id = ?');
111
+ const ins = db.prepare(`INSERT INTO book_knowledge
112
+ (book, ku_id, corpus_version, type, name, problem, content, chapter, pages, metadata)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
114
+ const run = db.transaction(() => {
115
+ const evicted = (del.run(book, corpusVersion) as { changes?: number }).changes ?? 0;
116
+ for (const ku of kus) {
117
+ delKu.run(ku.book, ku.kuId);
118
+ ins.run(
119
+ ku.book, ku.kuId, ku.corpusVersion, ku.type, ku.name, ku.problem, ku.content,
120
+ ku.chapter ?? null, ku.pages ? JSON.stringify(ku.pages) : null,
121
+ ku.metadata ? JSON.stringify(ku.metadata) : null,
122
+ );
123
+ }
124
+ return { upserted: kus.length, evicted };
125
+ });
126
+ return run() as { upserted: number; evicted: number };
127
+ } catch (err) {
128
+ return { upserted: 0, evicted: 0, error: `book-kb upsert failed (rolled back): ${err instanceof Error ? err.message : String(err)}` };
129
+ } finally {
130
+ db.close();
131
+ }
132
+ }
133
+
134
+ /** Lexical (FTS5) search over the book KB. `book` filters to one book. Never throws. */
135
+ export async function queryBookKnowledge(
136
+ projectRoot: string,
137
+ query: string,
138
+ opts: { limit?: number; book?: string; dbPath?: string } = {},
139
+ ): Promise<{ hits: BookKUHit[]; error?: string }> {
140
+ const path = opts.dbPath ?? bookKbPath(projectRoot);
141
+ if (!existsSync(path)) return { hits: [] };
142
+ const opened = await openDb(projectRoot, opts.dbPath);
143
+ if ('error' in opened) return { hits: [], error: opened.error };
144
+ const db = opened;
145
+ try {
146
+ const limit = opts.limit ?? 10;
147
+ // Build the FTS5 MATCH by AND-ing individually-quoted TERMS (QE P2): quoting the whole query as
148
+ // one phrase forced strict adjacency (a big recall regression); per-term quoting keeps
149
+ // injection safety (special chars stay literal inside each phrase) while allowing any-order
150
+ // matches. Empty/all-punctuation query → no MATCH (return no hits) rather than an FTS5 error.
151
+ const terms = query.split(/\s+/).map((t) => t.replace(/[^\p{L}\p{N}]+/gu, '')).filter(Boolean);
152
+ if (terms.length === 0) return { hits: [] };
153
+ const match = terms.map((t) => `"${t}"`).join(' AND ');
154
+ const rows = (opts.book !== undefined
155
+ ? db.prepare('SELECT book, ku_id, type, name, problem, content, chapter, pages FROM book_knowledge WHERE book_knowledge MATCH ? AND book = ? ORDER BY rank LIMIT ?').all(match, opts.book, limit)
156
+ : db.prepare('SELECT book, ku_id, type, name, problem, content, chapter, pages FROM book_knowledge WHERE book_knowledge MATCH ? ORDER BY rank LIMIT ?').all(match, limit)
157
+ ) as Array<{ book: string; ku_id: string; type: string; name: string; problem: string; content: string; chapter: string | null; pages: string | null }>;
158
+ return {
159
+ hits: rows.map((r) => ({
160
+ book: r.book, kuId: r.ku_id, type: r.type, name: r.name, problem: r.problem, content: r.content,
161
+ ...(r.chapter !== null ? { chapter: r.chapter } : {}),
162
+ ...(r.pages !== null ? { pages: JSON.parse(r.pages) as number[] } : {}),
163
+ })),
164
+ };
165
+ } catch (err) {
166
+ return { hits: [], error: `book-kb query failed: ${err instanceof Error ? err.message : String(err)}` };
167
+ } finally {
168
+ db.close();
169
+ }
170
+ }
package/src/index.ts CHANGED
@@ -28,12 +28,16 @@ export { pretrain } from './pretrain.js';
28
28
  export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, BOOST_CAP, recordPattern, loadStorePatternsSync, patternToRecord, recordToPattern, consolidateSessions, recallPatterns } from './patterns.js';
29
29
  export type { PatternRecord, SessionRecord, LearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource } from './patterns.js';
30
30
  export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
31
+ export { indexPatternsToAgentdb, resolveAgentdbPath } from './agentdb-index.js';
32
+ export { putBookKnowledge, queryBookKnowledge, bookKbPath } from './book-kb.js';
33
+ export type { BookKU, BookKUHit } from './book-kb.js';
34
+ export type { AgentdbRow, AgentdbIndexResult } from './agentdb-index.js';
31
35
  export { generatePlugin } from './plugin.js';
32
36
  export type { PluginManifest } from './plugin.js';
33
37
  export type { SetupOptions, SetupResult, SetupStep } from './setup.js';
34
38
  export type { PretrainResult, DetectedTech } from './pretrain.js';
35
39
  export type { RecommendationReport, SkillRecommendation } from './recommend.js';
36
- export { discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies } from './publish.js';
40
+ export { discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
37
41
  export { fetchAllDownloads } from './downloads.js';
38
42
  export type { PackageDownloads, DownloadsReport } from './downloads.js';
39
43
  export { discoverInstalled, checkUpgrades } from './upgrade.js';
package/src/operations.ts CHANGED
@@ -526,6 +526,24 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
526
526
  detail: '.claude/mcp.json is NOT loaded by Claude Code — re-run dz setup to register agentdb in .mcp.json (project root)',
527
527
  });
528
528
  }
529
+ // Flat dz hook entries (dz ≤0.3.43 emitted `{type,command}` without the matcher-group wrapper)
530
+ // are silently IGNORED by Claude Code — the writer never fires. Detect and point at the fix.
531
+ try {
532
+ const { commandsOf } = await import('./setup.js');
533
+ const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8')) as {
534
+ hooks?: Record<string, unknown[]>;
535
+ };
536
+ const flatDz = Object.values(settings.hooks ?? {}).flat().filter((entry) =>
537
+ !Array.isArray((entry as { hooks?: unknown[] })?.hooks) &&
538
+ commandsOf(entry).some((cmd) => cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl')));
539
+ if (flatDz.length > 0) {
540
+ checks.push({
541
+ name: 'agentdb hooks shape',
542
+ ok: false,
543
+ detail: `${flatDz.length} dz hook entr(ies) use the legacy flat shape Claude Code ignores (writer never fires) — re-run dz setup to migrate`,
544
+ });
545
+ }
546
+ } catch { /* settings absent/unreadable — covered by other checks */ }
529
547
  const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
530
548
  if (existsSync(writerPath)) {
531
549
  const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
package/src/patterns.ts CHANGED
Binary file
package/src/publish.ts CHANGED
@@ -171,6 +171,31 @@ export function orderByDependencies<T extends { name: string; dir: string }>(pkg
171
171
  return ordered;
172
172
  }
173
173
 
174
+ /**
175
+ * Sync a package's own README to a freshly-bumped version: every exact occurrence of the OLD
176
+ * version token (optionally `v`-prefixed, word-bounded) becomes the new one.
177
+ *
178
+ * This kills the perpetual footer off-by-one: publish bumps package.json DURING publishing, so a
179
+ * hand-synced "vX.Y.Z" status line was always one release behind on npmjs.com (or required
180
+ * pre-setting the future version by hand). Exact-old-token matching keeps every other version
181
+ * string (dependency pins, historical notes, examples citing other releases) untouched.
182
+ * Returns the pre-sync README text for failure restore, or undefined when nothing was rewritten.
183
+ *
184
+ * Bootstrap invariant: exact-token matching MAINTAINS sync but cannot REPAIR pre-existing drift
185
+ * (a footer already one release behind contains a token != oldVersion and is skipped). Bring the
186
+ * footer to the current package.json version once; the mechanism owns it from then on.
187
+ */
188
+ export function syncReadmeVersion(dir: string, oldVersion: string, newVersion: string): string | undefined {
189
+ const readmePath = join(dir, 'README.md');
190
+ if (!existsSync(readmePath)) return undefined;
191
+ const original = readFileSync(readmePath, 'utf-8');
192
+ const escaped = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
193
+ const updated = original.replace(new RegExp(`(^|[^0-9A-Za-z.])(v?)${escaped}(?![0-9])(?!\\.[0-9])`, 'g'), `$1$2${newVersion}`);
194
+ if (updated === original) return undefined;
195
+ writeFileSync(readmePath, updated);
196
+ return original;
197
+ }
198
+
174
199
  /** Publish packages that have changes since last publish. */
175
200
  export function publishPackages(
176
201
  monorepoRoot: string,
@@ -220,9 +245,13 @@ export function publishPackages(
220
245
 
221
246
  const pkgJsonPath = join(pkg.dir, 'package.json');
222
247
  const originalPkgJson = readFileSync(pkgJsonPath, 'utf-8');
248
+ let originalReadme: string | undefined;
223
249
  try {
224
250
  // Bump version in package.json
225
251
  writeFileSync(pkgJsonPath, originalPkgJson.replace(`"version": "${oldVersion}"`, `"version": "${newVersion}"`));
252
+ // Keep the package's README version footer in lock-step with the bump, so the version
253
+ // shown on npmjs.com always matches the published package (no more manual off-by-one).
254
+ originalReadme = syncReadmeVersion(pkg.dir, oldVersion, newVersion);
226
255
 
227
256
  if (opts.bumpOnly) {
228
257
  results.push({ name: pkg.name, oldVersion, newVersion, status: 'published' });
@@ -246,10 +275,13 @@ export function publishPackages(
246
275
  results.push({ name: pkg.name, oldVersion, newVersion, status: 'published' });
247
276
  } catch (err) {
248
277
  // The version was written BEFORE build+publish; on any failure restore the
249
- // original package.json so a failed attempt doesn't orphan/skip a version
250
- // number (audit #4). pnpm rewrites workspace:* deps in-place during publish,
251
- // so restore the on-disk text we captured up front.
278
+ // original package.json (and README, if we rewrote its version) so a failed
279
+ // attempt doesn't orphan/skip a version number (audit #4). pnpm rewrites
280
+ // workspace:* deps in-place during publish, so restore the captured text.
252
281
  try { writeFileSync(pkgJsonPath, originalPkgJson); } catch { /* best-effort restore */ }
282
+ if (originalReadme !== undefined) {
283
+ try { writeFileSync(join(pkg.dir, 'README.md'), originalReadme); } catch { /* best-effort restore */ }
284
+ }
253
285
  results.push({
254
286
  name: pkg.name,
255
287
  oldVersion,
package/src/registry.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
- import { basename, dirname, join } from 'node:path';
11
+ import { basename, dirname, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
14
  /**
@@ -31,16 +31,24 @@ export function skillPackBaseDirs(cwd: string): string[] {
31
31
  if (!bases.includes(dir)) bases.push(dir);
32
32
  };
33
33
 
34
- add(join(cwd, 'packages', '@dzhechkov'));
35
- add(join(cwd, 'node_modules', '@dzhechkov'));
34
+ // Extra @scopes and local pack roots from `.dz/config.json` (ADR-001 v2): makes packs in other
35
+ // npm scopes (e.g. a private @mybooks registry) OR arbitrary local dirs first-class for
36
+ // discovery — the path for generated book packs distributed privately, not via public @dzhechkov.
37
+ const { skillScopes, skillDirs } = readSkillDiscoveryConfig(cwd);
38
+ const scopes = ['@dzhechkov', ...skillScopes];
36
39
 
37
- // Self-location: harness-core is always co-installed with the skill packs (they are
38
- // siblings under the same `@dzhechkov` dir, or one `node_modules` hop away). Walk up.
40
+ for (const scope of scopes) add(join(cwd, 'packages', scope));
41
+ for (const scope of scopes) add(join(cwd, 'node_modules', scope));
42
+
43
+ // Self-location: harness-core is co-installed with the skill packs (siblings under the same
44
+ // scope dir, or one `node_modules` hop away). Walk up for each configured scope.
39
45
  try {
40
46
  let dir = dirname(fileURLToPath(import.meta.url)); // .../@dzhechkov/harness-core/dist
41
47
  for (let depth = 0; depth < 8; depth += 1) {
42
- if (basename(dir) === '@dzhechkov') add(dir);
43
- add(join(dir, 'node_modules', '@dzhechkov'));
48
+ for (const scope of scopes) {
49
+ if (basename(dir) === scope) add(dir);
50
+ add(join(dir, 'node_modules', scope));
51
+ }
44
52
  const parent = dirname(dir);
45
53
  if (parent === dir) break;
46
54
  dir = parent;
@@ -49,9 +57,25 @@ export function skillPackBaseDirs(cwd: string): string[] {
49
57
  // import.meta.url unavailable (unexpected in ESM) — cwd scans still apply.
50
58
  }
51
59
 
60
+ // Arbitrary local pack roots (each directly contains `skills-*` dirs). Resolved against cwd.
61
+ for (const d of skillDirs) add(resolve(cwd, d));
62
+
52
63
  return bases.filter((dir) => existsSync(dir));
53
64
  }
54
65
 
66
+ /** Read `discovery.skillScopes[]` / `discovery.skillDirs[]` from `.dz/config.json` (empty on any error). */
67
+ function readSkillDiscoveryConfig(cwd: string): { skillScopes: string[]; skillDirs: string[] } {
68
+ try {
69
+ const cfg = JSON.parse(readFileSync(join(cwd, '.dz', 'config.json'), 'utf-8')) as {
70
+ discovery?: { skillScopes?: unknown; skillDirs?: unknown };
71
+ };
72
+ const arr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
73
+ return { skillScopes: arr(cfg.discovery?.skillScopes), skillDirs: arr(cfg.discovery?.skillDirs) };
74
+ } catch {
75
+ return { skillScopes: [], skillDirs: [] };
76
+ }
77
+ }
78
+
55
79
  /** List `skills-*` pack directories across all base dirs, de-duplicated by pack name (first wins). */
56
80
  export function discoverSkillPackDirs(cwd: string): { pack: string; dir: string }[] {
57
81
  const seen = new Set<string>();
@@ -155,6 +179,8 @@ function categoryFromPack(pack: string): string {
155
179
  pack.includes('presentation')
156
180
  )
157
181
  return 'product';
182
+ // Digitized-book knowledge packs (ADR-001 book-knowledge-digitizer)
183
+ if (pack.includes('book')) return 'knowledge';
158
184
  return 'other';
159
185
  }
160
186
 
package/src/setup.ts CHANGED
@@ -157,6 +157,17 @@ export function writerVersionOf(content: string): number {
157
157
  }
158
158
 
159
159
  /** Generate Claude Code hooks configuration for self-learning. */
160
+ /**
161
+ * Commands of a Claude Code hook entry in EITHER shape: the valid matcher-group form
162
+ * `{matcher?, hooks:[{type,command}]}` or the legacy flat `{type,command}` that dz ≤0.3.43
163
+ * emitted (Claude Code silently ignores flat entries — the writer-hooks bug; migrated on setup).
164
+ */
165
+ export function commandsOf(entry: unknown): string[] {
166
+ const e = entry as { command?: unknown; hooks?: { command?: unknown }[] };
167
+ if (Array.isArray(e?.hooks)) return e.hooks.map((h) => String(h?.command ?? ''));
168
+ return [String(e?.command ?? '')];
169
+ }
170
+
160
171
  export function generateHooksConfig(projectRoot: string, backend: MemoryBackend): string {
161
172
  const dzDir = join(projectRoot, '.dz');
162
173
 
@@ -165,10 +176,13 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
165
176
  // (ReasoningBank.storePattern) into the AGENTDB_PATH store the MCP server shares. The writer
166
177
  // self-degrades to a sessions.jsonl marker on any failure, so no hook ever throws.
167
178
  const writer = join(dzDir, 'agentdb-writer.mjs');
179
+ // Claude Code's hooks schema requires MATCHER-GROUP entries: `[{ hooks: [{type, command}] }]`.
180
+ // A flat `[{type, command}]` is silently ignored by Claude Code (QE find: writer hooks never
181
+ // fired), so wrap every entry. Session events take no matcher.
168
182
  return JSON.stringify({
169
183
  hooks: {
170
- SessionStart: [{ type: 'command', command: `node ${JSON.stringify(writer)} start` }],
171
- SessionEnd: [{ type: 'command', command: `node ${JSON.stringify(writer)} end` }],
184
+ SessionStart: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} start` }] }],
185
+ SessionEnd: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} end` }] }],
172
186
  },
173
187
  }, null, 2);
174
188
  }
@@ -181,8 +195,8 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
181
195
  `node -e "const fs=require('fs');const d=new Date().toISOString();fs.appendFileSync('.dz/sessions.jsonl',JSON.stringify({event:'${event}',ts:d,backend:'jsonl'})+'\\n')"`;
182
196
  return JSON.stringify({
183
197
  hooks: {
184
- SessionStart: [{ type: 'command', command: jsonlCmd('start') }],
185
- SessionEnd: [{ type: 'command', command: jsonlCmd('end') }],
198
+ SessionStart: [{ hooks: [{ type: 'command', command: jsonlCmd('start') }] }],
199
+ SessionEnd: [{ hooks: [{ type: 'command', command: jsonlCmd('end') }] }],
186
200
  },
187
201
  }, null, 2);
188
202
  }
@@ -500,7 +514,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
500
514
  const settingsDir = join(opts.projectRoot, '.claude');
501
515
  const settingsPath = join(settingsDir, 'settings.json');
502
516
  const generated = JSON.parse(generateHooksConfig(opts.projectRoot, backend)) as {
503
- hooks: Record<string, { type: string; command: string }[]>;
517
+ hooks: Record<string, { hooks: { type: string; command: string }[] }[]>;
504
518
  };
505
519
 
506
520
  if (!existsSync(settingsPath)) {
@@ -515,11 +529,14 @@ export function runSetup(opts: SetupOptions): SetupResult {
515
529
  let replacedLegacy = false;
516
530
  for (const event of Object.keys(generated.hooks)) {
517
531
  const current = Array.isArray(hooks[event]) ? hooks[event] : [];
518
- // Drop dz-generated entries (any vintage) — keep the user's own hooks untouched.
532
+ // Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
533
+ // untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
534
+ // matcher-group shape appended below.
519
535
  const kept = current.filter((entry) => {
520
- const cmd = String((entry as { command?: unknown })?.command ?? '');
521
- const isDz = cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl');
522
- if (isDz && !cmd.includes('agentdb-writer.mjs')) replacedLegacy = true;
536
+ const cmds = commandsOf(entry);
537
+ const isDz = cmds.some((cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'));
538
+ const isFlat = !Array.isArray((entry as { hooks?: unknown[] })?.hooks);
539
+ if (isDz && (isFlat || cmds.some((cmd) => cmd.includes('agentdb add')))) replacedLegacy = true;
523
540
  return !isDz;
524
541
  });
525
542
  const next = [...kept, ...generated.hooks[event]!];
@@ -605,10 +622,10 @@ export function runSetup(opts: SetupOptions): SetupResult {
605
622
  if (!isAgentdbInstalledLocally(opts.projectRoot)) problems.push('deps missing (npm i agentdb better-sqlite3)');
606
623
  try {
607
624
  const settings = JSON.parse(readFileSync(join(opts.projectRoot, '.claude', 'settings.json'), 'utf-8')) as {
608
- hooks?: Record<string, { command?: string }[]>;
625
+ hooks?: Record<string, unknown[]>;
609
626
  };
610
627
  const refs = ['SessionStart', 'SessionEnd'].every((ev) =>
611
- (settings.hooks?.[ev] ?? []).some((h) => String(h?.command ?? '').includes('agentdb-writer.mjs')));
628
+ (settings.hooks?.[ev] ?? []).some((h) => commandsOf(h).some((cmd) => cmd.includes('agentdb-writer.mjs'))));
612
629
  if (!refs) problems.push('hooks do not invoke the writer');
613
630
  } catch {
614
631
  problems.push('settings.json unreadable');