@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/src/backlog.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { createHash } from 'node:crypto';
20
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
21
21
  import { dirname, join } from 'node:path';
22
22
 
23
23
  // ── Outbound ports to the Brain engine (ADR-001): reuse VERBATIM, define none locally. ──
@@ -26,12 +26,26 @@ import { dirname, join } from 'node:path';
26
26
  // ALWAYS hit the SAME store regardless of `memory.vector.engine` (ADR-001/005 — the single-store
27
27
  // property; routing through the generic engine let `engine:'rvf'` split writes from reads).
28
28
  import { cosineSimilarity, deleteAgentdbByDzIds, importVectorsToAgentdb, resolveAgentdbEmbedder, searchAgentdbPatterns } from './agentdb-index.js';
29
+ // The ONE dedup embed form + the two-signal pair decision (register-inflation fix, 2026-08-11) —
30
+ // shared with agentdb-index's write path so query and row vectors can never live in different spaces.
31
+ import { BACKLOG_TASK_TYPE, DEDUP_EMBED_FORM_VERSION, dedupEmbedText, dedupPairBand, lexicalContainment } from './backlog-embed.js';
29
32
  import { resolveEmbedModel } from './embedding-config.js';
30
33
  // Seeded PRNG — reuse the ONE mulberry32 already in the repo (no second RNG; ADR-004 determinism).
31
34
  import { mulberry32 } from './compounding.js';
32
35
 
33
- /** The vector-store namespace that isolates ideas from lessons (ADR-001/005). */
34
- export const BACKLOG_TASK_TYPE = 'dz-backlog';
36
+ // Re-export the embed-form module through the backlog surface (index.ts stars this file).
37
+ export {
38
+ BACKLOG_TASK_TYPE,
39
+ DEDUP_EMBED_CAP,
40
+ DEDUP_EMBED_FORM_VERSION,
41
+ dedupEmbedText,
42
+ dedupExcerpt,
43
+ dedupPairBand,
44
+ distinctiveTokens,
45
+ lexicalContainment,
46
+ type DedupBandConfig,
47
+ type DedupPairBand,
48
+ } from './backlog-embed.js';
35
49
 
36
50
  /* ================================================================== */
37
51
  /* DOMAIN TYPES (04 Domain Model) */
@@ -54,6 +68,10 @@ export interface IdeaRecord {
54
68
  enrichedPath?: string; // features/<slug>/ once enriched (FR-5)
55
69
  jiraKey?: string; // IssueRef.key or the outbox ref (ADR-006)
56
70
  tags: string[];
71
+ /** ISO timestamp of the LAST explicit status transition (`dz backlog ship|drop|reopen`). */
72
+ statusTs?: string;
73
+ /** The `--reason` given at the LAST explicit transition — describes THAT transition only. */
74
+ statusReason?: string;
57
75
  }
58
76
 
59
77
  /** The compass (ADR-003) — user-owned, edited as a file. */
@@ -86,6 +104,20 @@ export interface DedupVerdict {
86
104
  readonly relatedIds: readonly string[];
87
105
  /** True when the embedder was unavailable and dedup degraded to exact-text (ADR-002 §degrade). */
88
106
  readonly exactTextOnly: boolean;
107
+ /** Lexical containment of the DECIDING candidate (matched for a duplicate, top-1 otherwise), when known. */
108
+ readonly containment?: number;
109
+ /**
110
+ * Set when a candidate cleared the cosine threshold but FAILED lexical corroboration and was
111
+ * demoted to the related band — the register-only false positive (zombie x publish-gate @ 0.941,
112
+ * containment 0.077, MEASURED 2026-08-05/11). Observability: the CLI prints it, so a demotion is
113
+ * never a silent judgment call.
114
+ */
115
+ readonly demoted?: { readonly id: string; readonly cosine: number; readonly containment: number };
116
+ /**
117
+ * True when the duplicate verdict came from the SUBSET band (containment >= subsetContainment at
118
+ * sub-threshold cosine) — the same idea re-captured at a different length.
119
+ */
120
+ readonly subsetMatch?: boolean;
89
121
  }
90
122
 
91
123
  /* ── Effort parsing (idea 86096d6d): a clamp the user cannot see is a silent surprise. ── */
@@ -126,13 +158,30 @@ export function parseEffort(raw: string | undefined, fallback: number): EffortPa
126
158
 
127
159
  export const DEFAULT_DUPLICATE_THRESHOLD = 0.92; // measured house constant (02 §R-D)
128
160
  export const DEFAULT_RELATEDNESS_FLOOR = 0.35; // GROUND_VECTOR_SIMILARITY_FLOOR (02 §R-D)
161
+ /**
162
+ * Two-signal corroboration cuts (register-inflation fix, MEASURED 2026-08-11 on the real store —
163
+ * see backlog-embed.ts for the full numbers). Containment on the labeled classes: register-only
164
+ * false-positive pairs <= 0.171, true duplicates that also cleared the cosine threshold >= 0.538 —
165
+ * 0.30 sits between with real margin on both sides. Subset band: long-vs-short same-idea pair
166
+ * measured at containment 0.971 / cosine 0.8019; the nearest non-duplicate (a template-family pair
167
+ * differing in ONE entity) at containment 0.889 — 0.95 splits them.
168
+ */
169
+ export const DEFAULT_CORROBORATION_FLOOR = 0.3;
170
+ export const DEFAULT_SUBSET_CONTAINMENT = 0.95;
171
+ export const DEFAULT_SUBSET_COSINE_FLOOR = 0.75;
129
172
  export const DEFAULT_ROULETTE_ALPHA = 1.5;
