@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.
- package/.dz-manifest.json +410 -62
- package/README.md +81 -3
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +10 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/backlog-embed.d.ts +94 -0
- package/dist/backlog-embed.d.ts.map +1 -0
- package/dist/backlog-embed.js +138 -0
- package/dist/backlog-embed.js.map +1 -0
- package/dist/backlog.d.ts +180 -7
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +429 -26
- package/dist/backlog.js.map +1 -1
- package/dist/challenge-panel.d.ts +3 -0
- package/dist/challenge-panel.d.ts.map +1 -1
- package/dist/challenge-panel.js +3 -0
- package/dist/challenge-panel.js.map +1 -1
- package/dist/export-holdout.d.ts +149 -0
- package/dist/export-holdout.d.ts.map +1 -0
- package/dist/export-holdout.js +198 -0
- package/dist/export-holdout.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +127 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +199 -1
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +3 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +3 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +42 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +73 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +33 -0
- package/dist/loop-blobs.generated.d.ts.map +1 -0
- package/dist/loop-blobs.generated.js +101 -0
- package/dist/loop-blobs.generated.js.map +1 -0
- package/dist/loop-lint.d.ts +63 -0
- package/dist/loop-lint.d.ts.map +1 -0
- package/dist/loop-lint.js +606 -0
- package/dist/loop-lint.js.map +1 -0
- package/dist/loop-plan.d.ts +416 -0
- package/dist/loop-plan.d.ts.map +1 -0
- package/dist/loop-plan.js +1151 -0
- package/dist/loop-plan.js.map +1 -0
- package/dist/loop-render.d.ts +104 -0
- package/dist/loop-render.d.ts.map +1 -0
- package/dist/loop-render.js +1068 -0
- package/dist/loop-render.js.map +1 -0
- package/dist/loop-trace.d.ts +229 -0
- package/dist/loop-trace.d.ts.map +1 -0
- package/dist/loop-trace.js +614 -0
- package/dist/loop-trace.js.map +1 -0
- package/dist/mutation-gate.d.ts +247 -0
- package/dist/mutation-gate.d.ts.map +1 -0
- package/dist/mutation-gate.js +535 -0
- package/dist/mutation-gate.js.map +1 -0
- package/dist/no-stubs.d.ts +53 -0
- package/dist/no-stubs.d.ts.map +1 -0
- package/dist/no-stubs.js +190 -0
- package/dist/no-stubs.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +67 -0
- package/dist/package-skill-layouts.d.ts.map +1 -0
- package/dist/package-skill-layouts.js +81 -0
- package/dist/package-skill-layouts.js.map +1 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +156 -75
- package/dist/patterns.js.map +1 -1
- package/dist/recall-domain-boost.d.ts.map +1 -1
- package/dist/recall-domain-boost.js +6 -0
- package/dist/recall-domain-boost.js.map +1 -1
- package/dist/statusline.d.ts +10 -2
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +122 -36
- package/dist/statusline.js.map +1 -1
- package/dist/store-lock.d.ts +108 -0
- package/dist/store-lock.d.ts.map +1 -0
- package/dist/store-lock.js +231 -0
- package/dist/store-lock.js.map +1 -0
- package/dist/workflows.d.ts +16 -22
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +17 -98
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -4
- package/sbom.json +1073 -203
- package/src/agentdb-index.ts +10 -1
- package/src/backlog-embed.ts +156 -0
- package/src/backlog.ts +536 -28
- package/src/challenge-panel.ts +4 -0
- package/src/export-holdout.ts +235 -0
- package/src/feature-adr-checkpoints.ts +291 -1
- package/src/feature-adr-routing.ts +4 -0
- package/src/guard.ts +106 -1
- package/src/index.ts +62 -2
- package/src/loop-blobs.generated.ts +114 -0
- package/src/loop-lint.ts +643 -0
- package/src/loop-plan.ts +1419 -0
- package/src/loop-render.ts +1126 -0
- package/src/loop-trace.ts +727 -0
- package/src/mutation-gate.ts +701 -0
- package/src/no-stubs.ts +204 -0
- package/src/package-skill-layouts.ts +107 -0
- package/src/patterns.ts +135 -60
- package/src/recall-domain-boost.ts +6 -0
- package/src/statusline.ts +117 -30
- package/src/store-lock.ts +258 -0
- 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
|
-
|
|
34
|
-
export
|
|
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: {
|
|
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: {
|
|
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?: {
|
|
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: {
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
/**
|
|
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
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
490
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|