@dzhechkov/harness-core 0.3.150 → 0.4.2

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.
Files changed (111) hide show
  1. package/.dz-manifest.json +410 -62
  2. package/README.md +81 -3
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +10 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/backlog-embed.d.ts +94 -0
  7. package/dist/backlog-embed.d.ts.map +1 -0
  8. package/dist/backlog-embed.js +138 -0
  9. package/dist/backlog-embed.js.map +1 -0
  10. package/dist/backlog.d.ts +180 -7
  11. package/dist/backlog.d.ts.map +1 -1
  12. package/dist/backlog.js +429 -26
  13. package/dist/backlog.js.map +1 -1
  14. package/dist/challenge-panel.d.ts +3 -0
  15. package/dist/challenge-panel.d.ts.map +1 -1
  16. package/dist/challenge-panel.js +3 -0
  17. package/dist/challenge-panel.js.map +1 -1
  18. package/dist/export-holdout.d.ts +149 -0
  19. package/dist/export-holdout.d.ts.map +1 -0
  20. package/dist/export-holdout.js +198 -0
  21. package/dist/export-holdout.js.map +1 -0
  22. package/dist/feature-adr-checkpoints.d.ts +127 -0
  23. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  24. package/dist/feature-adr-checkpoints.js +199 -1
  25. package/dist/feature-adr-checkpoints.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +3 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +3 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +42 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +73 -1
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +28 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-blobs.generated.d.ts +33 -0
  39. package/dist/loop-blobs.generated.d.ts.map +1 -0
  40. package/dist/loop-blobs.generated.js +101 -0
  41. package/dist/loop-blobs.generated.js.map +1 -0
  42. package/dist/loop-lint.d.ts +63 -0
  43. package/dist/loop-lint.d.ts.map +1 -0
  44. package/dist/loop-lint.js +606 -0
  45. package/dist/loop-lint.js.map +1 -0
  46. package/dist/loop-plan.d.ts +416 -0
  47. package/dist/loop-plan.d.ts.map +1 -0
  48. package/dist/loop-plan.js +1151 -0
  49. package/dist/loop-plan.js.map +1 -0
  50. package/dist/loop-render.d.ts +104 -0
  51. package/dist/loop-render.d.ts.map +1 -0
  52. package/dist/loop-render.js +1068 -0
  53. package/dist/loop-render.js.map +1 -0
  54. package/dist/loop-trace.d.ts +229 -0
  55. package/dist/loop-trace.d.ts.map +1 -0
  56. package/dist/loop-trace.js +614 -0
  57. package/dist/loop-trace.js.map +1 -0
  58. package/dist/mutation-gate.d.ts +247 -0
  59. package/dist/mutation-gate.d.ts.map +1 -0
  60. package/dist/mutation-gate.js +535 -0
  61. package/dist/mutation-gate.js.map +1 -0
  62. package/dist/no-stubs.d.ts +53 -0
  63. package/dist/no-stubs.d.ts.map +1 -0
  64. package/dist/no-stubs.js +190 -0
  65. package/dist/no-stubs.js.map +1 -0
  66. package/dist/package-skill-layouts.d.ts +67 -0
  67. package/dist/package-skill-layouts.d.ts.map +1 -0
  68. package/dist/package-skill-layouts.js +81 -0
  69. package/dist/package-skill-layouts.js.map +1 -0
  70. package/dist/patterns.d.ts.map +1 -1
  71. package/dist/patterns.js +156 -75
  72. package/dist/patterns.js.map +1 -1
  73. package/dist/recall-domain-boost.d.ts.map +1 -1
  74. package/dist/recall-domain-boost.js +6 -0
  75. package/dist/recall-domain-boost.js.map +1 -1
  76. package/dist/statusline.d.ts +10 -2
  77. package/dist/statusline.d.ts.map +1 -1
  78. package/dist/statusline.js +122 -36
  79. package/dist/statusline.js.map +1 -1
  80. package/dist/store-lock.d.ts +108 -0
  81. package/dist/store-lock.d.ts.map +1 -0
  82. package/dist/store-lock.js +231 -0
  83. package/dist/store-lock.js.map +1 -0
  84. package/dist/workflows.d.ts +16 -22
  85. package/dist/workflows.d.ts.map +1 -1
  86. package/dist/workflows.js +17 -98
  87. package/dist/workflows.js.map +1 -1
  88. package/package.json +6 -4
  89. package/sbom.json +1073 -203
  90. package/src/agentdb-index.ts +10 -1
  91. package/src/backlog-embed.ts +156 -0
  92. package/src/backlog.ts +536 -28
  93. package/src/challenge-panel.ts +4 -0
  94. package/src/export-holdout.ts +235 -0
  95. package/src/feature-adr-checkpoints.ts +291 -1
  96. package/src/feature-adr-routing.ts +4 -0
  97. package/src/guard.ts +106 -1
  98. package/src/index.ts +62 -2
  99. package/src/loop-blobs.generated.ts +114 -0
  100. package/src/loop-lint.ts +643 -0
  101. package/src/loop-plan.ts +1419 -0
  102. package/src/loop-render.ts +1126 -0
  103. package/src/loop-trace.ts +727 -0
  104. package/src/mutation-gate.ts +701 -0
  105. package/src/no-stubs.ts +204 -0
  106. package/src/package-skill-layouts.ts +107 -0
  107. package/src/patterns.ts +135 -60
  108. package/src/recall-domain-boost.ts +6 -0
  109. package/src/statusline.ts +117 -30
  110. package/src/store-lock.ts +258 -0
  111. package/src/workflows.ts +18 -117