130
173
  export const DEFAULT_HALF_LIFE_DAYS = 30;
131
174
  export const DEFAULT_RECENCY_FLOOR = 0.3;
132
175
  export const DEFAULT_EFFORT = 3;
133
176
 
134
177
  export interface BacklogConfig {
135
- readonly dedup: { readonly duplicateThreshold: number; readonly relatednessFloor: number };
178
+ readonly dedup: {
179
+ readonly duplicateThreshold: number;
180
+ readonly relatednessFloor: number;
181
+ readonly corroborationFloor: number;
182
+ readonly subsetContainment: number;
183
+ readonly subsetCosineFloor: number;
184
+ };
136
185
  readonly roulette: {
137
186
  readonly alpha: number;
138
187
  readonly halfLifeDays: number;
@@ -150,7 +199,13 @@ function clampNum(v: unknown, lo: number, hi: number, fallback: number, opts: {
150
199
 
151
200
  export function readBacklogConfig(projectRoot: string): BacklogConfig {
152
201
  const fallback: BacklogConfig = {
153
- dedup: { duplicateThreshold: DEFAULT_DUPLICATE_THRESHOLD, relatednessFloor: DEFAULT_RELATEDNESS_FLOOR },
202
+ dedup: {
203
+ duplicateThreshold: DEFAULT_DUPLICATE_THRESHOLD,
204
+ relatednessFloor: DEFAULT_RELATEDNESS_FLOOR,
205
+ corroborationFloor: DEFAULT_CORROBORATION_FLOOR,
206
+ subsetContainment: DEFAULT_SUBSET_CONTAINMENT,
207
+ subsetCosineFloor: DEFAULT_SUBSET_COSINE_FLOOR,
208
+ },
154
209
  roulette: {
155
210
  alpha: DEFAULT_ROULETTE_ALPHA,
156
211
  halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
@@ -164,7 +219,13 @@ export function readBacklogConfig(projectRoot: string): BacklogConfig {
164
219
  try {
165
220
  const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as {
166
221
  backlog?: {
167
- dedup?: { duplicateThreshold?: unknown; relatednessFloor?: unknown };
222
+ dedup?: {
223
+ duplicateThreshold?: unknown;
224
+ relatednessFloor?: unknown;
225
+ corroborationFloor?: unknown;
226
+ subsetContainment?: unknown;
227
+ subsetCosineFloor?: unknown;
228
+ };
168
229
  roulette?: { alpha?: unknown; halfLifeDays?: unknown; recencyFloor?: unknown; defaultEffort?: unknown };
169
230
  jira?: { adapter?: unknown };
170
231
  };
@@ -180,7 +241,13 @@ export function readBacklogConfig(projectRoot: string): BacklogConfig {
180
241
  floor = Math.min(Math.max(floor, 0), dup);
181
242
  const adapter = isBacklogBackend(b.jira?.adapter) ? b.jira!.adapter : 'none'; // unknown ⇒ none (ADR-006 T-006d)
182
243
  return {
183
- dedup: { duplicateThreshold: dup, relatednessFloor: floor },
244
+ dedup: {
245
+ duplicateThreshold: dup,
246
+ relatednessFloor: floor,
247
+ corroborationFloor: clampNum(b.dedup?.corroborationFloor, 0, 1, DEFAULT_CORROBORATION_FLOOR),
248
+ subsetContainment: clampNum(b.dedup?.subsetContainment, 0, 1, DEFAULT_SUBSET_CONTAINMENT),
249
+ subsetCosineFloor: clampNum(b.dedup?.subsetCosineFloor, 0, 1, DEFAULT_SUBSET_COSINE_FLOOR),
250
+ },
184
251
  roulette: {
185
252
  alpha: clampNum(b.roulette?.alpha, Number.MIN_VALUE, Number.MAX_VALUE, DEFAULT_ROULETTE_ALPHA),
186
253
  halfLifeDays: clampNum(b.roulette?.halfLifeDays, Number.MIN_VALUE, Number.MAX_VALUE, DEFAULT_HALF_LIFE_DAYS),
@@ -259,6 +326,8 @@ function normaliseIdea(raw: Record<string, unknown>): IdeaRecord | undefined {
259
326
  if (typeof raw.proposal === 'string') rec.proposal = raw.proposal;
260
327
  if (typeof raw.enrichedPath === 'string') rec.enrichedPath = raw.enrichedPath;
261
328
  if (typeof raw.jiraKey === 'string') rec.jiraKey = raw.jiraKey;
329
+ if (typeof raw.statusTs === 'string') rec.statusTs = raw.statusTs;
330
+ if (typeof raw.statusReason === 'string') rec.statusReason = raw.statusReason;
262
331
  return rec;
263
332
  }
264
333
 
@@ -292,8 +361,236 @@ export function writeIdeas(projectRoot: string, ideas: readonly IdeaRecord[]): v
292
361
  mkdirSync(dirname(path), { recursive: true });
293
362
  const body = ideas.map((i) => JSON.stringify(i)).join('\n') + (ideas.length > 0 ? '\n' : '');
294
363
  const tmp = `${path}.tmp-${process.pid}`;
295
- writeFileSync(tmp, body);
296
- renameSync(tmp, path);
364
+ try {
365
+ writeFileSync(tmp, body);
366
+ renameSync(tmp, path);
367
+ } catch (e) {
368
+ try { unlinkSync(tmp); } catch { /* best-effort litter cleanup */ }
369
+ throw e;
370
+ }
371
+ }
372
+
373
+ /* ================================================================== */
374
+ /* STATUS TRANSITIONS — dz backlog ship|drop|reopen */
375
+ /* */
376
+ /* The MEASURED defect (2026-08-10): the ONLY status mutation was */
377
+ /* roulette --commit → in-progress, so work finished WITHOUT a commit */
378
+ /* (the normal flow: spin, see the pick, do the work) stayed `new` */
379
+ /* FOREVER and the roulette kept re-drawing already-shipped ideas */
380
+ /* (21 of 100 records were hand-edited to `shipped` because no CLI */
381
+ /* surface existed). This section is that missing surface. */
382
+ /* ================================================================== */
383
+
384
+ export type TransitionVerb = 'ship' | 'drop' | 'reopen';
385
+
386
+ /**
387
+ * THE transition table — the single source of truth for which statuses each verb may leave from.
388
+ * `target` is where the verb lands. For the IDEMPOTENT verbs (ship/drop) a record ALREADY at
389
+ * `target` is a no-op that SAYS so (not an error) — a cleanup batch may name already-marked ideas.
390
+ * `reopen` is deliberately NOT idempotent: reopening an already-`new` idea almost always means the
391
+ * user grabbed the wrong id (they believed it was shipped), so it is refused loudly. A status in
392
+ * neither set is an ILLEGAL transition — notably `dropped → shipped` and `shipped → dropped` both
393
+ * require an explicit `reopen` first; a terminal state never silently becomes the other terminal.
394
+ */
395
+ export const IDEA_TRANSITIONS: Record<
396
+ TransitionVerb,
397
+ { readonly target: IdeaStatus; readonly from: readonly IdeaStatus[]; readonly idempotent: boolean }
398
+ > = {
399
+ ship: { target: 'shipped', from: ['new', 'enriched', 'in-progress'], idempotent: true },
400
+ drop: { target: 'dropped', from: ['new', 'enriched', 'in-progress'], idempotent: true },
401
+ reopen: { target: 'new', from: ['shipped', 'dropped', 'in-progress'], idempotent: false },
402
+ };
403
+
404
+ export type TransitionCheck =
405
+ | { readonly kind: 'transition'; readonly to: IdeaStatus }
406
+ | { readonly kind: 'noop' }
407
+ | { readonly kind: 'illegal'; readonly message: string };
408
+
409
+ /** PURE transition legality check against {@link IDEA_TRANSITIONS}. */
410
+ export function checkTransition(verb: TransitionVerb, current: IdeaStatus): TransitionCheck {
411
+ const t = IDEA_TRANSITIONS[verb];
412
+ if (t.from.includes(current)) return { kind: 'transition', to: t.target };
413
+ if (current === t.target && t.idempotent) return { kind: 'noop' };
414
+ if (current === t.target) {
415
+ return { kind: 'illegal', message: `cannot ${verb} a ${current} idea — it is already ${t.target} (did you mean a different id?)` };
416
+ }
417
+ return {
418
+ kind: 'illegal',
419
+ message: `cannot ${verb} a ${current} idea (${verb}: ${t.from.join('|')} → ${t.target})`,
420
+ };
421
+ }
422
+
423
+ export type PrefixResolution =
424
+ | { readonly kind: 'ok'; readonly id: string }
425
+ | { readonly kind: 'not-found'; readonly prefix: string }
426
+ | { readonly kind: 'ambiguous'; readonly prefix: string; readonly matches: readonly string[] };
427
+
428
+ /**
429
+ * Resolve a (possibly short) id prefix against the store's ids — the roulette prints 8-char ids, so
430
+ * the transition verbs accept them. An EXACT id always wins; otherwise a UNIQUE prefix resolves and
431
+ * anything else (no match / several matches) is a loud error, never a silent no-op (the
432
+ * inconclusive-≠-pass discipline from dz skills-verify).
433
+ */
434
+ export function resolveIdPrefix(ids: readonly string[], prefix: string): PrefixResolution {
435
+ if (ids.includes(prefix)) return { kind: 'ok', id: prefix };
436
+ const matches = [...new Set(ids.filter((id) => id.startsWith(prefix)))];
437
+ if (matches.length === 1) return { kind: 'ok', id: matches[0]! };
438
+ if (matches.length === 0) return { kind: 'not-found', prefix };
439
+ return { kind: 'ambiguous', prefix, matches: matches.sort() };
440
+ }
441
+
442
+ export interface TransitionChange {
443
+ readonly id: string;
444
+ readonly from: IdeaStatus;
445
+ readonly to: IdeaStatus;
446
+ readonly action: 'transitioned' | 'noop';
447
+ /** First 70 chars of the idea text — for the human confirmation line. */
448
+ readonly text: string;
449
+ }
450
+
451
+ export interface TransitionReport {
452
+ /** True iff every requested id resolved and every transition was legal (no-ops count as ok). */
453
+ readonly ok: boolean;
454
+ readonly dryRun: boolean;
455
+ readonly changes: readonly TransitionChange[];
456
+ readonly errors: readonly string[];
457
+ /** True iff the store file was actually rewritten. */
458
+ readonly written: boolean;
459
+ }
460
+
461
+ /**
462
+ * Apply a batch of status transitions to `.dz/backlog/ideas.jsonl`.
463
+ *
464
+ * ALL-OR-NOTHING: any unresolved/ambiguous prefix or illegal transition fails the WHOLE batch with
465
+ * NO write (fail-closed — a partial batch that "mostly worked" is how shipped work silently stays
466
+ * eligible). LINE-PRESERVING: untouched lines (including corrupt ones readIdeas would skip) are
467
+ * emitted byte-for-byte; a transitioned line is re-serialized from ITS OWN parsed object, so
468
+ * `JSON.parse`/`stringify` key-order preservation keeps every non-status field byte-identical —
469
+ * unknown fields a future schema might add survive untouched. (Documented limit: exotic JSON
470
+ * scalars — lone surrogates, -0, >2^53 integers — would not round-trip byte-identically; no schema
471
+ * field has those shapes, ids are 16-hex.) ATOMIC: tmp + rename, same as {@link writeIdeas}, so a
472
+ * crash never leaves a torn store. (Documented limit: the read-modify-write is NOT locked against a
473
+ * concurrent writer — pre-existing across every backlog writer, same as writeIdeas.)
474
+ */
475
+ export function transitionIdeas(
476
+ projectRoot: string,
477
+ verb: TransitionVerb,
478
+ prefixes: readonly string[],
479
+ opts: { reason?: string; dryRun?: boolean; nowIso?: string } = {},
480
+ ): TransitionReport {
481
+ const dryRun = opts.dryRun === true;
482
+ const path = ideasPath(projectRoot);
483
+ if (!existsSync(path)) {
484
+ return { ok: false, dryRun, changes: [], errors: ['no backlog store — nothing captured yet (dz backlog add "<idea>")'], written: false };
485
+ }
486
+ let text: string;
487
+ try {
488
+ text = readFileSync(path, 'utf-8');
489
+ } catch (e) {
490
+ return { ok: false, dryRun, changes: [], errors: [`cannot read ${path}: ${(e as Error).message}`], written: false };
491
+ }
492
+ // Preserve the file's exact line structure: split WITHOUT discarding anything; untouched entries
493
+ // (and unparseable lines) go back out verbatim.
494
+ const lines = text.split('\n');
495
+ interface ParsedLine {
496
+ readonly index: number;
497
+ readonly obj: Record<string, unknown>;
498
+ readonly id: string;
499
+ readonly status: string;
500
+ }
501
+ const parsed: ParsedLine[] = [];
502
+ for (let i = 0; i < lines.length; i += 1) {
503
+ const trimmed = (lines[i] ?? '').trim();
504
+ if (trimmed === '') continue;
505
+ try {
506
+ const obj = JSON.parse(trimmed) as Record<string, unknown>;
507
+ if (typeof obj.id === 'string' && obj.id !== '') {
508
+ parsed.push({ index: i, obj, id: obj.id, status: typeof obj.status === 'string' ? obj.status : 'new' });
509
+ }
510
+ } catch {
511
+ /* corrupt line — left byte-for-byte as-is */
512
+ }
513
+ }
514
+ const ids = parsed.map((p) => p.id);
515
+ const errors: string[] = [];
516
+ const changes: TransitionChange[] = [];
517
+ const resolvedIds: string[] = [];
518
+ const seen = new Set<string>();
519
+ for (const prefix of prefixes) {
520
+ if (!isSafeId(prefix)) {
521
+ errors.push(`refusing an unsafe idea id: ${JSON.stringify(prefix)}`);
522
+ continue;
523
+ }
524
+ const res = resolveIdPrefix(ids, prefix);
525
+ if (res.kind === 'not-found') {
526
+ errors.push(`no idea matches ${prefix} — run dz backlog list to see the ids`);
527
+ continue;
528
+ }
529
+ if (res.kind === 'ambiguous') {
530
+ errors.push(`ambiguous prefix ${prefix} — matches ${res.matches.join(', ')}; give more characters`);
531
+ continue;
532
+ }
533
+ if (seen.has(res.id)) continue; // the same idea named twice in one batch — count it once
534
+ seen.add(res.id);
535
+ resolvedIds.push(res.id);
536
+ }
537
+ // Legality pass over EVERY resolved id BEFORE any mutation (all-or-nothing).
538
+ //
539
+ // Legality and mutation are decided over the SAME set of lines: every line bearing the id
540
+ // (a hand-edited duplicate is a store-integrity problem, and deciding legality on the FIRST
541
+ // line while mutating ALL lines produced both failure modes — a first-`shipped` twin turned
542
+ // `ship` into a no-op that left the second `new` twin roulette-eligible forever, and a
543
+ // first-`new` twin let `ship` drag a second `dropped` twin through the dropped→shipped hop
544
+ // checkTransition refuses). The rule: ANY twin at an illegal source state refuses the WHOLE
545
+ // id loudly, naming the duplicate, and writes nothing; otherwise every not-yet-at-target twin
546
+ // transitions, so no stale twin remains. All twins already at target = a said no-op.
547
+ const nowIso = opts.nowIso ?? new Date().toISOString();
548
+ const toMutate: ParsedLine[] = [];
549
+ for (const id of resolvedIds) {
550
+ const entries = parsed.filter((p) => p.id === id);
551
+ const dupNote = entries.length > 1 ? ` — id ${id} appears ${entries.length}× in the store (duplicate lines; resolve the duplicate by hand)` : '';
552
+ const twins = entries.map((entry) => {
553
+ const current = (['new', 'enriched', 'in-progress', 'shipped', 'dropped'].includes(entry.status) ? entry.status : 'new') as IdeaStatus;
554
+ return { entry, current, check: checkTransition(verb, current) };
555
+ });
556
+ const textPreview = typeof entries[0]!.obj.text === 'string' ? (entries[0]!.obj.text as string).slice(0, 70) : '';
557
+ const illegal = twins.find((t) => t.check.kind === 'illegal');
558
+ if (illegal !== undefined) {
559
+ errors.push(`${id}: ${(illegal.check as { message: string }).message}${dupNote}`);
560
+ continue;
561
+ }
562
+ const transitioning = twins.filter((t) => t.check.kind === 'transition');
563
+ if (transitioning.length === 0) {
564
+ // EVERY twin is already at the target — a genuine, said no-op (nothing stale can remain).
565
+ changes.push({ id, from: twins[0]!.current, to: twins[0]!.current, action: 'noop', text: textPreview });
566
+ continue;
567
+ }
568
+ changes.push({ id, from: transitioning[0]!.current, to: (transitioning[0]!.check as { to: IdeaStatus }).to, action: 'transitioned', text: textPreview });
569
+ for (const t of transitioning) toMutate.push(t.entry);
570
+ }
571
+ if (errors.length > 0) {
572
+ return { ok: false, dryRun, changes: [], errors, written: false }; // fail-closed: NOTHING was written
573
+ }
574
+ const target = IDEA_TRANSITIONS[verb].target;
575
+ if (dryRun || toMutate.length === 0) {
576
+ return { ok: true, dryRun, changes, errors: [], written: false };
577
+ }
578
+ for (const p of toMutate) {
579
+ p.obj.status = target;
580
+ p.obj.statusTs = nowIso;
581
+ if (opts.reason !== undefined && opts.reason !== '') p.obj.statusReason = opts.reason;
582
+ else delete p.obj.statusReason; // a stale reason describes the PREVIOUS transition — never carry it
583
+ lines[p.index] = JSON.stringify(p.obj);
584
+ }
585
+ const tmp = `${path}.tmp-${process.pid}`;
586
+ try {
587
+ writeFileSync(tmp, lines.join('\n'));
588
+ renameSync(tmp, path);
589
+ } catch (e) {
590
+ try { unlinkSync(tmp); } catch { /* best-effort litter cleanup — never mask the original failure */ }
591
+ return { ok: false, dryRun, changes: [], errors: [`store write failed: ${(e as Error).message}`], written: false };
592
+ }
593
+ return { ok: true, dryRun, changes, errors: [], written: true };
297
594
  }
298
595
 
299
596
  /* ── Store privacy (idea ec4cd60d): raw ideas are prompt-class PRIVATE content, like recall-usage.jsonl. ── */
@@ -373,8 +670,13 @@ function dominantEol(text: string): '\r\n' | '\n' {
373
670
  /** Atomic write (tmp + rename in the SAME dir) — the ideas.jsonl discipline: a crash never truncates. */
374
671
  function writeFileAtomic(path: string, body: string): void {
375
672
  const tmp = `${path}.tmp-${process.pid}`;
376
- writeFileSync(tmp, body);
377
- renameSync(tmp, path);
673
+ try {
674
+ writeFileSync(tmp, body);
675
+ renameSync(tmp, path);
676
+ } catch (e) {
677
+ try { unlinkSync(tmp); } catch { /* best-effort litter cleanup */ }
678
+ throw e;
679
+ }
378
680
  }
379
681
 
380
682
  /**
@@ -429,18 +731,28 @@ export function snapshotIdeas(projectRoot: string, dest: string): SnapshotResult
429
731
  /* DEDUP (AM-2 / ADR-002) — raw cosine bands, NOT RRF. */
430
732
  /* ================================================================== */
431
733
 
432
- /** One existing-idea comparison candidate: its id and the RAW cosine of the new idea against it. */
734
+ /**
735
+ * One existing-idea comparison candidate: its id, the RAW cosine of the new idea against it, and
736
+ * (when the candidate's text is available) the lexical containment of the pair. `containment`
737
+ * undefined = corroboration unavailable (degraded search hit) ⇒ the cosine band decides alone,
738
+ * exactly the pre-fix behavior.
739
+ */
433
740
  export interface DedupCandidate {
434
741
  readonly id: string;
435
742
  readonly cosine: number;
743
+ readonly containment?: number;
436
744
  }
437
745
 
438
746
  /**
439
- * THE LOAD-BEARING CLASSIFIER (ADR-002 T-002a) PURE. Bands the top-1 raw cosine:
440
- * DUPLICATE cosine duplicateThreshold (default 0.92)
441
- * RELATED relatednessFloor cosine < dup (default [0.35, 0.92)) ⇒ create + attach relatedIds
747
+ * THE LOAD-BEARING CLASSIFIER (ADR-002 T-002a, two-signal since the register-inflation fix) — PURE.
748
+ * Bands via {@link dedupPairBand} (shared with harmonize):
749
+ * DUPLICATE top-1 cosine duplicateThreshold AND corroborated (containment corroborationFloor
750
+ * or unknown) — OR any candidate in the SUBSET band (containment ≥ subsetContainment,
751
+ * cosine ≥ subsetCosineFloor): the same idea re-captured at a different length.
752
+ * RELATED relatednessFloor ≤ cosine < dup, PLUS any DEMOTED candidate (≥ threshold cosine that
753
+ * failed corroboration — the register-only false positive) ⇒ create + attach relatedIds.
442
754
  * NEW cosine < relatednessFloor
443
- * Flip either cut and exactly one boundary fixture crosses a band — the test REDS.
755
+ * Flip any cut and exactly one boundary fixture crosses a band — the test REDS.
444
756
  */
445
757
  export function classifyDedup(
446
758
  candidates: readonly DedupCandidate[],
@@ -454,14 +766,55 @@ export function classifyDedup(
454
766
  const top = sorted[0];
455
767
  const exactTextOnly = opts.exactTextOnly === true;
456
768
  if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly };
457
- if (top.cosine >= cfg.duplicateThreshold) {
458
- return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, topMatchId: top.id, relatedIds: [], exactTextOnly };
769
+ const bands = new Map(sorted.map((c) => [c.id, dedupPairBand(c.cosine, c.containment, cfg)]));
770
+ if (bands.get(top.id) === 'duplicate') {
771
+ return {
772
+ action: 'duplicate',
773
+ cosine: top.cosine,
774
+ matchedId: top.id,
775
+ topMatchId: top.id,
776
+ relatedIds: [],
777
+ exactTextOnly,
778
+ ...(top.containment !== undefined ? { containment: top.containment } : {}),
779
+ };
459
780
  }
460
- const related = sorted.filter((c) => c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold);
781
+ // Demotion is OBSERVABLE, never silent: the highest-cosine candidate that cleared the threshold but
782
+ // failed corroboration is reported (the zombie x publish-gate incident surface).
783
+ const demotedTop = sorted.find((c) => bands.get(c.id) === 'demoted');
784
+ const demoted =
785
+ demotedTop !== undefined ? { demoted: { id: demotedTop.id, cosine: demotedTop.cosine, containment: demotedTop.containment ?? 0 } } : {};
786
+ // SUBSET promotion: highest-cosine candidate whose distinctive vocabulary contains (or is contained
787
+ // by) the new idea's — length alone must not move the verdict.
788
+ const subset = sorted.find((c) => bands.get(c.id) === 'subset-duplicate');
789
+ if (subset !== undefined) {
790
+ return {
791
+ action: 'duplicate',
792
+ cosine: subset.cosine,
793
+ matchedId: subset.id,
794
+ topMatchId: top.id,
795
+ relatedIds: [],
796
+ exactTextOnly,
797
+ subsetMatch: true,
798
+ ...(subset.containment !== undefined ? { containment: subset.containment } : {}),
799
+ ...demoted,
800
+ };
801
+ }
802
+ // A demoted candidate IS related (its cosine is above the whole related band by construction).
803
+ const related = sorted.filter((c) => bands.get(c.id) === 'demoted' || (c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold));
804
+ const topContainment = top.containment !== undefined ? { containment: top.containment } : {};
461
805
  if (related.length > 0) {
462
- return { action: 'related', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: related.map((c) => c.id), exactTextOnly };
806
+ return {
807
+ action: 'related',
808
+ cosine: top.cosine,
809
+ matchedId: undefined,
810
+ topMatchId: top.id,
811
+ relatedIds: related.map((c) => c.id),
812
+ exactTextOnly,
813
+ ...topContainment,
814
+ ...demoted,
815
+ };
463
816
  }
464
- return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly };
817
+ return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly, ...topContainment };
465
818
  }
466
819
 
467
820
  /** Injectable deps so the production dedup path is testable without a live agentdb. */
@@ -486,8 +839,10 @@ export async function dedupIdea(projectRoot: string, text: string, cfg: BacklogC
486
839
  const search =
487
840
  deps.search ??
488
841
  ((root: string, query: string) => searchAgentdbPatterns(root, query, { taskTypes: [BACKLOG_TASK_TYPE], limit: 20 }));
489
- // Match the stored embed form `${taskType}: ${text}` (02 §R-B) so query and row vectors co-locate.
490
- const result = await search(projectRoot, `${BACKLOG_TASK_TYPE}: ${text}`);
842
+ // Match the stored embed form the BOUNDED v2 excerpt (backlog-embed.ts) so query and row
843
+ // vectors co-locate. Full-length embeds INVERTED the duplicate signal on long texts (MEASURED:
844
+ // genuine long-RU paraphrases 0.35–0.61 vs unrelated long-RU pairs up to 0.9195).
845
+ const result = await search(projectRoot, dedupEmbedText(text));
491
846
  // MED-5: build the VALID candidate set FIRST (a hit must carry a dzId AND a finite cosine). Only
492
847
  // then decide — a malformed hit (missing dzId / NaN cosine) must NOT bypass the exact-text net.
493
848
  // HIGH-A: a hit whose dzId is NOT a member of the CURRENT ideas.jsonl is an ORPHAN vector (its
@@ -495,9 +850,19 @@ export async function dedupIdea(projectRoot: string, text: string, cfg: BacklogC
495
850
  // of a nonexistent idea (a dead match). Drop orphans before deciding — defense in depth alongside the
496
851
  // prune-on-removal in `harmonizeBacklog`.
497
852
  const liveIds = new Set(ideas.map((i) => i.id));
853
+ const textById = new Map(ideas.map((i) => [i.id, i.text]));
498
854
  const candidates: DedupCandidate[] = [];
499
855
  for (const h of result.hits) {
500
- if (typeof h.dzId === 'string' && liveIds.has(h.dzId) && Number.isFinite(h.similarity)) candidates.push({ id: h.dzId, cosine: h.similarity });
856
+ if (typeof h.dzId === 'string' && liveIds.has(h.dzId) && Number.isFinite(h.similarity)) {
857
+ // Lexical corroboration over the FULL texts (the excerpt bounds only the embedding): a member
858
+ // of liveIds always has a text, so containment is always attached on this path.
859
+ const candText = textById.get(h.dzId);
860
+ candidates.push({
861
+ id: h.dzId,
862
+ cosine: h.similarity,
863
+ ...(candText !== undefined ? { containment: lexicalContainment(text, candText) } : {}),
864
+ });
865
+ }
501
866
  }
502
867
  if (result.error !== undefined || candidates.length === 0) {
503
868
  // No usable semantic signal (embedder unavailable, nothing mirrored, or only malformed hits) ⇒
@@ -1011,7 +1376,7 @@ function declaredMcpAdapter(backend: 'jira-mcp' | 'copilot-mcp', instruction: st
1011
1376
  return {
1012
1377
  backend,
1013
1378
  async createIssue(draft, io) {
1014
- // v1: build + persist the same payload; NO live MCP call (FR-8.2). Honest stub — the wire is a TODO.
1379
+ // 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
1015
1380
  const outboxPath = io.writeOutbox(draft.sourceIdeaId, { backend, draft, note: 'declared-not-wired seam (v1)' });
1016
1381
  return { backend, key: null, stub: true, outboxPath };
1017
1382
  },
@@ -1122,10 +1487,15 @@ export async function harmonizeBacklog(
1122
1487
  };
1123
1488
  if (embed !== null && ideas.length >= 2) {
1124
1489
  try {
1125
- const vecs = await Promise.all(ideas.map((i) => embed!(`${BACKLOG_TASK_TYPE}: ${i.text}`)));
1490
+ // The SAME bounded embed form + two-signal pair decision as capture-time dedup (backlog-embed.ts):
1491
+ // batch harmonize and `dz backlog add` must never disagree about what a duplicate is. The
1492
+ // corroboration knobs come from config; the cosine threshold honors the `--threshold` override.
1493
+ const bandCfg = { ...readBacklogConfig(projectRoot).dedup, duplicateThreshold: threshold };
1494
+ const vecs = await Promise.all(ideas.map((i) => embed!(dedupEmbedText(i.text))));
1126
1495
  for (let a = 0; a < ideas.length; a += 1) {
1127
1496
  for (let b = a + 1; b < ideas.length; b += 1) {
1128
- if (cosineSimilarity(vecs[a]!, vecs[b]!) >= threshold) union(a, b);
1497
+ const band = dedupPairBand(cosineSimilarity(vecs[a]!, vecs[b]!), lexicalContainment(ideas[a]!.text, ideas[b]!.text), bandCfg);
1498
+ if (band === 'duplicate' || band === 'subset-duplicate') union(a, b);
1129
1499
  }
1130
1500
  }
1131
1501
  } catch {
@@ -1215,7 +1585,7 @@ export async function mirrorIdeaVector(projectRoot: string, idea: IdeaRecord): P
1215
1585
  if ('error' in emb) return { mirrored: 0, error: emb.error };
1216
1586
  let vector: Float32Array;
1217
1587
  try {
1218
- vector = await emb.embed(`${BACKLOG_TASK_TYPE}: ${idea.text}`); // SAME embed form the dedup query uses
1588
+ vector = await emb.embed(dedupEmbedText(idea.text)); // SAME bounded embed form the dedup query uses (v2)
1219
1589
  } catch (err) {
1220
1590
  return { mirrored: 0, error: `embed failed: ${err instanceof Error ? err.message : String(err)}` };
1221
1591
  }
@@ -1231,3 +1601,141 @@ export async function mirrorIdeaVector(projectRoot: string, idea: IdeaRecord): P
1231
1601
  ]);
1232
1602
  return { mirrored: res.imported, ...(res.error !== undefined ? { error: res.error } : {}) };
1233
1603
  }
1604
+
1605
+ /* ================================================================== */
1606
+ /* ABSORPTION audit — a duplicate verdict must never DESTROY the text. */
1607
+ /* ================================================================== */
1608
+
1609
+ /** One absorbed capture: everything needed to audit — or reverse — a duplicate verdict later. */
1610
+ export interface AbsorptionEntry {
1611
+ readonly ts: string;
1612
+ readonly matchedId: string;
1613
+ readonly cosine: number;
1614
+ readonly containment?: number;
1615
+ readonly subsetMatch?: boolean;
1616
+ /** The FULL incoming text that was absorbed (uses++ on the match, no record created). */
1617
+ readonly text: string;
1618
+ }
1619
+
1620
+ export function absorbedLogPath(projectRoot: string): string {
1621
+ return join(backlogDir(projectRoot), 'absorbed.jsonl');
1622
+ }
1623
+
1624
+ /**
1625
+ * Append the absorbed capture to `.dz/backlog/absorbed.jsonl`. Until this log existed, a DUPLICATE
1626
+ * verdict was the only backlog path that DESTROYED user text: the 2026-08-05 zombie-process idea and
1627
+ * the 2026-08-11 patient-values idea were both false absorptions whose original wording is gone
1628
+ * forever (`uses++` keeps no copy). The log makes every absorption auditable and reversible
1629
+ * (`dz backlog add` the logged text again after fixing the config). Best-effort: never throws, never
1630
+ * blocks capture — an unwritable log returns `{error}` for the caller to surface.
1631
+ */
1632
+ export function recordAbsorption(projectRoot: string, entry: AbsorptionEntry): { error?: string } {
1633
+ try {
1634
+ mkdirSync(backlogDir(projectRoot), { recursive: true });
1635
+ appendFileSync(absorbedLogPath(projectRoot), `${JSON.stringify(entry)}\n`);
1636
+ return {};
1637
+ } catch (err) {
1638
+ return { error: err instanceof Error ? err.message : String(err) };
1639
+ }
1640
+ }
1641
+
1642
+ /* ================================================================== */
1643
+ /* EMBED-FORM migration — one-shot re-mirror when the form version bumps. */
1644
+ /* ================================================================== */
1645
+
1646
+ /** Marker recording which dedup embed FORM the mirrored vectors were built with. */
1647
+ export function backlogEmbedFormPath(projectRoot: string): string {
1648
+ return join(backlogDir(projectRoot), 'embed-form.json');
1649
+ }
1650
+
1651
+ /** The recorded embed-form version, or 1 (the pre-marker full-text form) when absent/corrupt. */
1652
+ export function readBacklogEmbedFormVersion(projectRoot: string): number {
1653
+ const p = backlogEmbedFormPath(projectRoot);
1654
+ if (!existsSync(p)) return 1;
1655
+ try {
1656
+ const parsed = JSON.parse(readFileSync(p, 'utf-8')) as { version?: unknown };
1657
+ return typeof parsed.version === 'number' && Number.isFinite(parsed.version) ? parsed.version : 1;
1658
+ } catch {
1659
+ return 1;
1660
+ }
1661
+ }
1662
+
1663
+ export interface EmbedFormReport {
1664
+ readonly action: 'current' | 'migrated' | 'deferred' | 'empty';
1665
+ /** The version the store is at AFTER this call. */
1666
+ readonly version: number;
1667
+ readonly remirrored?: number;
1668
+ readonly error?: string;
1669
+ }
1670
+
1671
+ /**
1672
+ * Bring the mirrored dz-backlog vectors into the CURRENT embed form ({@link DEDUP_EMBED_FORM_VERSION}).
1673
+ * v1 vectors were full-text embeds; v2 queries are bounded excerpts — comparing across the two forms
1674
+ * is a query-vs-row space split for any idea longer than the cap (the exact "stale space rot" the
1675
+ * ADR-001 comments warn about). Re-mirrors every idea through the ONE seam (upsert-by-dzId, so it is
1676
+ * idempotent), and writes the marker ONLY after every re-mirror succeeded — a half-migrated store
1677
+ * keeps claiming the old version and is retried next time. Best-effort: NEVER blocks capture; a
1678
+ * `deferred` outcome must be surfaced by the caller (a silent stale space is the trap).
1679
+ */
1680
+ export async function ensureBacklogEmbedForm(
1681
+ projectRoot: string,
1682
+ deps: { mirror?: (root: string, idea: IdeaRecord) => Promise<{ mirrored: number; error?: string | undefined }> } = {},
1683
+ ): Promise<EmbedFormReport> {
1684
+ const stored = readBacklogEmbedFormVersion(projectRoot);
1685
+ if (stored >= DEDUP_EMBED_FORM_VERSION) return { action: 'current', version: stored };
1686
+ const ideas = readIdeas(projectRoot);
1687
+ const writeMarker = (): void => {
1688
+ mkdirSync(backlogDir(projectRoot), { recursive: true });
1689
+ writeFileAtomic(backlogEmbedFormPath(projectRoot), `${JSON.stringify({ version: DEDUP_EMBED_FORM_VERSION })}\n`);
1690
+ };
1691
+ if (ideas.length === 0) {
1692
+ // Nothing to re-embed — stamp the marker so a store born under v2 never "migrates".
1693
+ try {
1694
+ writeMarker();
1695
+ return { action: 'empty', version: DEDUP_EMBED_FORM_VERSION };
1696
+ } catch (err) {
1697
+ return { action: 'deferred', version: stored, error: `marker write failed: ${err instanceof Error ? err.message : String(err)}` };
1698
+ }
1699
+ }
1700
+ let remirrored = 0;
1701
+ if (deps.mirror !== undefined) {
1702
+ for (const idea of ideas) {
1703
+ const res = await deps.mirror(projectRoot, idea);
1704
+ if (res.error !== undefined) {
1705
+ // Abort WITHOUT the marker: the store stays honestly marked v1 and the migration retries later.
1706
+ return { action: 'deferred', version: stored, remirrored, error: res.error };
1707
+ }
1708
+ remirrored += 1;
1709
+ }
1710
+ } else {
1711
+ // BATCHED production path: ONE embedder init + ONE upsert transaction. A per-idea
1712
+ // `mirrorIdeaVector` loop re-initializes the transformer model per idea — MEASURED 2026-08-11:
1713
+ // 105 ideas = 105 model loads, minutes of pure init time.
1714
+ const emb = await resolveAgentdbEmbedder(projectRoot);
1715
+ if ('error' in emb) return { action: 'deferred', version: stored, remirrored: 0, error: emb.error };
1716
+ const rows = [];
1717
+ try {
1718
+ for (const idea of ideas) {
1719
+ rows.push({
1720
+ dzId: idea.id,
1721
+ vector: await emb.embed(dedupEmbedText(idea.text)),
1722
+ text: idea.text,
1723
+ taskType: BACKLOG_TASK_TYPE,
1724
+ score: Math.max(0, Math.min(1, Number.isFinite(idea.goalAlignment) ? idea.goalAlignment : 0)),
1725
+ metadata: { kind: 'dz-backlog-idea' },
1726
+ });
1727
+ }
1728
+ } catch (err) {
1729
+ return { action: 'deferred', version: stored, remirrored: 0, error: `embed failed: ${err instanceof Error ? err.message : String(err)}` };
1730
+ }
1731
+ const res = await importVectorsToAgentdb(projectRoot, rows);
1732
+ if (res.error !== undefined) return { action: 'deferred', version: stored, remirrored: res.imported, error: res.error };
1733
+ remirrored = res.imported;
1734
+ }
1735
+ try {
1736
+ writeMarker();
1737
+ } catch (err) {
1738
+ return { action: 'deferred', version: stored, remirrored, error: `marker write failed: ${err instanceof Error ? err.message : String(err)}` };
1739
+ }
1740
+ return { action: 'migrated', version: DEDUP_EMBED_FORM_VERSION, remirrored };
1741
+ }