@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/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
|
-
|
|
31
|
-
export
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|