package/dist/backlog.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * records live in a dedicated `.dz/backlog/ideas.jsonl`, never in `.dz/memory/patterns.*`.
17
17
  */
18
18
  import { createHash } from 'node:crypto';
19
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
21
  // ── Outbound ports to the Brain engine (ADR-001): reuse VERBATIM, define none locally. ──
22
22
  // Backlog vectors are written DIRECTLY to the agentdb `dz-backlog` namespace via
@@ -24,11 +24,14 @@ import { dirname, join } from 'node:path';
24
24
  // ALWAYS hit the SAME store regardless of `memory.vector.engine` (ADR-001/005 — the single-store
25
25
  // property; routing through the generic engine let `engine:'rvf'` split writes from reads).
26
26
  import { cosineSimilarity, deleteAgentdbByDzIds, importVectorsToAgentdb, resolveAgentdbEmbedder, searchAgentdbPatterns } from './agentdb-index.js';
27
+ // The ONE dedup embed form + the two-signal pair decision (register-inflation fix, 2026-08-11) —
28
+ // shared with agentdb-index's write path so query and row vectors can never live in different spaces.
29
+ import { BACKLOG_TASK_TYPE, DEDUP_EMBED_FORM_VERSION, dedupEmbedText, dedupPairBand, lexicalContainment } from './backlog-embed.js';
27
30
  import { resolveEmbedModel } from './embedding-config.js';
28
31
  // Seeded PRNG — reuse the ONE mulberry32 already in the repo (no second RNG; ADR-004 determinism).
29
32
  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';
33
+ // Re-export the embed-form module through the backlog surface (index.ts stars this file).
34
+ export { BACKLOG_TASK_TYPE, DEDUP_EMBED_CAP, DEDUP_EMBED_FORM_VERSION, dedupEmbedText, dedupExcerpt, dedupPairBand, distinctiveTokens, lexicalContainment, } from './backlog-embed.js';
32
35
  export const EFFORT_MIN = 1;
33
36
  export const EFFORT_MAX = 5;
34
37
  /**
@@ -57,6 +60,17 @@ export function parseEffort(raw, fallback) {
57
60
  /* ================================================================== */
58
61
  export const DEFAULT_DUPLICATE_THRESHOLD = 0.92; // measured house constant (02 §R-D)
59
62
  export const DEFAULT_RELATEDNESS_FLOOR = 0.35; // GROUND_VECTOR_SIMILARITY_FLOOR (02 §R-D)
63
+ /**
64
+ * Two-signal corroboration cuts (register-inflation fix, MEASURED 2026-08-11 on the real store —
65
+ * see backlog-embed.ts for the full numbers). Containment on the labeled classes: register-only
66
+ * false-positive pairs <= 0.171, true duplicates that also cleared the cosine threshold >= 0.538 —
67
+ * 0.30 sits between with real margin on both sides. Subset band: long-vs-short same-idea pair
68
+ * measured at containment 0.971 / cosine 0.8019; the nearest non-duplicate (a template-family pair
69
+ * differing in ONE entity) at containment 0.889 — 0.95 splits them.
70
+ */
71
+ export const DEFAULT_CORROBORATION_FLOOR = 0.3;
72
+ export const DEFAULT_SUBSET_CONTAINMENT = 0.95;
73
+ export const DEFAULT_SUBSET_COSINE_FLOOR = 0.75;
60
74
  export const DEFAULT_ROULETTE_ALPHA = 1.5;
61
75
  export const DEFAULT_HALF_LIFE_DAYS = 30;
62
76
  export const DEFAULT_RECENCY_FLOOR = 0.3;
@@ -69,7 +83,13 @@ function clampNum(v, lo, hi, fallback, opts = {}) {
69
83
  }
