@dzhechkov/harness-core 0.3.140 → 0.3.142

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,755 @@
1
+ /**
2
+ * `dz backlog` — the Smart Backlog: a personal, goal-directed idea pipeline (feature smart-backlog).
3
+ *
4
+ * Capture an idea → semantic dedup against existing ideas via the EXISTING Brain vector engine
5
+ * (agentdb + the shared embedder — ADR-001; NO second store) → score alignment against a GoalMap
6
+ * (ADR-003) → weighted-roulette pick (ADR-004) → stage an idea2prd enrich hand-off → draft a Jira
7
+ * issue through a configurable, stub-first adapter seam (ADR-006).
8
+ *
9
+ * ARCHITECTURE (05): every load-bearing decision is a PURE function tested on a layer-1 deterministic
10
+ * test; the CLI handler (`cmdBacklog`) is a thin arg-parse + call. Semantic work (embedding, cosine,
11
+ * vector search) is an OUTBOUND PORT to harness-core's Brain engine — this file defines NO embedder,
12
+ * NO cosine, and imports NO MCP client (ADR-001 / ADR-006, both grep-guarded).
13
+ *
14
+ * Ideas carry `task_type:'dz-backlog'` in the shared `.dz/agentdb.db`, so they never surface in
15
+ * `dz recall`'s lesson namespace (ADR-005 T-005b, the discriminating isolation test). Structured
16
+ * records live in a dedicated `.dz/backlog/ideas.jsonl`, never in `.dz/memory/patterns.*`.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ // ── Outbound ports to the Brain engine (ADR-001): reuse VERBATIM, define none locally. ──
22
+ // Backlog vectors are written DIRECTLY to the agentdb `dz-backlog` namespace via
23
+ // `importVectorsToAgentdb` (NOT the configurable `mirrorEntriesToVector` engine), so write and read
24
+ // ALWAYS hit the SAME store regardless of `memory.vector.engine` (ADR-001/005 — the single-store
25
+ // property; routing through the generic engine let `engine:'rvf'` split writes from reads).
26
+ import { cosineSimilarity, deleteAgentdbByDzIds, importVectorsToAgentdb, resolveAgentdbEmbedder, searchAgentdbPatterns } from './agentdb-index.js';
27
+ import { resolveEmbedModel } from './embedding-config.js';
28
+ // Seeded PRNG — reuse the ONE mulberry32 already in the repo (no second RNG; ADR-004 determinism).
29
+ import { mulberry32 } from './compounding.js';
30
+ /** The vector-store namespace that isolates ideas from lessons (ADR-001/005). */
31
+ export const BACKLOG_TASK_TYPE = 'dz-backlog';
32
+ /* ================================================================== */
33
+ /* CONFIG (readBacklogConfig) — defensive, Number.isFinite clamps. */
34
+ /* ================================================================== */
35
+ export const DEFAULT_DUPLICATE_THRESHOLD = 0.92; // measured house constant (02 §R-D)
36
+ export const DEFAULT_RELATEDNESS_FLOOR = 0.35; // GROUND_VECTOR_SIMILARITY_FLOOR (02 §R-D)
37
+ export const DEFAULT_ROULETTE_ALPHA = 1.5;
38
+ export const DEFAULT_HALF_LIFE_DAYS = 30;
39
+ export const DEFAULT_RECENCY_FLOOR = 0.3;
40
+ export const DEFAULT_EFFORT = 3;
41
+ /** Finite-and-in-range or the fallback (the recurring Infinity-clamp lesson). */
42
+ function clampNum(v, lo, hi, fallback, opts = {}) {
43
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < lo || v > hi)
44
+ return fallback;
45
+ return opts.intOnly ? Math.floor(v) : v;
46
+ }
47
+ export function readBacklogConfig(projectRoot) {
48
+ const fallback = {
49
+ dedup: { duplicateThreshold: DEFAULT_DUPLICATE_THRESHOLD, relatednessFloor: DEFAULT_RELATEDNESS_FLOOR },
50
+ roulette: {
51
+ alpha: DEFAULT_ROULETTE_ALPHA,
52
+ halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
53
+ recencyFloor: DEFAULT_RECENCY_FLOOR,
54
+ defaultEffort: DEFAULT_EFFORT,
55
+ },
56
+ jira: { adapter: 'none' },
57
+ };
58
+ const configPath = join(projectRoot, '.dz', 'config.json');
59
+ if (!existsSync(configPath))
60
+ return fallback;
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
63
+ const b = parsed.backlog ?? {};
64
+ // `> 0 && <= 1` — a value of 2 or NaN falls back to 0.92 (ADR-002 T-002d).
65
+ const dup = clampNum(b.dedup?.duplicateThreshold, Number.MIN_VALUE, 1, DEFAULT_DUPLICATE_THRESHOLD);
66
+ // The floor must sit in [0, dup]. MED-7: re-range-check the ADJUSTED value (clamp the RESULT, not
67
+ // just the input) — a tiny `dup` (e.g. 5e-324) previously drove `dup - EPSILON` NEGATIVE, so a
68
+ // cosine of -1e-16 fell into the RELATED band. Force the repaired floor back into [0, dup].
69
+ let floor = clampNum(b.dedup?.relatednessFloor, 0, 1, DEFAULT_RELATEDNESS_FLOOR);
70
+ if (floor >= dup)
71
+ floor = DEFAULT_RELATEDNESS_FLOOR < dup ? DEFAULT_RELATEDNESS_FLOOR : dup;
72
+ floor = Math.min(Math.max(floor, 0), dup);
73
+ const adapter = isBacklogBackend(b.jira?.adapter) ? b.jira.adapter : 'none'; // unknown ⇒ none (ADR-006 T-006d)
74
+ return {
75
+ dedup: { duplicateThreshold: dup, relatednessFloor: floor },
76
+ roulette: {
77
+ alpha: clampNum(b.roulette?.alpha, Number.MIN_VALUE, Number.MAX_VALUE, DEFAULT_ROULETTE_ALPHA),
78
+ halfLifeDays: clampNum(b.roulette?.halfLifeDays, Number.MIN_VALUE, Number.MAX_VALUE, DEFAULT_HALF_LIFE_DAYS),
79
+ recencyFloor: clampNum(b.roulette?.recencyFloor, Number.MIN_VALUE, 1, DEFAULT_RECENCY_FLOOR),
80
+ defaultEffort: clampNum(b.roulette?.defaultEffort, 1, 5, DEFAULT_EFFORT, { intOnly: true }),
81
+ },
82
+ jira: { adapter },
83
+ };
84
+ }
85
+ catch {
86
+ return fallback;
87
+ }
88
+ }
89
+ /* ================================================================== */
90
+ /* STORE (AM-1) — .dz/backlog/ideas.jsonl (append-only JSONL, ADR-005) */
91
+ /* ================================================================== */
92
+ export function backlogDir(projectRoot) {
93
+ return join(projectRoot, '.dz', 'backlog');
94
+ }
95
+ export function ideasPath(projectRoot) {
96
+ return join(backlogDir(projectRoot), 'ideas.jsonl');
97
+ }
98
+ export function goalsPath(projectRoot) {
99
+ return join(backlogDir(projectRoot), 'goals.json');
100
+ }
101
+ export function jiraOutboxDir(projectRoot) {
102
+ return join(backlogDir(projectRoot), 'jira-outbox');
103
+ }
104
+ /** Content-addressed id — `sha1(text|createdTs).slice(0,16)`; NO `Date.now()` (mirrors patterns.ts). */
105
+ export function ideaId(text, createdTs) {
106
+ return createHash('sha1').update(`${text}|${createdTs}`).digest('hex').slice(0, 16);
107
+ }
108
+ /**
109
+ * Strict id whitelist (mirrors `dz score`'s slug guard) — the ONLY shape that may be interpolated into
110
+ * a filesystem path (jira-outbox/<id>.json, enrich slug fallback). Rejects `.`/`..`/`/`/`\` and any
111
+ * traversal, so an idea id like `../../../owned` from a hand-edited store can NEVER escape its dir
112
+ * (HIGH-1). A content-addressed `ideaId()` (16 hex) always passes; anything else is refused, not sanitised
113
+ * into a surprising path.
114
+ */
115
+ export function isSafeId(id) {
116
+ return typeof id === 'string' && id.length > 0 && id.length <= 64 && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id) && !id.includes('..');
117
+ }
118
+ /** Strip an id to a filesystem-safe token (defence-in-depth for a slug fallback — never a path). */
119
+ function safeIdToken(id) {
120
+ const t = id.replace(/[^A-Za-z0-9]/g, '').slice(0, 12);
121
+ return t === '' ? 'idea' : t;
122
+ }
123
+ /** Normalise a partial/loaded object into a full IdeaRecord (defensive: bad fields ⇒ safe defaults). */
124
+ function normaliseIdea(raw) {
125
+ // HIGH-1: an id that is not filesystem-safe can NEVER enter the store — it would later interpolate
126
+ // into an outbox filename or an enrich slug and escape its directory. Drop the record entirely.
127
+ if (!isSafeId(raw.id) || typeof raw.text !== 'string')
128
+ return undefined;
129
+ const status = ['new', 'enriched', 'in-progress', 'shipped', 'dropped'].includes(raw.status)
130
+ ? raw.status
131
+ : 'new';
132
+ // MED-6: a bad createdTs (`Date.parse` ⇒ NaN) would poison the roulette weight; coerce it to a
133
+ // valid, maximally-old ISO timestamp so recency degrades to the neutral floor, never NaN.
134
+ const createdRaw = typeof raw.createdTs === 'string' && Number.isFinite(Date.parse(raw.createdTs)) ? raw.createdTs : new Date(0).toISOString();
135
+ const rec = {
136
+ id: raw.id,
137
+ text: raw.text,
138
+ status,
139
+ createdTs: createdRaw,
140
+ effort: clampNum(raw.effort, 1, 5, DEFAULT_EFFORT, { intOnly: true }),
141
+ goalId: typeof raw.goalId === 'string' ? raw.goalId : null,
142
+ goalAlignment: clampNum(raw.goalAlignment, 0, 1, 0),
143
+ relatedIds: Array.isArray(raw.relatedIds) ? raw.relatedIds.filter((x) => typeof x === 'string') : [],
144
+ uses: clampNum(raw.uses, 0, Number.MAX_VALUE, 0, { intOnly: true }),
145
+ tags: Array.isArray(raw.tags) ? raw.tags.filter((x) => typeof x === 'string') : [],
146
+ };
147
+ if (typeof raw.proposal === 'string')
148
+ rec.proposal = raw.proposal;
149
+ if (typeof raw.enrichedPath === 'string')
150
+ rec.enrichedPath = raw.enrichedPath;
151
+ if (typeof raw.jiraKey === 'string')
152
+ rec.jiraKey = raw.jiraKey;
153
+ return rec;
154
+ }
155
+ /** Read the append-only store. A corrupt line is SKIPPED (never fatal) — the whole store never throws. */
156
+ export function readIdeas(projectRoot) {
157
+ const path = ideasPath(projectRoot);
158
+ if (!existsSync(path))
159
+ return [];
160
+ let text;
161
+ try {
162
+ text = readFileSync(path, 'utf-8');
163
+ }
164
+ catch {
165
+ return [];
166
+ }
167
+ const out = [];
168
+ for (const line of text.split('\n')) {
169
+ const trimmed = line.trim();
170
+ if (trimmed === '')
171
+ continue;
172
+ try {
173
+ const rec = normaliseIdea(JSON.parse(trimmed));
174
+ if (rec !== undefined)
175
+ out.push(rec);
176
+ }
177
+ catch {
178
+ /* skip corrupt line */
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ /** Atomic full rewrite (tmp + rename) so a crash mid-write never truncates the store. */
184
+ export function writeIdeas(projectRoot, ideas) {
185
+ const path = ideasPath(projectRoot);
186
+ mkdirSync(dirname(path), { recursive: true });
187
+ const body = ideas.map((i) => JSON.stringify(i)).join('\n') + (ideas.length > 0 ? '\n' : '');
188
+ const tmp = `${path}.tmp-${process.pid}`;
189
+ writeFileSync(tmp, body);
190
+ renameSync(tmp, path);
191
+ }
192
+ export function snapshotIdeas(projectRoot, dest) {
193
+ try {
194
+ const ideas = readIdeas(projectRoot);
195
+ mkdirSync(dirname(dest), { recursive: true });
196
+ writeFileSync(dest, ideas.map((i) => JSON.stringify(i)).join('\n') + (ideas.length > 0 ? '\n' : ''));
197
+ return { path: dest, count: ideas.length };
198
+ }
199
+ catch (err) {
200
+ return { path: dest, count: 0, error: `snapshot failed: ${err instanceof Error ? err.message : String(err)}` };
201
+ }
202
+ }
203
+ /**
204
+ * THE LOAD-BEARING CLASSIFIER (ADR-002 T-002a) — PURE. Bands the top-1 raw cosine:
205
+ * DUPLICATE cosine ≥ duplicateThreshold (default 0.92)
206
+ * RELATED relatednessFloor ≤ cosine < dup (default [0.35, 0.92)) ⇒ create + attach relatedIds
207
+ * NEW cosine < relatednessFloor
208
+ * Flip either cut and exactly one boundary fixture crosses a band — the test REDS.
209
+ */
210
+ export function classifyDedup(candidates, cfg, opts = {}) {
211
+ // HIGH-4: a non-finite cosine (NaN/±Infinity) sorts unpredictably and can shove a real 0.97 duplicate
212
+ // out of the top slot → misclassified NEW. Drop non-finite candidates BEFORE sorting/banding (the
213
+ // recurring repo `Number.isFinite` lesson).
214
+ const sorted = candidates.filter((c) => Number.isFinite(c.cosine)).sort((a, b) => b.cosine - a.cosine);
215
+ const top = sorted[0];
216
+ const exactTextOnly = opts.exactTextOnly === true;
217
+ if (top === undefined)
218
+ return { action: 'new', cosine: -1, matchedId: undefined, relatedIds: [], exactTextOnly };
219
+ if (top.cosine >= cfg.duplicateThreshold) {
220
+ return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, relatedIds: [], exactTextOnly };
221
+ }
222
+ const related = sorted.filter((c) => c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold);
223
+ if (related.length > 0) {
224
+ return { action: 'related', cosine: top.cosine, matchedId: undefined, relatedIds: related.map((c) => c.id), exactTextOnly };
225
+ }
226
+ return { action: 'new', cosine: top.cosine, matchedId: undefined, relatedIds: [], exactTextOnly };
227
+ }
228
+ /**
229
+ * Production dedup: embed+search the `dz-backlog` vectors (RAW COSINE via `searchAgentdbPatterns`,
230
+ * NEVER the RRF `recallHybrid().score` — ADR-002 T-002c), then band via {@link classifyDedup}. If the
231
+ * embedder/search is unavailable it DEGRADES to exact-text dedup (identical text ⇒ DUPLICATE), never
232
+ * blocking capture (NFR-2 / ADR-002 T-002e).
233
+ */
234
+ export async function dedupIdea(projectRoot, text, cfg, deps = {}) {
235
+ const ideas = deps.ideas ?? readIdeas(projectRoot);
236
+ const search = deps.search ??
237
+ ((root, query) => searchAgentdbPatterns(root, query, { taskTypes: [BACKLOG_TASK_TYPE], limit: 20 }));
238
+ // Match the stored embed form `${taskType}: ${text}` (02 §R-B) so query and row vectors co-locate.
239
+ const result = await search(projectRoot, `${BACKLOG_TASK_TYPE}: ${text}`);
240
+ // MED-5: build the VALID candidate set FIRST (a hit must carry a dzId AND a finite cosine). Only
241
+ // then decide — a malformed hit (missing dzId / NaN cosine) must NOT bypass the exact-text net.
242
+ // HIGH-A: a hit whose dzId is NOT a member of the CURRENT ideas.jsonl is an ORPHAN vector (its
243
+ // structured record was removed, e.g. by `harmonize --apply`); matching it would report a DUPLICATE
244
+ // of a nonexistent idea (a dead match). Drop orphans before deciding — defense in depth alongside the
245
+ // prune-on-removal in `harmonizeBacklog`.
246
+ const liveIds = new Set(ideas.map((i) => i.id));
247
+ const candidates = [];
248
+ for (const h of result.hits) {
249
+ if (typeof h.dzId === 'string' && liveIds.has(h.dzId) && Number.isFinite(h.similarity))
250
+ candidates.push({ id: h.dzId, cosine: h.similarity });
251
+ }
252
+ if (result.error !== undefined || candidates.length === 0) {
253
+ // No usable semantic signal (embedder unavailable, nothing mirrored, or only malformed hits) ⇒
254
+ // EXACT-text safety net: identical text among existing ideas is still a DUPLICATE (content-addressed
255
+ // idempotency, ADR-002 §degrade). Otherwise NEW — the RELATED band needs cosine and is skipped.
256
+ const match = ideas.find((i) => i.text === text);
257
+ if (match !== undefined)
258
+ return { action: 'duplicate', cosine: 1, matchedId: match.id, relatedIds: [], exactTextOnly: true };
259
+ return { action: 'new', cosine: -1, matchedId: undefined, relatedIds: [], exactTextOnly: result.error !== undefined };
260
+ }
261
+ return classifyDedup(candidates, cfg.dedup);
262
+ }
263
+ /* ================================================================== */
264
+ /* ALIGNMENT (AM-3 / ADR-003) — weighted-MAX cosine over the GoalMap. */
265
+ /* ================================================================== */
266
+ /** Defensive GoalMap reader — never throws; a missing/corrupt file ⇒ empty compass. */
267
+ export function readGoalMap(projectRoot) {
268
+ const path = goalsPath(projectRoot);
269
+ if (!existsSync(path))
270
+ return { version: 1, goals: [] };
271
+ try {
272
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
273
+ const goals = Array.isArray(parsed.goals)
274
+ ? parsed.goals
275
+ .map((g) => {
276
+ const o = g;
277
+ if (typeof o.id !== 'string' || typeof o.statement !== 'string')
278
+ return undefined;
279
+ return {
280
+ id: o.id,
281
+ statement: o.statement,
282
+ weight: clampNum(o.weight, Number.MIN_VALUE, 1, 1),
283
+ keywords: Array.isArray(o.keywords) ? o.keywords.filter((k) => typeof k === 'string') : [],
284
+ };
285
+ })
286
+ .filter((g) => g !== undefined)
287
+ : [];
288
+ return { version: typeof parsed.version === 'number' ? parsed.version : 1, goals };
289
+ }
290
+ catch {
291
+ return { version: 1, goals: [] };
292
+ }
293
+ }
294
+ /** The text embedded for a goal: statement + keywords (same convention across cache + score). */
295
+ export function goalEmbedText(goal) {
296
+ return goal.keywords.length > 0 ? `${goal.statement} ${goal.keywords.join(' ')}` : goal.statement;
297
+ }
298
+ /**
299
+ * THE LOAD-BEARING ALIGNMENT SCORER (ADR-003 T-003a) — PURE. Alignment =
300
+ * `max over goals of ( cosine(ideaVec, goalVec) * weight )`, clamped to [0,1]. Weighted-MAX (not
301
+ * sum/mean) so a focused idea advancing ONE goal strongly scores high. No goals ⇒ 0 / null.
302
+ */
303
+ export function scoreAlignment(ideaVec, goals) {
304
+ let bestId = null;
305
+ let best = 0;
306
+ for (const g of goals) {
307
+ const raw = Math.max(0, cosineSimilarity(ideaVec, g.vec)) * g.weight;
308
+ if (raw > best) {
309
+ best = raw;
310
+ bestId = g.id;
311
+ }
312
+ }
313
+ return { goalId: bestId, goalAlignment: Math.min(1, best) };
314
+ }
315
+ /**
316
+ * Production alignment: embed the idea + each goal (same reused embedder) and score. No embedder or no
317
+ * GoalMap ⇒ `{goalId:null, goalAlignment:0}` — capture and roulette still work (ADR-003 T-003d).
318
+ */
319
+ export async function alignIdea(projectRoot, text, goalMap, deps = {}) {
320
+ if (goalMap.goals.length === 0)
321
+ return { goalId: null, goalAlignment: 0 };
322
+ let embed = deps.embed;
323
+ if (embed === undefined) {
324
+ const resolved = await resolveAgentdbEmbedder(projectRoot);
325
+ if ('error' in resolved)
326
+ return { goalId: null, goalAlignment: 0 };
327
+ embed = resolved.embed;
328
+ }
329
+ try {
330
+ const ideaVec = await embed(`${BACKLOG_TASK_TYPE}: ${text}`);
331
+ // LOW-10: USE the goal-embed cache (the ADR-003 design) — goal vectors are stable across captures,
332
+ // so re-embedding them every `add` is waste. The cache is keyed by embed model+dim (a model change
333
+ // invalidates it, T-003e) and by each goal's text hash. Cache use is best-effort: any failure just
334
+ // falls through to a live embed, never breaks alignment.
335
+ const model = resolveEmbedModel(projectRoot);
336
+ const useCache = !('error' in model);
337
+ const cache = useCache ? readGoalEmbedCache(projectRoot) : undefined;
338
+ const nextGoals = {};
339
+ const goalVecs = [];
340
+ let cacheChanged = false;
341
+ for (const g of goalMap.goals) {
342
+ let vec = useCache && !('error' in model) ? goalCacheHit(cache, model.model, model.dim, g) : undefined;
343
+ if (vec === undefined) {
344
+ vec = await embed(`${BACKLOG_TASK_TYPE}: ${goalEmbedText(g)}`);
345
+ cacheChanged = true;
346
+ }
347
+ goalVecs.push({ id: g.id, vec, weight: g.weight });
348
+ if (useCache)
349
+ nextGoals[g.id] = { hash: goalTextHash(g), vec: Array.from(vec) };
350
+ }
351
+ if (useCache && !('error' in model) && cacheChanged) {
352
+ try {
353
+ writeGoalEmbedCache(projectRoot, { model: model.model, dim: model.dim, goals: nextGoals });
354
+ }
355
+ catch {
356
+ /* cache write is best-effort — a failure never affects the score */
357
+ }
358
+ }
359
+ return scoreAlignment(ideaVec, goalVecs);
360
+ }
361
+ catch {
362
+ return { goalId: null, goalAlignment: 0 };
363
+ }
364
+ }
365
+ function goalCachePath(projectRoot) {
366
+ return join(backlogDir(projectRoot), 'goal-embeds.json');
367
+ }
368
+ function goalTextHash(goal) {
369
+ return createHash('sha1').update(goalEmbedText(goal)).digest('hex').slice(0, 16);
370
+ }
371
+ /**
372
+ * PURE cache validity check (ADR-003 T-003e): a cache is valid for a goal ONLY when the embed
373
+ * model+dim manifest matches AND the goal's embed-text hash is unchanged. A model change (different
374
+ * `model`/`dim`) invalidates EVERY cached goal vector — forcing a recompute, never a stale alignment.
375
+ */
376
+ export function goalCacheHit(cache, model, dim, goal) {
377
+ if (cache === undefined || cache.model !== model || cache.dim !== dim)
378
+ return undefined;
379
+ const entry = cache.goals[goal.id];
380
+ if (entry === undefined || entry.hash !== goalTextHash(goal))
381
+ return undefined;
382
+ return Float32Array.from(entry.vec);
383
+ }
384
+ export function readGoalEmbedCache(projectRoot) {
385
+ const path = goalCachePath(projectRoot);
386
+ if (!existsSync(path))
387
+ return undefined;
388
+ try {
389
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
390
+ if (typeof parsed.model !== 'string' || typeof parsed.dim !== 'number' || typeof parsed.goals !== 'object')
391
+ return undefined;
392
+ return parsed;
393
+ }
394
+ catch {
395
+ return undefined;
396
+ }
397
+ }
398
+ export function writeGoalEmbedCache(projectRoot, cache) {
399
+ const path = goalCachePath(projectRoot);
400
+ mkdirSync(dirname(path), { recursive: true });
401
+ writeFileSync(path, JSON.stringify(cache, null, 2));
402
+ }
403
+ /* ================================================================== */
404
+ /* ROULETTE (AM-5 / ADR-004) — WEIGHTED, seeded, no starvation. */
405
+ /* ================================================================== */
406
+ /** Tiny base added to the compass term so an unaligned idea keeps a non-zero weight (no starvation). */
407
+ export const ROULETTE_EPSILON = 0.02;
408
+ const DAY_MS = 24 * 60 * 60 * 1000;
409
+ /** Ideas eligible for a spin: only `new` and `enriched` (ADR-004). */
410
+ export function eligibleIdeas(ideas) {
411
+ return ideas.filter((i) => i.status === 'new' || i.status === 'enriched');
412
+ }
413
+ /** Bounded recency decay ∈ [floor, 1]: `2^(-ageDays/halfLife)`, floored so old ideas are down-weighted, never zero. */
414
+ export function recencyDecay(ageMs, halfLifeDays, floor) {
415
+ const ageDays = Math.max(0, ageMs) / DAY_MS;
416
+ const decay = Math.pow(2, -ageDays / halfLifeDays);
417
+ return Math.max(floor, Math.min(1, decay));
418
+ }
419
+ /**
420
+ * THE selection weight (ADR-004): `(alignment^alpha + EPS) · recencyDecay(age) · (1/effort)`. Strictly
421
+ * positive for every idea (EPS>0, floor>0, effort≥1) ⇒ no permanent starvation (T-004c). Equal
422
+ * alignment+age+effort ⇒ equal weights ⇒ a uniform draw (the T-004a control).
423
+ */
424
+ export function ideaWeight(idea, cfg, nowMs) {
425
+ // MED-6: EVERY factor is Number.isFinite-guarded so one bad field (NaN alignment, an unparseable
426
+ // createdTs) degrades to a neutral contribution — it can NEVER poison the whole spin's total (which
427
+ // would make the draw always pick the last item). A bad timestamp ⇒ neutral (floor) recency.
428
+ const alignment = Number.isFinite(idea.goalAlignment) ? Math.max(0, Math.min(1, idea.goalAlignment)) : 0;
429
+ const compass = Math.pow(alignment, cfg.alpha) + ROULETTE_EPSILON;
430
+ const parsed = Date.parse(idea.createdTs);
431
+ const age = Number.isFinite(parsed) ? nowMs - parsed : Number.POSITIVE_INFINITY; // bad ts ⇒ maximally old ⇒ floor
432
+ const recency = recencyDecay(age, cfg.halfLifeDays, cfg.recencyFloor);
433
+ const effort = Number.isFinite(idea.effort) && idea.effort >= 1 ? Math.max(1, Math.min(5, idea.effort)) : cfg.defaultEffort;
434
+ const w = compass * recency * (1 / effort);
435
+ return Number.isFinite(w) && w > 0 ? w : ROULETTE_EPSILON; // last-resort finite floor
436
+ }
437
+ /** Deterministic ranked shortlist (`--pick N`): by weight desc, id asc — no RNG. */
438
+ export function rankRoulette(ideas, cfg, nowMs) {
439
+ return eligibleIdeas(ideas)
440
+ .map((i) => ({ i, w: ideaWeight(i, cfg, nowMs) }))
441
+ .sort((a, b) => b.w - a.w || (a.i.id < b.i.id ? -1 : 1))
442
+ .map((x) => x.i);
443
+ }
444
+ /**
445
+ * A single WEIGHTED-RANDOM spin over normalised weights, using an injected seeded RNG (`rng()∈[0,1)`).
446
+ * Same seed ⇒ identical pick (ADR-004 T-004b determinism). Returns `undefined` when nothing is eligible.
447
+ */
448
+ export function spinRoulette(ideas, cfg, rng, nowMs) {
449
+ const pool = eligibleIdeas(ideas);
450
+ if (pool.length === 0)
451
+ return undefined;
452
+ const weights = pool.map((i) => ideaWeight(i, cfg, nowMs));
453
+ const total = weights.reduce((a, b) => a + b, 0);
454
+ if (total <= 0)
455
+ return pool[0];
456
+ let r = rng() * total;
457
+ for (let k = 0; k < pool.length; k += 1) {
458
+ r -= weights[k];
459
+ if (r < 0)
460
+ return pool[k];
461
+ }
462
+ return pool[pool.length - 1];
463
+ }
464
+ /** Build a seeded RNG from a `--seed` integer (reuses the one repo mulberry32). */
465
+ export function seededRng(seed) {
466
+ return mulberry32(Number.isFinite(seed) ? Math.floor(seed) : 0);
467
+ }
468
+ /* ================================================================== */
469
+ /* ENRICH (AM-7 / FR-5) — stage the idea2prd hand-off, do NOT expand. */
470
+ /* ================================================================== */
471
+ /** kebab-case, Latin-only, ≤40 chars (feature-adr slug convention). */
472
+ export function ideaSlug(idea) {
473
+ const base = idea.text
474
+ .toLowerCase()
475
+ .replace(/[^a-z0-9]+/g, '-')
476
+ .replace(/^-+|-+$/g, '')
477
+ .slice(0, 40)
478
+ .replace(/-+$/g, '');
479
+ // HIGH-1: the fallback must be a filesystem-safe token, NOT the raw id — a hand-edited id like
480
+ // `../../../owned` would otherwise make the slug traverse out of `features/`.
481
+ return base === '' ? `idea-${safeIdToken(idea.id)}` : base;
482
+ }
483
+ /**
484
+ * Stage the idea2prd INPUT and hand off to the `idea2prd-manual` skill (an AGENT phase — 02 §R-G).
485
+ * The CLI writes the scaffold ONLY; it never fabricates a PRD (idea2prd is a skill, not a synchronous
486
+ * transform). Returns the hand-off target so the agent can pick it up.
487
+ */
488
+ export function stageEnrichment(projectRoot, idea, related, goalMap) {
489
+ const slug = ideaSlug(idea);
490
+ const dir = join(projectRoot, 'features', slug);
491
+ const scaffoldPath = join(dir, 'idea2prd-input.md');
492
+ const goal = idea.goalId !== null ? goalMap.goals.find((g) => g.id === idea.goalId) : undefined;
493
+ const lines = [
494
+ `# idea2prd input — ${slug}`,
495
+ '',
496
+ '> STAGED by `dz backlog enrich`. This is the HAND-OFF scaffold, not a PRD. Run the',
497
+ '> `idea2prd-manual` skill (agent phase) to expand it — the CLI never fabricates the PRD.',
498
+ '',
499
+ '## Idea',
500
+ '',
501
+ idea.text,
502
+ '',
503
+ `- id: \`${idea.id}\``,
504
+ `- effort: ${idea.effort}/5`,
505
+ `- goal alignment: ${idea.goalAlignment.toFixed(3)}${goal !== undefined ? ` (top goal: ${goal.id} — ${goal.statement})` : ' (no aligned goal)'}`,
506
+ '',
507
+ '## Related ideas (dedup RELATED band)',
508
+ '',
509
+ related.length > 0 ? related.map((r) => `- \`${r.id}\` — ${r.text}`).join('\n') : '_none_',
510
+ '',
511
+ '## Goal context (the compass)',
512
+ '',
513
+ goalMap.goals.length > 0 ? goalMap.goals.map((g) => `- \`${g.id}\` (w=${g.weight}): ${g.statement}`).join('\n') : '_no GoalMap_',
514
+ '',
515
+ '## Verification & honesty (owned at the BACKLOG layer, per the user steer)',
516
+ '',
517
+ '- Apply claim-check discipline: every accuracy claim MEASURED with a reproducer.',
518
+ '- Run an adversarial red-team pass on the resulting PRD/ADRs.',
519
+ '- Recall Brain lessons before asserting what exists (`dz recall`).',
520
+ '',
521
+ ];
522
+ mkdirSync(dir, { recursive: true });
523
+ writeFileSync(scaffoldPath, lines.join('\n'));
524
+ return { slug, scaffoldPath };
525
+ }
526
+ /* ================================================================== */
527
+ /* JIRA PORT (AM-6 / ADR-006) — closed vocab + registry + real stub. */
528
+ /* ⚠ NO MCP CLIENT IMPORT ANYWHERE IN THIS SEAM (grep-guard T-006b). */
529
+ /* ================================================================== */
530
+ export const BACKLOG_BACKENDS = ['jira-mcp', 'copilot-mcp', 'none'];
531
+ export function isBacklogBackend(v) {
532
+ return typeof v === 'string' && BACKLOG_BACKENDS.includes(v);
533
+ }
534
+ /** Pure draft builder from an idea. */
535
+ export function buildJiraDraft(idea, goalMap) {
536
+ const goal = idea.goalId !== null ? goalMap.goals.find((g) => g.id === idea.goalId) : undefined;
537
+ const descLines = [
538
+ idea.proposal ?? idea.text,
539
+ '',
540
+ `Source idea: ${idea.id} (effort ${idea.effort}/5, alignment ${idea.goalAlignment.toFixed(3)})`,
541
+ goal !== undefined ? `Top goal: ${goal.id} — ${goal.statement}` : 'No aligned goal',
542
+ idea.relatedIds.length > 0 ? `Related ideas: ${idea.relatedIds.join(', ')}` : '',
543
+ idea.enrichedPath !== undefined ? `Enriched: ${idea.enrichedPath}` : '',
544
+ ].filter((l) => l !== '');
545
+ return {
546
+ summary: idea.text.length > 120 ? `${idea.text.slice(0, 117)}...` : idea.text,
547
+ description: descLines.join('\n'),
548
+ labels: ['dz-backlog', ...idea.tags],
549
+ sourceIdeaId: idea.id,
550
+ };
551
+ }
552
+ /** The `none` adapter — a REAL stub (not an absence check): writes the full draft to the outbox. */
553
+ const noneAdapter = {
554
+ backend: 'none',
555
+ async createIssue(draft, io) {
556
+ const outboxPath = io.writeOutbox(draft.sourceIdeaId, { backend: 'none', draft });
557
+ return { backend: 'none', key: null, stub: true, outboxPath };
558
+ },
559
+ async verify() {
560
+ return {
561
+ form: 'manual',
562
+ ready: true, // the stub IS ready — it writes an auditable outbox with no external wiring
563
+ instruction: 'The `none` backend writes .dz/backlog/jira-outbox/<id>.json — no external wiring needed.',
564
+ };
565
+ },
566
+ };
567
+ /** A declared-not-wired MCP seam: builds+persists the SAME outbox payload; verify() reports `manual`. */
568
+ function declaredMcpAdapter(backend, instruction) {
569
+ return {
570
+ backend,
571
+ async createIssue(draft, io) {
572
+ // v1: build + persist the same payload; NO live MCP call (FR-8.2). Honest stub — the wire is a TODO.
573
+ const outboxPath = io.writeOutbox(draft.sourceIdeaId, { backend, draft, note: 'declared-not-wired seam (v1)' });
574
+ return { backend, key: null, stub: true, outboxPath };
575
+ },
576
+ async verify() {
577
+ return { form: 'manual', ready: false, instruction };
578
+ },
579
+ };
580
+ }
581
+ /** The registry — coverage-tested (ADR-006 T-006a): keys ≡ BACKLOG_BACKENDS as a set. */
582
+ export const JIRA_ADAPTERS = {
583
+ 'jira-mcp': declaredMcpAdapter('jira-mcp', 'Wire a Jira MCP server: `claude mcp add jira ...` then add it to .mcp.json `mcpServers`. Live wiring is a post-v1 adapter.'),
584
+ 'copilot-mcp': declaredMcpAdapter('copilot-mcp', 'Wire a Copilot MCP server into .mcp.json `mcpServers`. Live wiring is a post-v1 adapter.'),
585
+ none: noneAdapter,
586
+ };
587
+ /** Factory: pick the configured adapter; an unknown value fell back to `none` in readBacklogConfig. */
588
+ export function resolveJiraAdapter(cfg) {
589
+ return JIRA_ADAPTERS[cfg.jira.adapter] ?? JIRA_ADAPTERS.none;
590
+ }
591
+ /** Production BacklogIO — writes `.dz/backlog/jira-outbox/<id>.json`. */
592
+ export function makeBacklogIO(projectRoot) {
593
+ return {
594
+ writeOutbox(id, payload) {
595
+ // HIGH-1: refuse any id that is not filesystem-safe — the filename is derived from it. A
596
+ // traversal id can never write outside jira-outbox/. Legit content-addressed ids always pass.
597
+ if (!isSafeId(id))
598
+ throw new Error(`refusing unsafe idea id for outbox path: ${JSON.stringify(id)}`);
599
+ const dir = jiraOutboxDir(projectRoot);
600
+ mkdirSync(dir, { recursive: true });
601
+ const path = join(dir, `${id}.json`);
602
+ writeFileSync(path, JSON.stringify(payload, null, 2));
603
+ return path;
604
+ },
605
+ };
606
+ }
607
+ /** Deterministic keeper within a cluster: most uses → oldest → smallest id (stable, testable). */
608
+ function pickKeeper(members) {
609
+ return [...members].sort((a, b) => b.uses - a.uses || Date.parse(a.createdTs) - Date.parse(b.createdTs) || (a.id < b.id ? -1 : 1))[0];
610
+ }
611
+ /**
612
+ * Batch-dedup the backlog: cluster near-duplicate ideas (cosine ≥ threshold when an embedder is
613
+ * available, EXACT text otherwise), keep one deterministic keeper per cluster, merge the others'
614
+ * `uses` into it. DRY-RUN by default (writes nothing); `--apply` SNAPSHOTS FIRST then mutates and
615
+ * ABORTS the mutation if the snapshot fails (NFR-6). Injectable `embed` for tests.
616
+ */
617
+ export async function harmonizeBacklog(projectRoot, opts = {}) {
618
+ const apply = opts.apply === true;
619
+ const threshold = opts.threshold !== undefined && opts.threshold > 0 && opts.threshold <= 1 ? opts.threshold : DEFAULT_DUPLICATE_THRESHOLD;
620
+ const ideas = readIdeas(projectRoot);
621
+ let embed = opts.embed;
622
+ if (embed === undefined) {
623
+ const resolved = await resolveAgentdbEmbedder(projectRoot);
624
+ embed = 'error' in resolved ? null : resolved.embed;
625
+ }
626
+ let fellBackToExact = embed === null;
627
+ // Build clusters (union-find over ≥threshold cosine, or exact-text groups).
628
+ const parent = ideas.map((_, i) => i);
629
+ const find = (x) => {
630
+ while (parent[x] !== x) {
631
+ parent[x] = parent[parent[x]];
632
+ x = parent[x];
633
+ }
634
+ return x;
635
+ };
636
+ const union = (a, b) => {
637
+ parent[find(a)] = find(b);
638
+ };
639
+ if (embed !== null && ideas.length >= 2) {
640
+ try {
641
+ const vecs = await Promise.all(ideas.map((i) => embed(`${BACKLOG_TASK_TYPE}: ${i.text}`)));
642
+ for (let a = 0; a < ideas.length; a += 1) {
643
+ for (let b = a + 1; b < ideas.length; b += 1) {
644
+ if (cosineSimilarity(vecs[a], vecs[b]) >= threshold)
645
+ union(a, b);
646
+ }
647
+ }
648
+ }
649
+ catch {
650
+ fellBackToExact = true;
651
+ }
652
+ }
653
+ if (fellBackToExact) {
654
+ const byText = new Map();
655
+ ideas.forEach((idea, i) => {
656
+ const first = byText.get(idea.text);
657
+ if (first === undefined)
658
+ byText.set(idea.text, i);
659
+ else
660
+ union(i, first);
661
+ });
662
+ }
663
+ const groups = new Map();
664
+ ideas.forEach((idea, i) => {
665
+ const root = find(i);
666
+ (groups.get(root) ?? groups.set(root, []).get(root)).push(idea);
667
+ });
668
+ const clusters = [];
669
+ const keepById = new Map();
670
+ const dropIds = new Set();
671
+ for (const members of groups.values()) {
672
+ if (members.length < 2)
673
+ continue;
674
+ const keeper = pickKeeper(members);
675
+ const drops = members.filter((m) => m.id !== keeper.id);
676
+ clusters.push({ keep: keeper.id, drops: drops.map((d) => d.id) });
677
+ keepById.set(keeper.id, keeper);
678
+ for (const d of drops)
679
+ dropIds.add(d.id);
680
+ }
681
+ const report = {
682
+ mode: apply ? 'apply' : 'dry-run',
683
+ fellBackToExact,
684
+ threshold,
685
+ clusters,
686
+ kept: clusters.length,
687
+ dropped: dropIds.size,
688
+ unique: ideas.length - clusters.length - dropIds.size,
689
+ };
690
+ if (!apply || dropIds.size === 0)
691
+ return report;
692
+ // --apply: snapshot FIRST, then merge uses into keepers and drop the rest.
693
+ const snapDest = join(backlogDir(projectRoot), `ideas.pre-harmonize-${Date.now()}.jsonl`);
694
+ const snap = snapshotIdeas(projectRoot, snapDest);
695
+ if (snap.error !== undefined)
696
+ return { ...report, error: `backup failed — drop aborted: ${snap.error}` };
697
+ const merged = new Map();
698
+ for (const [id, keeper] of keepById)
699
+ merged.set(id, { ...keeper });
700
+ for (const c of clusters) {
701
+ const keeper = merged.get(c.keep);
702
+ keeper.uses += c.drops.length;
703
+ }
704
+ const survivors = ideas
705
+ .filter((i) => !dropIds.has(i.id))
706
+ .map((i) => merged.get(i.id) ?? i);
707
+ writeIdeas(projectRoot, survivors);
708
+ // HIGH-A: prune the agentdb `dz-backlog` vectors for every removed idea, so a later `add` can't match
709
+ // an ORPHAN dzId (a DUPLICATE of a nonexistent idea). Best-effort for the structured store (already
710
+ // snapshotted + written) — but MED-E: the prune outcome must NOT be swallowed. If it failed, the store
711
+ // has dangling vectors; report it (a non-clean status) so the user knows, rather than a false success.
712
+ const prune = deleteAgentdbByDzIds(projectRoot, [...dropIds], { taskTypes: [BACKLOG_TASK_TYPE] });
713
+ return {
714
+ ...report,
715
+ snapshotPath: snapDest,
716
+ ...(prune.error !== undefined
717
+ ? { pruneError: `${dropIds.size} idea(s) removed from ideas.jsonl, but their agentdb vectors were NOT pruned: ${prune.error}` }
718
+ : {}),
719
+ };
720
+ }
721
+ /* ================================================================== */
722
+ /* MIRROR seam (ADR-001) — write idea vectors through the ONE seam. */
723
+ /* ================================================================== */
724
+ /**
725
+ * Mirror one idea's vector into the SHARED `.dz/agentdb.db` under `task_type:'dz-backlog'`, written
726
+ * DIRECTLY through `importVectorsToAgentdb` (upsert-by-dzId) — NOT the configurable vector engine.
727
+ * This is the load-bearing single-store guarantee (ADR-001/005): dedup ALWAYS searches agentdb, so
728
+ * the write must ALWAYS land in agentdb, no matter what `memory.vector.engine` says. Upsert-by-dzId
729
+ * makes a re-mirror idempotent (0 duplicate rows). `guardEmbedSpace` inside `importVectorsToAgentdb`
730
+ * covers the write (ADR-001 T-001c). Best-effort: NEVER blocks capture (I-1) — honest `{error}`.
731
+ */
732
+ export async function mirrorIdeaVector(projectRoot, idea) {
733
+ const emb = await resolveAgentdbEmbedder(projectRoot);
734
+ if ('error' in emb)
735
+ return { mirrored: 0, error: emb.error };
736
+ let vector;
737
+ try {
738
+ vector = await emb.embed(`${BACKLOG_TASK_TYPE}: ${idea.text}`); // SAME embed form the dedup query uses
739
+ }
740
+ catch (err) {
741
+ return { mirrored: 0, error: `embed failed: ${err instanceof Error ? err.message : String(err)}` };
742
+ }
743
+ const res = await importVectorsToAgentdb(projectRoot, [
744
+ {
745
+ dzId: idea.id,
746
+ vector,
747
+ text: idea.text,
748
+ taskType: BACKLOG_TASK_TYPE,
749
+ score: Math.max(0, Math.min(1, Number.isFinite(idea.goalAlignment) ? idea.goalAlignment : 0)),
750
+ metadata: { kind: 'dz-backlog-idea' },
751
+ },
752
+ ]);
753
+ return { mirrored: res.imported, ...(res.error !== undefined ? { error: res.error } : {}) };
754
+ }
755
+ //# sourceMappingURL=backlog.js.map