70
84
  export function readBacklogConfig(projectRoot) {
71
85
  const fallback = {
72
- dedup: { duplicateThreshold: DEFAULT_DUPLICATE_THRESHOLD, relatednessFloor: DEFAULT_RELATEDNESS_FLOOR },
86
+ dedup: {
87
+ duplicateThreshold: DEFAULT_DUPLICATE_THRESHOLD,
88
+ relatednessFloor: DEFAULT_RELATEDNESS_FLOOR,
89
+ corroborationFloor: DEFAULT_CORROBORATION_FLOOR,
90
+ subsetContainment: DEFAULT_SUBSET_CONTAINMENT,
91
+ subsetCosineFloor: DEFAULT_SUBSET_COSINE_FLOOR,
92
+ },
73
93
  roulette: {
74
94
  alpha: DEFAULT_ROULETTE_ALPHA,
75
95
  halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
@@ -95,7 +115,13 @@ export function readBacklogConfig(projectRoot) {
95
115
  floor = Math.min(Math.max(floor, 0), dup);
96
116
  const adapter = isBacklogBackend(b.jira?.adapter) ? b.jira.adapter : 'none'; // unknown ⇒ none (ADR-006 T-006d)
97
117
  return {
98
- dedup: { duplicateThreshold: dup, relatednessFloor: floor },
118
+ dedup: {
119
+ duplicateThreshold: dup,
120
+ relatednessFloor: floor,
121
+ corroborationFloor: clampNum(b.dedup?.corroborationFloor, 0, 1, DEFAULT_CORROBORATION_FLOOR),
122
+ subsetContainment: clampNum(b.dedup?.subsetContainment, 0, 1, DEFAULT_SUBSET_CONTAINMENT),
123
+ subsetCosineFloor: clampNum(b.dedup?.subsetCosineFloor, 0, 1, DEFAULT_SUBSET_COSINE_FLOOR),
124
+ },
99
125
  roulette: {
100
126
  alpha: clampNum(b.roulette?.alpha, Number.MIN_VALUE, Number.MAX_VALUE, DEFAULT_ROULETTE_ALPHA),
101
127
  halfLifeDays: clampNum(b.roulette?.halfLifeDays, Number.MIN_VALUE, Number.MAX_VALUE, DEFAULT_HALF_LIFE_DAYS),
@@ -173,6 +199,10 @@ function normaliseIdea(raw) {
173
199
  rec.enrichedPath = raw.enrichedPath;
174
200
  if (typeof raw.jiraKey === 'string')
175
201
  rec.jiraKey = raw.jiraKey;
202
+ if (typeof raw.statusTs === 'string')
203
+ rec.statusTs = raw.statusTs;
204
+ if (typeof raw.statusReason === 'string')
205
+ rec.statusReason = raw.statusReason;
176
206
  return rec;
177
207
  }
178
208
  /** Read the append-only store. A corrupt line is SKIPPED (never fatal) — the whole store never throws. */
@@ -209,8 +239,196 @@ export function writeIdeas(projectRoot, ideas) {
209
239
  mkdirSync(dirname(path), { recursive: true });
210
240
  const body = ideas.map((i) => JSON.stringify(i)).join('\n') + (ideas.length > 0 ? '\n' : '');
211
241
  const tmp = `${path}.tmp-${process.pid}`;
212
- writeFileSync(tmp, body);
213
- renameSync(tmp, path);
242
+ try {
243
+ writeFileSync(tmp, body);
244
+ renameSync(tmp, path);
245
+ }
246
+ catch (e) {
247
+ try {
248
+ unlinkSync(tmp);
249
+ }
250
+ catch { /* best-effort litter cleanup */ }
251
+ throw e;
252
+ }
253
+ }
254
+ /**
255
+ * THE transition table — the single source of truth for which statuses each verb may leave from.
256
+ * `target` is where the verb lands. For the IDEMPOTENT verbs (ship/drop) a record ALREADY at
257
+ * `target` is a no-op that SAYS so (not an error) — a cleanup batch may name already-marked ideas.
258
+ * `reopen` is deliberately NOT idempotent: reopening an already-`new` idea almost always means the
259
+ * user grabbed the wrong id (they believed it was shipped), so it is refused loudly. A status in
260
+ * neither set is an ILLEGAL transition — notably `dropped → shipped` and `shipped → dropped` both
261
+ * require an explicit `reopen` first; a terminal state never silently becomes the other terminal.
262
+ */
263
+ export const IDEA_TRANSITIONS = {
264
+ ship: { target: 'shipped', from: ['new', 'enriched', 'in-progress'], idempotent: true },
265
+ drop: { target: 'dropped', from: ['new', 'enriched', 'in-progress'], idempotent: true },
266
+ reopen: { target: 'new', from: ['shipped', 'dropped', 'in-progress'], idempotent: false },
267
+ };
268
+ /** PURE transition legality check against {@link IDEA_TRANSITIONS}. */
269
+ export function checkTransition(verb, current) {
270
+ const t = IDEA_TRANSITIONS[verb];
271
+ if (t.from.includes(current))
272
+ return { kind: 'transition', to: t.target };
273
+ if (current === t.target && t.idempotent)
274
+ return { kind: 'noop' };
275
+ if (current === t.target) {
276
+ return { kind: 'illegal', message: `cannot ${verb} a ${current} idea — it is already ${t.target} (did you mean a different id?)` };
277
+ }
278
+ return {
279
+ kind: 'illegal',
280
+ message: `cannot ${verb} a ${current} idea (${verb}: ${t.from.join('|')} → ${t.target})`,
281
+ };
282
+ }
283
+ /**
284
+ * Resolve a (possibly short) id prefix against the store's ids — the roulette prints 8-char ids, so
285
+ * the transition verbs accept them. An EXACT id always wins; otherwise a UNIQUE prefix resolves and
286
+ * anything else (no match / several matches) is a loud error, never a silent no-op (the
287
+ * inconclusive-≠-pass discipline from dz skills-verify).
288
+ */
289
+ export function resolveIdPrefix(ids, prefix) {
290
+ if (ids.includes(prefix))
291
+ return { kind: 'ok', id: prefix };
292
+ const matches = [...new Set(ids.filter((id) => id.startsWith(prefix)))];
293
+ if (matches.length === 1)
294
+ return { kind: 'ok', id: matches[0] };
295
+ if (matches.length === 0)
296
+ return { kind: 'not-found', prefix };
297
+ return { kind: 'ambiguous', prefix, matches: matches.sort() };
298
+ }
299
+ /**
300
+ * Apply a batch of status transitions to `.dz/backlog/ideas.jsonl`.
301
+ *
302
+ * ALL-OR-NOTHING: any unresolved/ambiguous prefix or illegal transition fails the WHOLE batch with
303
+ * NO write (fail-closed — a partial batch that "mostly worked" is how shipped work silently stays
304
+ * eligible). LINE-PRESERVING: untouched lines (including corrupt ones readIdeas would skip) are
305
+ * emitted byte-for-byte; a transitioned line is re-serialized from ITS OWN parsed object, so
306
+ * `JSON.parse`/`stringify` key-order preservation keeps every non-status field byte-identical —
307
+ * unknown fields a future schema might add survive untouched. (Documented limit: exotic JSON
308
+ * scalars — lone surrogates, -0, >2^53 integers — would not round-trip byte-identically; no schema
309
+ * field has those shapes, ids are 16-hex.) ATOMIC: tmp + rename, same as {@link writeIdeas}, so a
310
+ * crash never leaves a torn store. (Documented limit: the read-modify-write is NOT locked against a
311
+ * concurrent writer — pre-existing across every backlog writer, same as writeIdeas.)
312
+ */
313
+ export function transitionIdeas(projectRoot, verb, prefixes, opts = {}) {
314
+ const dryRun = opts.dryRun === true;
315
+ const path = ideasPath(projectRoot);
316
+ if (!existsSync(path)) {
317
+ return { ok: false, dryRun, changes: [], errors: ['no backlog store — nothing captured yet (dz backlog add "<idea>")'], written: false };
318
+ }
319
+ let text;
320
+ try {
321
+ text = readFileSync(path, 'utf-8');
322
+ }
323
+ catch (e) {
324
+ return { ok: false, dryRun, changes: [], errors: [`cannot read ${path}: ${e.message}`], written: false };
325
+ }
326
+ // Preserve the file's exact line structure: split WITHOUT discarding anything; untouched entries
327
+ // (and unparseable lines) go back out verbatim.
328
+ const lines = text.split('\n');
329
+ const parsed = [];
330
+ for (let i = 0; i < lines.length; i += 1) {
331
+ const trimmed = (lines[i] ?? '').trim();
332
+ if (trimmed === '')
333
+ continue;
334
+ try {
335
+ const obj = JSON.parse(trimmed);
336
+ if (typeof obj.id === 'string' && obj.id !== '') {
337
+ parsed.push({ index: i, obj, id: obj.id, status: typeof obj.status === 'string' ? obj.status : 'new' });
338
+ }
339
+ }
340
+ catch {
341
+ /* corrupt line — left byte-for-byte as-is */
342
+ }
343
+ }
344
+ const ids = parsed.map((p) => p.id);
345
+ const errors = [];
346
+ const changes = [];
347
+ const resolvedIds = [];
348
+ const seen = new Set();
349
+ for (const prefix of prefixes) {
350
+ if (!isSafeId(prefix)) {
351
+ errors.push(`refusing an unsafe idea id: ${JSON.stringify(prefix)}`);
352
+ continue;
353
+ }
354
+ const res = resolveIdPrefix(ids, prefix);
355
+ if (res.kind === 'not-found') {
356
+ errors.push(`no idea matches ${prefix} — run dz backlog list to see the ids`);
357
+ continue;
358
+ }
359
+ if (res.kind === 'ambiguous') {
360
+ errors.push(`ambiguous prefix ${prefix} — matches ${res.matches.join(', ')}; give more characters`);
361
+ continue;
362
+ }
363
+ if (seen.has(res.id))
364
+ continue; // the same idea named twice in one batch — count it once
365
+ seen.add(res.id);
366
+ resolvedIds.push(res.id);
367
+ }
368
+ // Legality pass over EVERY resolved id BEFORE any mutation (all-or-nothing).
369
+ //
370
+ // Legality and mutation are decided over the SAME set of lines: every line bearing the id
371
+ // (a hand-edited duplicate is a store-integrity problem, and deciding legality on the FIRST
372
+ // line while mutating ALL lines produced both failure modes — a first-`shipped` twin turned
373
+ // `ship` into a no-op that left the second `new` twin roulette-eligible forever, and a
374
+ // first-`new` twin let `ship` drag a second `dropped` twin through the dropped→shipped hop
375
+ // checkTransition refuses). The rule: ANY twin at an illegal source state refuses the WHOLE
376
+ // id loudly, naming the duplicate, and writes nothing; otherwise every not-yet-at-target twin
377
+ // transitions, so no stale twin remains. All twins already at target = a said no-op.
378
+ const nowIso = opts.nowIso ?? new Date().toISOString();
379
+ const toMutate = [];
380
+ for (const id of resolvedIds) {
381
+ const entries = parsed.filter((p) => p.id === id);
382
+ const dupNote = entries.length > 1 ? ` — id ${id} appears ${entries.length}× in the store (duplicate lines; resolve the duplicate by hand)` : '';
383
+ const twins = entries.map((entry) => {
384
+ const current = (['new', 'enriched', 'in-progress', 'shipped', 'dropped'].includes(entry.status) ? entry.status : 'new');
385
+ return { entry, current, check: checkTransition(verb, current) };
386
+ });
387
+ const textPreview = typeof entries[0].obj.text === 'string' ? entries[0].obj.text.slice(0, 70) : '';
388
+ const illegal = twins.find((t) => t.check.kind === 'illegal');
389
+ if (illegal !== undefined) {
390
+ errors.push(`${id}: ${illegal.check.message}${dupNote}`);
391
+ continue;
392
+ }
393
+ const transitioning = twins.filter((t) => t.check.kind === 'transition');
394
+ if (transitioning.length === 0) {
395
+ // EVERY twin is already at the target — a genuine, said no-op (nothing stale can remain).
396
+ changes.push({ id, from: twins[0].current, to: twins[0].current, action: 'noop', text: textPreview });
397
+ continue;
398
+ }
399
+ changes.push({ id, from: transitioning[0].current, to: transitioning[0].check.to, action: 'transitioned', text: textPreview });
400
+ for (const t of transitioning)
401
+ toMutate.push(t.entry);
402
+ }
403
+ if (errors.length > 0) {
404
+ return { ok: false, dryRun, changes: [], errors, written: false }; // fail-closed: NOTHING was written
405
+ }
406
+ const target = IDEA_TRANSITIONS[verb].target;
407
+ if (dryRun || toMutate.length === 0) {
408
+ return { ok: true, dryRun, changes, errors: [], written: false };
409
+ }
410
+ for (const p of toMutate) {
411
+ p.obj.status = target;
412
+ p.obj.statusTs = nowIso;
413
+ if (opts.reason !== undefined && opts.reason !== '')
414
+ p.obj.statusReason = opts.reason;
415
+ else
416
+ delete p.obj.statusReason; // a stale reason describes the PREVIOUS transition — never carry it
417
+ lines[p.index] = JSON.stringify(p.obj);
418
+ }
419
+ const tmp = `${path}.tmp-${process.pid}`;
420
+ try {
421
+ writeFileSync(tmp, lines.join('\n'));
422
+ renameSync(tmp, path);
423
+ }
424
+ catch (e) {
425
+ try {
426
+ unlinkSync(tmp);
427
+ }
428
+ catch { /* best-effort litter cleanup — never mask the original failure */ }
429
+ return { ok: false, dryRun, changes: [], errors: [`store write failed: ${e.message}`], written: false };
430
+ }
431
+ return { ok: true, dryRun, changes, errors: [], written: true };
214
432
  }
215
433
  /** The entry + its one-line rationale, written verbatim so the file explains itself. */
216
434
  const BACKLOG_IGNORE_ENTRY = '.dz/backlog/';
@@ -277,8 +495,17 @@ function dominantEol(text) {
277
495
  /** Atomic write (tmp + rename in the SAME dir) — the ideas.jsonl discipline: a crash never truncates. */
278
496
  function writeFileAtomic(path, body) {
279
497
  const tmp = `${path}.tmp-${process.pid}`;
280
- writeFileSync(tmp, body);
281
- renameSync(tmp, path);
498
+ try {
499
+ writeFileSync(tmp, body);
500
+ renameSync(tmp, path);
501
+ }
502
+ catch (e) {
503
+ try {
504
+ unlinkSync(tmp);
505
+ }
506
+ catch { /* best-effort litter cleanup */ }
507
+ throw e;
508
+ }
282
509
  }
283
510
  /**
284
511
  * Ensure the backlog store is gitignored, at the moment the feature FIRST creates it (idea ec4cd60d).
@@ -323,11 +550,15 @@ export function snapshotIdeas(projectRoot, dest) {
323
550
  }
324
551
  }
325
552
  /**
326
- * THE LOAD-BEARING CLASSIFIER (ADR-002 T-002a) PURE. Bands the top-1 raw cosine:
327
- * DUPLICATE cosine duplicateThreshold (default 0.92)
328
- * RELATED relatednessFloor cosine < dup (default [0.35, 0.92)) ⇒ create + attach relatedIds
553
+ * THE LOAD-BEARING CLASSIFIER (ADR-002 T-002a, two-signal since the register-inflation fix) — PURE.
554
+ * Bands via {@link dedupPairBand} (shared with harmonize):
555
+ * DUPLICATE top-1 cosine duplicateThreshold AND corroborated (containment corroborationFloor
556
+ * or unknown) — OR any candidate in the SUBSET band (containment ≥ subsetContainment,
557
+ * cosine ≥ subsetCosineFloor): the same idea re-captured at a different length.
558
+ * RELATED relatednessFloor ≤ cosine < dup, PLUS any DEMOTED candidate (≥ threshold cosine that
559
+ * failed corroboration — the register-only false positive) ⇒ create + attach relatedIds.
329
560
  * NEW cosine < relatednessFloor
330
- * Flip either cut and exactly one boundary fixture crosses a band — the test REDS.
561
+ * Flip any cut and exactly one boundary fixture crosses a band — the test REDS.
331
562
  */
332
563
  export function classifyDedup(candidates, cfg, opts = {}) {
333
564
  // HIGH-4: a non-finite cosine (NaN/±Infinity) sorts unpredictably and can shove a real 0.97 duplicate
@@ -338,14 +569,54 @@ export function classifyDedup(candidates, cfg, opts = {}) {
338
569
  const exactTextOnly = opts.exactTextOnly === true;
339
570
  if (top === undefined)
340
571
  return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly };
341
- if (top.cosine >= cfg.duplicateThreshold) {
342
- return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, topMatchId: top.id, relatedIds: [], exactTextOnly };
572
+ const bands = new Map(sorted.map((c) => [c.id, dedupPairBand(c.cosine, c.containment, cfg)]));
573
+ if (bands.get(top.id) === 'duplicate') {
574
+ return {
575
+ action: 'duplicate',
576
+ cosine: top.cosine,
577
+ matchedId: top.id,
578
+ topMatchId: top.id,
579
+ relatedIds: [],
580
+ exactTextOnly,
581
+ ...(top.containment !== undefined ? { containment: top.containment } : {}),
582
+ };
583
+ }
584
+ // Demotion is OBSERVABLE, never silent: the highest-cosine candidate that cleared the threshold but
585
+ // failed corroboration is reported (the zombie x publish-gate incident surface).
586
+ const demotedTop = sorted.find((c) => bands.get(c.id) === 'demoted');
587
+ const demoted = demotedTop !== undefined ? { demoted: { id: demotedTop.id, cosine: demotedTop.cosine, containment: demotedTop.containment ?? 0 } } : {};
588
+ // SUBSET promotion: highest-cosine candidate whose distinctive vocabulary contains (or is contained
589
+ // by) the new idea's — length alone must not move the verdict.
590
+ const subset = sorted.find((c) => bands.get(c.id) === 'subset-duplicate');
591
+ if (subset !== undefined) {
592
+ return {
593
+ action: 'duplicate',
594
+ cosine: subset.cosine,
595
+ matchedId: subset.id,
596
+ topMatchId: top.id,
597
+ relatedIds: [],
598
+ exactTextOnly,
599
+ subsetMatch: true,
600
+ ...(subset.containment !== undefined ? { containment: subset.containment } : {}),
601
+ ...demoted,
602
+ };
343
603
  }
344
- const related = sorted.filter((c) => c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold);
604
+ // A demoted candidate IS related (its cosine is above the whole related band by construction).
605
+ const related = sorted.filter((c) => bands.get(c.id) === 'demoted' || (c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold));
606
+ const topContainment = top.containment !== undefined ? { containment: top.containment } : {};
345
607
  if (related.length > 0) {
346
- return { action: 'related', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: related.map((c) => c.id), exactTextOnly };
608
+ return {
609
+ action: 'related',
610
+ cosine: top.cosine,
611
+ matchedId: undefined,
612
+ topMatchId: top.id,
613
+ relatedIds: related.map((c) => c.id),
614
+ exactTextOnly,
615
+ ...topContainment,
616
+ ...demoted,
617
+ };
347
618
  }
348
- return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly };
619
+ return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly, ...topContainment };
349
620
  }
350
621
  /**
351
622
  * Production dedup: embed+search the `dz-backlog` vectors (RAW COSINE via `searchAgentdbPatterns`,
@@ -357,8 +628,10 @@ export async function dedupIdea(projectRoot, text, cfg, deps = {}) {
357
628
  const ideas = deps.ideas ?? readIdeas(projectRoot);
358
629
  const search = deps.search ??
359
630
  ((root, query) => searchAgentdbPatterns(root, query, { taskTypes: [BACKLOG_TASK_TYPE], limit: 20 }));
360
- // Match the stored embed form `${taskType}: ${text}` (02 §R-B) so query and row vectors co-locate.
361
- const result = await search(projectRoot, `${BACKLOG_TASK_TYPE}: ${text}`);
631
+ // Match the stored embed form the BOUNDED v2 excerpt (backlog-embed.ts) so query and row
632
+ // vectors co-locate. Full-length embeds INVERTED the duplicate signal on long texts (MEASURED:
633
+ // genuine long-RU paraphrases 0.35–0.61 vs unrelated long-RU pairs up to 0.9195).
634
+ const result = await search(projectRoot, dedupEmbedText(text));
362
635
  // MED-5: build the VALID candidate set FIRST (a hit must carry a dzId AND a finite cosine). Only
363
636
  // then decide — a malformed hit (missing dzId / NaN cosine) must NOT bypass the exact-text net.
364
637
  // HIGH-A: a hit whose dzId is NOT a member of the CURRENT ideas.jsonl is an ORPHAN vector (its
@@ -366,10 +639,19 @@ export async function dedupIdea(projectRoot, text, cfg, deps = {}) {
366
639
  // of a nonexistent idea (a dead match). Drop orphans before deciding — defense in depth alongside the
367
640
  // prune-on-removal in `harmonizeBacklog`.
368
641
  const liveIds = new Set(ideas.map((i) => i.id));
642
+ const textById = new Map(ideas.map((i) => [i.id, i.text]));
369
643
  const candidates = [];
370
644
  for (const h of result.hits) {
371
- if (typeof h.dzId === 'string' && liveIds.has(h.dzId) && Number.isFinite(h.similarity))
372
- candidates.push({ id: h.dzId, cosine: h.similarity });
645
+ if (typeof h.dzId === 'string' && liveIds.has(h.dzId) && Number.isFinite(h.similarity)) {
646
+ // Lexical corroboration over the FULL texts (the excerpt bounds only the embedding): a member
647
+ // of liveIds always has a text, so containment is always attached on this path.
648
+ const candText = textById.get(h.dzId);
649
+ candidates.push({
650
+ id: h.dzId,
651
+ cosine: h.similarity,
652
+ ...(candText !== undefined ? { containment: lexicalContainment(text, candText) } : {}),
653
+ });
654
+ }
373
655
  }
374
656
  if (result.error !== undefined || candidates.length === 0) {
375
657
  // No usable semantic signal (embedder unavailable, nothing mirrored, or only malformed hits) ⇒
@@ -752,7 +1034,7 @@ function declaredMcpAdapter(backend, instruction) {
752
1034
  return {
753
1035
  backend,
754
1036
  async createIssue(draft, io) {
755
- // v1: build + persist the same payload; NO live MCP call (FR-8.2). Honest stub — the wire is a TODO.
1037
+ // v1: build + persist the same payload; NO live MCP call (FR-8.2). Honest stub — the wire is a TODO. no-stubs: pre-existing documented declared-not-wired seam (FR-8.2), untouched by the dedup fix
756
1038
  const outboxPath = io.writeOutbox(draft.sourceIdeaId, { backend, draft, note: 'declared-not-wired seam (v1)' });
757
1039
  return { backend, key: null, stub: true, outboxPath };
758
1040
  },
@@ -821,10 +1103,15 @@ export async function harmonizeBacklog(projectRoot, opts = {}) {
821
1103
  };
822
1104
  if (embed !== null && ideas.length >= 2) {
823
1105
  try {
824
- const vecs = await Promise.all(ideas.map((i) => embed(`${BACKLOG_TASK_TYPE}: ${i.text}`)));
1106
+ // The SAME bounded embed form + two-signal pair decision as capture-time dedup (backlog-embed.ts):
1107
+ // batch harmonize and `dz backlog add` must never disagree about what a duplicate is. The
1108
+ // corroboration knobs come from config; the cosine threshold honors the `--threshold` override.
1109
+ const bandCfg = { ...readBacklogConfig(projectRoot).dedup, duplicateThreshold: threshold };
1110
+ const vecs = await Promise.all(ideas.map((i) => embed(dedupEmbedText(i.text))));
825
1111
  for (let a = 0; a < ideas.length; a += 1) {
826
1112
  for (let b = a + 1; b < ideas.length; b += 1) {
827
- if (cosineSimilarity(vecs[a], vecs[b]) >= threshold)
1113
+ const band = dedupPairBand(cosineSimilarity(vecs[a], vecs[b]), lexicalContainment(ideas[a].text, ideas[b].text), bandCfg);
1114
+ if (band === 'duplicate' || band === 'subset-duplicate')
828
1115
  union(a, b);
829
1116
  }
830
1117
  }
@@ -918,7 +1205,7 @@ export async function mirrorIdeaVector(projectRoot, idea) {
918
1205
  return { mirrored: 0, error: emb.error };
919
1206
  let vector;
920
1207
  try {
921
- vector = await emb.embed(`${BACKLOG_TASK_TYPE}: ${idea.text}`); // SAME embed form the dedup query uses
1208
+ vector = await emb.embed(dedupEmbedText(idea.text)); // SAME bounded embed form the dedup query uses (v2)
922
1209
  }
923
1210
  catch (err) {
924
1211
  return { mirrored: 0, error: `embed failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -935,4 +1222,120 @@ export async function mirrorIdeaVector(projectRoot, idea) {
935
1222
  ]);
936
1223
  return { mirrored: res.imported, ...(res.error !== undefined ? { error: res.error } : {}) };
937
1224
  }
1225
+ export function absorbedLogPath(projectRoot) {
1226
+ return join(backlogDir(projectRoot), 'absorbed.jsonl');
1227
+ }
1228
+ /**
1229
+ * Append the absorbed capture to `.dz/backlog/absorbed.jsonl`. Until this log existed, a DUPLICATE
1230
+ * verdict was the only backlog path that DESTROYED user text: the 2026-08-05 zombie-process idea and
1231
+ * the 2026-08-11 patient-values idea were both false absorptions whose original wording is gone
1232
+ * forever (`uses++` keeps no copy). The log makes every absorption auditable and reversible
1233
+ * (`dz backlog add` the logged text again after fixing the config). Best-effort: never throws, never
1234
+ * blocks capture — an unwritable log returns `{error}` for the caller to surface.
1235
+ */
1236
+ export function recordAbsorption(projectRoot, entry) {
1237
+ try {
1238
+ mkdirSync(backlogDir(projectRoot), { recursive: true });
1239
+ appendFileSync(absorbedLogPath(projectRoot), `${JSON.stringify(entry)}\n`);
1240
+ return {};
1241
+ }
1242
+ catch (err) {
1243
+ return { error: err instanceof Error ? err.message : String(err) };
1244
+ }
1245
+ }
1246
+ /* ================================================================== */
1247
+ /* EMBED-FORM migration — one-shot re-mirror when the form version bumps. */
1248
+ /* ================================================================== */
1249
+ /** Marker recording which dedup embed FORM the mirrored vectors were built with. */
1250
+ export function backlogEmbedFormPath(projectRoot) {
1251
+ return join(backlogDir(projectRoot), 'embed-form.json');
1252
+ }
1253
+ /** The recorded embed-form version, or 1 (the pre-marker full-text form) when absent/corrupt. */
1254
+ export function readBacklogEmbedFormVersion(projectRoot) {
1255
+ const p = backlogEmbedFormPath(projectRoot);
1256
+ if (!existsSync(p))
1257
+ return 1;
1258
+ try {
1259
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'));
1260
+ return typeof parsed.version === 'number' && Number.isFinite(parsed.version) ? parsed.version : 1;
1261
+ }
1262
+ catch {
1263
+ return 1;
1264
+ }
1265
+ }
1266
+ /**
1267
+ * Bring the mirrored dz-backlog vectors into the CURRENT embed form ({@link DEDUP_EMBED_FORM_VERSION}).
1268
+ * v1 vectors were full-text embeds; v2 queries are bounded excerpts — comparing across the two forms
1269
+ * is a query-vs-row space split for any idea longer than the cap (the exact "stale space rot" the
1270
+ * ADR-001 comments warn about). Re-mirrors every idea through the ONE seam (upsert-by-dzId, so it is
1271
+ * idempotent), and writes the marker ONLY after every re-mirror succeeded — a half-migrated store
1272
+ * keeps claiming the old version and is retried next time. Best-effort: NEVER blocks capture; a
1273
+ * `deferred` outcome must be surfaced by the caller (a silent stale space is the trap).
1274
+ */
1275
+ export async function ensureBacklogEmbedForm(projectRoot, deps = {}) {
1276
+ const stored = readBacklogEmbedFormVersion(projectRoot);
1277
+ if (stored >= DEDUP_EMBED_FORM_VERSION)
1278
+ return { action: 'current', version: stored };
1279
+ const ideas = readIdeas(projectRoot);
1280
+ const writeMarker = () => {
1281
+ mkdirSync(backlogDir(projectRoot), { recursive: true });
1282
+ writeFileAtomic(backlogEmbedFormPath(projectRoot), `${JSON.stringify({ version: DEDUP_EMBED_FORM_VERSION })}\n`);
1283
+ };
1284
+ if (ideas.length === 0) {
1285
+ // Nothing to re-embed — stamp the marker so a store born under v2 never "migrates".
1286
+ try {
1287
+ writeMarker();
1288
+ return { action: 'empty', version: DEDUP_EMBED_FORM_VERSION };
1289
+ }
1290
+ catch (err) {
1291
+ return { action: 'deferred', version: stored, error: `marker write failed: ${err instanceof Error ? err.message : String(err)}` };
1292
+ }
1293
+ }
1294
+ let remirrored = 0;
1295
+ if (deps.mirror !== undefined) {
1296
+ for (const idea of ideas) {
1297
+ const res = await deps.mirror(projectRoot, idea);
1298
+ if (res.error !== undefined) {
1299
+ // Abort WITHOUT the marker: the store stays honestly marked v1 and the migration retries later.
1300
+ return { action: 'deferred', version: stored, remirrored, error: res.error };
1301
+ }
1302
+ remirrored += 1;
1303
+ }
1304
+ }
1305
+ else {
1306
+ // BATCHED production path: ONE embedder init + ONE upsert transaction. A per-idea
1307
+ // `mirrorIdeaVector` loop re-initializes the transformer model per idea — MEASURED 2026-08-11:
1308
+ // 105 ideas = 105 model loads, minutes of pure init time.
1309
+ const emb = await resolveAgentdbEmbedder(projectRoot);
1310
+ if ('error' in emb)
1311
+ return { action: 'deferred', version: stored, remirrored: 0, error: emb.error };
1312
+ const rows = [];
1313
+ try {
1314
+ for (const idea of ideas) {
1315
+ rows.push({
1316
+ dzId: idea.id,
1317
+ vector: await emb.embed(dedupEmbedText(idea.text)),
1318
+ text: idea.text,
1319
+ taskType: BACKLOG_TASK_TYPE,
1320
+ score: Math.max(0, Math.min(1, Number.isFinite(idea.goalAlignment) ? idea.goalAlignment : 0)),
1321
+ metadata: { kind: 'dz-backlog-idea' },
1322
+ });
1323
+ }
1324
+ }
1325
+ catch (err) {
1326
+ return { action: 'deferred', version: stored, remirrored: 0, error: `embed failed: ${err instanceof Error ? err.message : String(err)}` };
1327
+ }
1328
+ const res = await importVectorsToAgentdb(projectRoot, rows);
1329
+ if (res.error !== undefined)
1330
+ return { action: 'deferred', version: stored, remirrored: res.imported, error: res.error };
1331
+ remirrored = res.imported;
1332
+ }
1333
+ try {
1334
+ writeMarker();
1335
+ }
1336
+ catch (err) {
1337
+ return { action: 'deferred', version: stored, remirrored, error: `marker write failed: ${err instanceof Error ? err.message : String(err)}` };
1338
+ }
1339
+ return { action: 'migrated', version: DEDUP_EMBED_FORM_VERSION, remirrored };
1340
+ }
938
1341
  //# sourceMappingURL=backlog.js.map