@dzhechkov/harness-core 0.3.141 → 0.3.143
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 +19 -19
- package/README.md +2 -1
- package/dist/backlog.d.ts +97 -1
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +214 -31
- package/dist/backlog.js.map +1 -1
- package/dist/guard.d.ts +44 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +144 -1
- package/dist/guard.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -0
- package/dist/registry.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +18 -18
- package/src/backlog.ts +282 -27
- package/src/guard.ts +151 -1
- package/src/registry.ts +2 -0
package/sbom.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"hashes": [
|
|
26
26
|
{
|
|
27
27
|
"alg": "SHA-256",
|
|
28
|
-
"content": "
|
|
28
|
+
"content": "1b9db50b623f5737dd95a3b949d9ea85293387a010a09f4204e6a476f5127d57"
|
|
29
29
|
}
|
|
30
30
|
]
|
|
31
31
|
},
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"hashes": [
|
|
216
216
|
{
|
|
217
217
|
"alg": "SHA-256",
|
|
218
|
-
"content": "
|
|
218
|
+
"content": "3f3fbdb7a59c0152d0750291092b6453b0ae1ff6f6f910e99cb5facfd93b6e53"
|
|
219
219
|
}
|
|
220
220
|
]
|
|
221
221
|
},
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
"hashes": [
|
|
226
226
|
{
|
|
227
227
|
"alg": "SHA-256",
|
|
228
|
-
"content": "
|
|
228
|
+
"content": "0c96dddbd4aebe4bf085b641fcfaaa0ad149b5572471449f984364c94f78c7b3"
|
|
229
229
|
}
|
|
230
230
|
]
|
|
231
231
|
},
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
"hashes": [
|
|
236
236
|
{
|
|
237
237
|
"alg": "SHA-256",
|
|
238
|
-
"content": "
|
|
238
|
+
"content": "9208cce6400957bed310d8c0405a15128315fba59b447962e06e48fca11f6fe6"
|
|
239
239
|
}
|
|
240
240
|
]
|
|
241
241
|
},
|
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
"hashes": [
|
|
246
246
|
{
|
|
247
247
|
"alg": "SHA-256",
|
|
248
|
-
"content": "
|
|
248
|
+
"content": "1dfc72d3ba2c896a43504425d4fdeb6b34dccb7d7867bc2f9a792981a57a5c48"
|
|
249
249
|
}
|
|
250
250
|
]
|
|
251
251
|
},
|
|
@@ -1015,7 +1015,7 @@
|
|
|
1015
1015
|
"hashes": [
|
|
1016
1016
|
{
|
|
1017
1017
|
"alg": "SHA-256",
|
|
1018
|
-
"content": "
|
|
1018
|
+
"content": "7a1df1aa511a5d96669e660b99d29ba633393e1a20dc346e1dedfa9aec221260"
|
|
1019
1019
|
}
|
|
1020
1020
|
]
|
|
1021
1021
|
},
|
|
@@ -1025,7 +1025,7 @@
|
|
|
1025
1025
|
"hashes": [
|
|
1026
1026
|
{
|
|
1027
1027
|
"alg": "SHA-256",
|
|
1028
|
-
"content": "
|
|
1028
|
+
"content": "be03334a1746d3261afe69252f119c82f3718e0608957eebf5de311acf9d8a04"
|
|
1029
1029
|
}
|
|
1030
1030
|
]
|
|
1031
1031
|
},
|
|
@@ -1035,7 +1035,7 @@
|
|
|
1035
1035
|
"hashes": [
|
|
1036
1036
|
{
|
|
1037
1037
|
"alg": "SHA-256",
|
|
1038
|
-
"content": "
|
|
1038
|
+
"content": "dc20d0ea9ff7e1299b52ed3ce03dbd2f8bb13b1b86ee2799a49837f33ec33883"
|
|
1039
1039
|
}
|
|
1040
1040
|
]
|
|
1041
1041
|
},
|
|
@@ -1045,7 +1045,7 @@
|
|
|
1045
1045
|
"hashes": [
|
|
1046
1046
|
{
|
|
1047
1047
|
"alg": "SHA-256",
|
|
1048
|
-
"content": "
|
|
1048
|
+
"content": "1696212693b8cd400ff6d2af843e2901ad0e79679ddd09ea9831306aa75e342a"
|
|
1049
1049
|
}
|
|
1050
1050
|
]
|
|
1051
1051
|
},
|
|
@@ -1745,7 +1745,7 @@
|
|
|
1745
1745
|
"hashes": [
|
|
1746
1746
|
{
|
|
1747
1747
|
"alg": "SHA-256",
|
|
1748
|
-
"content": "
|
|
1748
|
+
"content": "84614c9a187b2217161fa4344af865e5bf0762acdd3adf928e4f8ce525f98137"
|
|
1749
1749
|
}
|
|
1750
1750
|
]
|
|
1751
1751
|
},
|
|
@@ -1755,7 +1755,7 @@
|
|
|
1755
1755
|
"hashes": [
|
|
1756
1756
|
{
|
|
1757
1757
|
"alg": "SHA-256",
|
|
1758
|
-
"content": "
|
|
1758
|
+
"content": "d4a3ae26540d4dcfdbdd28020263d14ca06b61fd0b8bd3df3abc4b400740feb6"
|
|
1759
1759
|
}
|
|
1760
1760
|
]
|
|
1761
1761
|
},
|
|
@@ -1765,7 +1765,7 @@
|
|
|
1765
1765
|
"hashes": [
|
|
1766
1766
|
{
|
|
1767
1767
|
"alg": "SHA-256",
|
|
1768
|
-
"content": "
|
|
1768
|
+
"content": "20e25cf3fc30868a611f413f883d68f4dd26939f3b1127954235c6efb53b9837"
|
|
1769
1769
|
}
|
|
1770
1770
|
]
|
|
1771
1771
|
},
|
|
@@ -2575,7 +2575,7 @@
|
|
|
2575
2575
|
"hashes": [
|
|
2576
2576
|
{
|
|
2577
2577
|
"alg": "SHA-256",
|
|
2578
|
-
"content": "
|
|
2578
|
+
"content": "55ce25bcbdeef7f26e93f1e14db495bf858644a0285b5d0bf3236a69d8d8013c"
|
|
2579
2579
|
}
|
|
2580
2580
|
]
|
|
2581
2581
|
},
|
|
@@ -2625,7 +2625,7 @@
|
|
|
2625
2625
|
"hashes": [
|
|
2626
2626
|
{
|
|
2627
2627
|
"alg": "SHA-256",
|
|
2628
|
-
"content": "
|
|
2628
|
+
"content": "7418f3809477f66b524d69f3f438aaaea818451396b360fe509285f52db21767"
|
|
2629
2629
|
}
|
|
2630
2630
|
]
|
|
2631
2631
|
},
|
|
@@ -2825,7 +2825,7 @@
|
|
|
2825
2825
|
"hashes": [
|
|
2826
2826
|
{
|
|
2827
2827
|
"alg": "SHA-256",
|
|
2828
|
-
"content": "
|
|
2828
|
+
"content": "f56d5f5e59e9ddecae339b054b9f888b81b3410c56d3a09eedd7be3c11d3438c"
|
|
2829
2829
|
}
|
|
2830
2830
|
]
|
|
2831
2831
|
},
|
|
@@ -2995,7 +2995,7 @@
|
|
|
2995
2995
|
"hashes": [
|
|
2996
2996
|
{
|
|
2997
2997
|
"alg": "SHA-256",
|
|
2998
|
-
"content": "
|
|
2998
|
+
"content": "acfd5088bf92124bb78505099860a10247938ef813d861ee8d11360e0b6e6fb0"
|
|
2999
2999
|
}
|
|
3000
3000
|
]
|
|
3001
3001
|
},
|
|
@@ -3265,7 +3265,7 @@
|
|
|
3265
3265
|
"hashes": [
|
|
3266
3266
|
{
|
|
3267
3267
|
"alg": "SHA-256",
|
|
3268
|
-
"content": "
|
|
3268
|
+
"content": "9b6bf3726a14f774913440ee3053276e88a4dcaf1265ae48d8015bc4985489f1"
|
|
3269
3269
|
}
|
|
3270
3270
|
]
|
|
3271
3271
|
},
|
|
@@ -3565,7 +3565,7 @@
|
|
|
3565
3565
|
"hashes": [
|
|
3566
3566
|
{
|
|
3567
3567
|
"alg": "SHA-256",
|
|
3568
|
-
"content": "
|
|
3568
|
+
"content": "49829d24440130cfe403a410b9068a76631c68e9baa2cfc978f5029df2d72f3a"
|
|
3569
3569
|
}
|
|
3570
3570
|
]
|
|
3571
3571
|
},
|
package/src/backlog.ts
CHANGED
|
@@ -76,11 +76,50 @@ export interface DedupVerdict {
|
|
|
76
76
|
/** Top-1 raw cosine (ADR-002 — never an RRF score). `-1` when there is nothing to compare against. */
|
|
77
77
|
readonly cosine: number;
|
|
78
78
|
readonly matchedId: string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* The id of the TOP-1 candidate whatever band it landed in — the ADR-002 CALIBRATION surface (idea
|
|
81
|
+
* ce914ac2). `matchedId` is only set for a DUPLICATE, so a RELATED verdict used to report a cosine with
|
|
82
|
+
* no way to see WHICH idea produced it; a user calibrating the 0.92 band needs the pair, not the number.
|
|
83
|
+
* Purely observational: it never changes the verdict.
|
|
84
|
+
*/
|
|
85
|
+
readonly topMatchId: string | undefined;
|
|
79
86
|
readonly relatedIds: readonly string[];
|
|
80
87
|
/** True when the embedder was unavailable and dedup degraded to exact-text (ADR-002 §degrade). */
|
|
81
88
|
readonly exactTextOnly: boolean;
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
/* ── Effort parsing (idea 86096d6d): a clamp the user cannot see is a silent surprise. ── */
|
|
92
|
+
|
|
93
|
+
/** The result of interpreting a `--effort` argument, with the note the CLI must ECHO when it altered it. */
|
|
94
|
+
export interface EffortParse {
|
|
95
|
+
readonly effort: number;
|
|
96
|
+
/** True when the parsed value was altered (clamped, floored, or rejected) — the CLI prints `note`. */
|
|
97
|
+
readonly adjusted: boolean;
|
|
98
|
+
/** Human line, e.g. `effort 13 → clamped to 5 (scale 1-5)`. Present iff `adjusted`. */
|
|
99
|
+
readonly note?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const EFFORT_MIN = 1;
|
|
103
|
+
export const EFFORT_MAX = 5;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* PURE `--effort` interpreter. `dz backlog add --effort 13` used to store 5 and say NOTHING, so the user
|
|
107
|
+
* kept a wrong mental model of the scale (idea 86096d6d). Every alteration now carries a printable note;
|
|
108
|
+
* an in-range integer is returned untouched with `adjusted:false` (no noise on the happy path).
|
|
109
|
+
*/
|
|
110
|
+
export function parseEffort(raw: string | undefined, fallback: number): EffortParse {
|
|
111
|
+
if (raw === undefined) return { effort: fallback, adjusted: false };
|
|
112
|
+
const v = Number(raw);
|
|
113
|
+
if (!Number.isFinite(v)) {
|
|
114
|
+
return { effort: fallback, adjusted: true, note: `effort ${JSON.stringify(raw)} → not a number, using ${fallback} (scale ${EFFORT_MIN}-${EFFORT_MAX})` };
|
|
115
|
+
}
|
|
116
|
+
if (v < EFFORT_MIN) return { effort: EFFORT_MIN, adjusted: true, note: `effort ${raw} → clamped to ${EFFORT_MIN} (scale ${EFFORT_MIN}-${EFFORT_MAX})` };
|
|
117
|
+
if (v > EFFORT_MAX) return { effort: EFFORT_MAX, adjusted: true, note: `effort ${raw} → clamped to ${EFFORT_MAX} (scale ${EFFORT_MIN}-${EFFORT_MAX})` };
|
|
118
|
+
const floored = Math.floor(v);
|
|
119
|
+
if (floored !== v) return { effort: floored, adjusted: true, note: `effort ${raw} → rounded down to ${floored} (scale ${EFFORT_MIN}-${EFFORT_MAX}, whole numbers)` };
|
|
120
|
+
return { effort: floored, adjusted: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
84
123
|
/* ================================================================== */
|
|
85
124
|
/* CONFIG (readBacklogConfig) — defensive, Number.isFinite clamps. */
|
|
86
125
|
/* ================================================================== */
|
|
@@ -257,6 +296,117 @@ export function writeIdeas(projectRoot: string, ideas: readonly IdeaRecord[]): v
|
|
|
257
296
|
renameSync(tmp, path);
|
|
258
297
|
}
|
|
259
298
|
|
|
299
|
+
/* ── Store privacy (idea ec4cd60d): raw ideas are prompt-class PRIVATE content, like recall-usage.jsonl. ── */
|
|
300
|
+
|
|
301
|
+
export type GitignoreAction = 'created' | 'appended' | 'already-covered' | 'user-opted-out' | 'skipped';
|
|
302
|
+
export interface GitignoreScaffold {
|
|
303
|
+
readonly action: GitignoreAction;
|
|
304
|
+
readonly path: string;
|
|
305
|
+
/** Set for `skipped` (the I/O reason) and `user-opted-out` (the negation line we obeyed). */
|
|
306
|
+
readonly reason?: string;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The entry + its one-line rationale, written verbatim so the file explains itself. */
|
|
310
|
+
const BACKLOG_IGNORE_ENTRY = '.dz/backlog/';
|
|
311
|
+
const BACKLOG_IGNORE_COMMENT = '# dz backlog — captured ideas are private prompt-class content';
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Normalise ONE .gitignore pattern to the bare path it targets, so the equally-valid spellings of the
|
|
315
|
+
* same rule compare equal: a leading `/` (repo-root anchor), a trailing `/**` or `/*` (recursive glob),
|
|
316
|
+
* and a trailing `/` (directory marker) are all decoration around the same path. Returns `undefined`
|
|
317
|
+
* for anything that is not a plain path pattern (a comment, an empty line, or a pattern carrying a
|
|
318
|
+
* wildcard we do NOT interpret) — an uninterpretable pattern must never be read as coverage.
|
|
319
|
+
*/
|
|
320
|
+
function normaliseIgnorePattern(body: string): string | undefined {
|
|
321
|
+
let p = body.trim();
|
|
322
|
+
if (p === '' || p.startsWith('#')) return undefined;
|
|
323
|
+
if (p.startsWith('/')) p = p.slice(1); // repo-root anchor: `/.dz/` ≡ `.dz/`
|
|
324
|
+
p = p.replace(/\/\*\*$/, '').replace(/\/\*$/, ''); // `.dz/**` / `.dz/*` ≡ `.dz`
|
|
325
|
+
p = p.replace(/\/+$/, ''); // trailing directory marker
|
|
326
|
+
if (p === '' || p.includes('*') || p.includes('?') || p.includes('[')) return undefined; // not a plain path
|
|
327
|
+
return p;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** True when the pattern targets the backlog store (directly or via its `.dz` parent). */
|
|
331
|
+
function targetsBacklogStore(pattern: string): boolean {
|
|
332
|
+
return pattern === '.dz' || pattern === '.dz/backlog';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export type BacklogIgnoreStatus = 'covered' | 'negated' | 'uncovered';
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* PURE .gitignore verdict for the backlog store. Deliberately NOT a full gitignore engine — it
|
|
339
|
+
* recognises the plain-path spellings of the rule (`/.dz/`, `.dz/**`, `.dz/backlog`, …) and refuses to
|
|
340
|
+
* interpret anything else, because a wrong-but-clever matcher either double-appends or silently
|
|
341
|
+
* decides a store is private when it is not.
|
|
342
|
+
*
|
|
343
|
+
* `negated` wins over `covered`: a `!.dz/backlog/` line (anchored or not) is the user saying *"track
|
|
344
|
+
* this on purpose"*. We obey it — appending a rule that overrides the user's explicit opt-out would be
|
|
345
|
+
* this tool deciding it knows better.
|
|
346
|
+
*/
|
|
347
|
+
export function backlogIgnoreStatus(gitignoreText: string): BacklogIgnoreStatus {
|
|
348
|
+
let covered = false;
|
|
349
|
+
for (const raw of String(gitignoreText).split('\n')) {
|
|
350
|
+
const line = raw.trim();
|
|
351
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
352
|
+
const negated = line.startsWith('!');
|
|
353
|
+
const pattern = normaliseIgnorePattern(negated ? line.slice(1) : line);
|
|
354
|
+
if (pattern === undefined || !targetsBacklogStore(pattern)) continue;
|
|
355
|
+
if (negated) return 'negated'; // explicit user intent — decided, no further scanning
|
|
356
|
+
covered = true;
|
|
357
|
+
}
|
|
358
|
+
return covered ? 'covered' : 'uncovered';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Back-compat shim: "is it ignored?" — a negation is NOT coverage (the store is tracked on purpose). */
|
|
362
|
+
export function backlogIgnoreCovered(gitignoreText: string): boolean {
|
|
363
|
+
return backlogIgnoreStatus(gitignoreText) === 'covered';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The file's dominant line ending, so an append does not mix CRLF and LF in one file. */
|
|
367
|
+
function dominantEol(text: string): '\r\n' | '\n' {
|
|
368
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
369
|
+
const lf = (text.match(/\n/g) ?? []).length - crlf;
|
|
370
|
+
return crlf > lf ? '\r\n' : '\n';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Atomic write (tmp + rename in the SAME dir) — the ideas.jsonl discipline: a crash never truncates. */
|
|
374
|
+
function writeFileAtomic(path: string, body: string): void {
|
|
375
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
376
|
+
writeFileSync(tmp, body);
|
|
377
|
+
renameSync(tmp, path);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Ensure the backlog store is gitignored, at the moment the feature FIRST creates it (idea ec4cd60d).
|
|
382
|
+
* Creates a `.gitignore` when there is none; appends the entry (+ its comment) when the project has one
|
|
383
|
+
* that does not cover the store; touches NOTHING when it is already covered OR when the user explicitly
|
|
384
|
+
* negated the rule. The write is ATOMIC and preserves the file's dominant EOL.
|
|
385
|
+
* Never throws — an unwritable .gitignore degrades to `skipped`, and the CALLER must say so out loud
|
|
386
|
+
* (a silently un-ignored store is exactly the privacy leak this function exists to prevent).
|
|
387
|
+
*/
|
|
388
|
+
export function ensureBacklogGitignored(projectRoot: string): GitignoreScaffold {
|
|
389
|
+
const path = join(projectRoot, '.gitignore');
|
|
390
|
+
try {
|
|
391
|
+
if (!existsSync(path)) {
|
|
392
|
+
writeFileAtomic(path, `${BACKLOG_IGNORE_COMMENT}\n${BACKLOG_IGNORE_ENTRY}\n`);
|
|
393
|
+
return { action: 'created', path };
|
|
394
|
+
}
|
|
395
|
+
const text = readFileSync(path, 'utf-8');
|
|
396
|
+
const status = backlogIgnoreStatus(text);
|
|
397
|
+
if (status === 'covered') return { action: 'already-covered', path };
|
|
398
|
+
if (status === 'negated') {
|
|
399
|
+
return { action: 'user-opted-out', path, reason: 'a "!" negation for the backlog store is present — respecting the explicit opt-out and adding nothing' };
|
|
400
|
+
}
|
|
401
|
+
const eol = dominantEol(text);
|
|
402
|
+
const sep = text === '' || text.endsWith('\n') ? '' : eol;
|
|
403
|
+
writeFileAtomic(path, `${text}${sep}${eol}${BACKLOG_IGNORE_COMMENT}${eol}${BACKLOG_IGNORE_ENTRY}${eol}`);
|
|
404
|
+
return { action: 'appended', path };
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return { action: 'skipped', path, reason: err instanceof Error ? err.message : String(err) };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
260
410
|
/** Pre-mutation snapshot (NFR-6) — mirrors `snapshotStore`. A failed snapshot returns `{error}` so
|
|
261
411
|
* the caller ABORTS the mutation (no partial merge). */
|
|
262
412
|
export interface SnapshotResult {
|
|
@@ -303,15 +453,15 @@ export function classifyDedup(
|
|
|
303
453
|
const sorted = candidates.filter((c) => Number.isFinite(c.cosine)).sort((a, b) => b.cosine - a.cosine);
|
|
304
454
|
const top = sorted[0];
|
|
305
455
|
const exactTextOnly = opts.exactTextOnly === true;
|
|
306
|
-
if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, relatedIds: [], exactTextOnly };
|
|
456
|
+
if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly };
|
|
307
457
|
if (top.cosine >= cfg.duplicateThreshold) {
|
|
308
|
-
return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, relatedIds: [], exactTextOnly };
|
|
458
|
+
return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, topMatchId: top.id, relatedIds: [], exactTextOnly };
|
|
309
459
|
}
|
|
310
460
|
const related = sorted.filter((c) => c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold);
|
|
311
461
|
if (related.length > 0) {
|
|
312
|
-
return { action: 'related', cosine: top.cosine, matchedId: undefined, relatedIds: related.map((c) => c.id), exactTextOnly };
|
|
462
|
+
return { action: 'related', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: related.map((c) => c.id), exactTextOnly };
|
|
313
463
|
}
|
|
314
|
-
return { action: 'new', cosine: top.cosine, matchedId: undefined, relatedIds: [], exactTextOnly };
|
|
464
|
+
return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly };
|
|
315
465
|
}
|
|
316
466
|
|
|
317
467
|
/** Injectable deps so the production dedup path is testable without a live agentdb. */
|
|
@@ -354,8 +504,8 @@ export async function dedupIdea(projectRoot: string, text: string, cfg: BacklogC
|
|
|
354
504
|
// EXACT-text safety net: identical text among existing ideas is still a DUPLICATE (content-addressed
|
|
355
505
|
// idempotency, ADR-002 §degrade). Otherwise NEW — the RELATED band needs cosine and is skipped.
|
|
356
506
|
const match = ideas.find((i) => i.text === text);
|
|
357
|
-
if (match !== undefined) return { action: 'duplicate', cosine: 1, matchedId: match.id, relatedIds: [], exactTextOnly: true };
|
|
358
|
-
return { action: 'new', cosine: -1, matchedId: undefined, relatedIds: [], exactTextOnly: result.error !== undefined };
|
|
507
|
+
if (match !== undefined) return { action: 'duplicate', cosine: 1, matchedId: match.id, topMatchId: match.id, relatedIds: [], exactTextOnly: true };
|
|
508
|
+
return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly: result.error !== undefined };
|
|
359
509
|
}
|
|
360
510
|
return classifyDedup(candidates, cfg.dedup);
|
|
361
511
|
}
|
|
@@ -364,30 +514,135 @@ export async function dedupIdea(projectRoot: string, text: string, cfg: BacklogC
|
|
|
364
514
|
/* ALIGNMENT (AM-3 / ADR-003) — weighted-MAX cosine over the GoalMap. */
|
|
365
515
|
/* ================================================================== */
|
|
366
516
|
|
|
367
|
-
/**
|
|
368
|
-
export
|
|
517
|
+
/** One goal entry the reader REFUSED, with the reason — the anti-vacuous-valid evidence (idea 960c9f26). */
|
|
518
|
+
export interface DroppedGoal {
|
|
519
|
+
/** 0-based index of the entry in the file's `goals` array. */
|
|
520
|
+
readonly index: number;
|
|
521
|
+
/** Machine-stable reason, e.g. `missing "statement"`. */
|
|
522
|
+
readonly reason: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* What the defensive read actually SAW. `readGoalMap` throws away the drops (correct for runtime paths —
|
|
527
|
+
* a corrupt compass must never break capture), but `goals --validate` needs them: a goals.json whose every
|
|
528
|
+
* entry was silently dropped previously reported *"valid (0 goal(s))"* — a vacuous pass that hid the user's
|
|
529
|
+
* typo (`text` instead of `statement`). This variant is the validate-facing reader.
|
|
530
|
+
*/
|
|
531
|
+
export interface GoalMapRead {
|
|
532
|
+
readonly goalMap: GoalMap;
|
|
533
|
+
/** Entries present in the file's `goals` array (kept + dropped). */
|
|
534
|
+
readonly present: number;
|
|
535
|
+
readonly dropped: readonly DroppedGoal[];
|
|
536
|
+
/**
|
|
537
|
+
* Fields the reader REPAIRED to keep the runtime safe. The clamp happens BEFORE any validation could
|
|
538
|
+
* see the original, so a `weight: 7` becomes a legal 1 and the validator's out-of-range branch is
|
|
539
|
+
* unreachable — a silent repair reported as "valid". These carry the RAW value so `goals --validate`
|
|
540
|
+
* can warn about what the user actually wrote while the runtime keeps the clamped value.
|
|
541
|
+
*/
|
|
542
|
+
readonly repaired: readonly RepairedGoalField[];
|
|
543
|
+
/** Set when the file exists but could not be parsed / has no `goals` array. */
|
|
544
|
+
readonly parseError?: string;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** One field the defensive reader clamped/replaced, with both the raw and the used value. */
|
|
548
|
+
export interface RepairedGoalField {
|
|
549
|
+
readonly index: number;
|
|
550
|
+
readonly id: string;
|
|
551
|
+
readonly field: 'weight';
|
|
552
|
+
readonly raw: unknown;
|
|
553
|
+
readonly used: number;
|
|
554
|
+
/** Human reason, e.g. `weight 7 is out of (0,1]`. */
|
|
555
|
+
readonly reason: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Reason an entry cannot become a Goal — `undefined` when it is well-formed. */
|
|
559
|
+
function goalDropReason(o: unknown): string | undefined {
|
|
560
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) return 'not an object';
|
|
561
|
+
const r = o as Record<string, unknown>;
|
|
562
|
+
if (typeof r.id !== 'string') return 'missing "id"';
|
|
563
|
+
if (typeof r.statement !== 'string') return 'missing "statement"';
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Detailed GoalMap read — never throws, and REPORTS what it dropped (idea 960c9f26). `readGoalMap` is the
|
|
569
|
+
* lossy runtime view of this; both share one parser so they can never disagree about what a goal is.
|
|
570
|
+
*/
|
|
571
|
+
export function readGoalMapDetailed(projectRoot: string): GoalMapRead {
|
|
369
572
|
const path = goalsPath(projectRoot);
|
|
370
|
-
if (!existsSync(path)) return { version: 1, goals: [] };
|
|
573
|
+
if (!existsSync(path)) return { goalMap: { version: 1, goals: [] }, present: 0, dropped: [], repaired: [] };
|
|
574
|
+
let parsed: { version?: unknown; goals?: unknown };
|
|
371
575
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
: []
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
576
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown; goals?: unknown };
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return {
|
|
579
|
+
goalMap: { version: 1, goals: [] },
|
|
580
|
+
present: 0,
|
|
581
|
+
dropped: [],
|
|
582
|
+
repaired: [],
|
|
583
|
+
parseError: `goals.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// Valid JSON that is not an object (null, a number, an array) must not crash the never-throw
|
|
587
|
+
// reader: `parsed.version` on null is a TypeError (Codex re-QE HIGH).
|
|
588
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
589
|
+
return {
|
|
590
|
+
goalMap: { version: 1, goals: [] },
|
|
591
|
+
present: 0,
|
|
592
|
+
dropped: [],
|
|
593
|
+
repaired: [],
|
|
594
|
+
parseError: 'goals.json is valid JSON but not an object',
|
|
595
|
+
};
|
|
390
596
|
}
|
|
597
|
+
const version = typeof parsed.version === 'number' ? parsed.version : 1;
|
|
598
|
+
if (!Array.isArray(parsed.goals)) {
|
|
599
|
+
return {
|
|
600
|
+
goalMap: { version, goals: [] },
|
|
601
|
+
present: 0,
|
|
602
|
+
dropped: [],
|
|
603
|
+
repaired: [],
|
|
604
|
+
parseError: 'goals.json has no `goals` array',
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
const goals: Goal[] = [];
|
|
608
|
+
const dropped: DroppedGoal[] = [];
|
|
609
|
+
const repaired: RepairedGoalField[] = [];
|
|
610
|
+
parsed.goals.forEach((g, index) => {
|
|
611
|
+
const reason = goalDropReason(g);
|
|
612
|
+
if (reason !== undefined) {
|
|
613
|
+
dropped.push({ index, reason });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const o = g as Record<string, unknown>;
|
|
617
|
+
const id = o.id as string;
|
|
618
|
+
// The clamp keeps the RUNTIME safe; the raw value is recorded so validation can still see what the
|
|
619
|
+
// user wrote (MED-7: clamping before validating made the validator's out-of-range branch dead code).
|
|
620
|
+
const weight = clampNum(o.weight, Number.MIN_VALUE, 1, 1);
|
|
621
|
+
if (o.weight !== undefined && o.weight !== weight) {
|
|
622
|
+
repaired.push({
|
|
623
|
+
index,
|
|
624
|
+
id,
|
|
625
|
+
field: 'weight',
|
|
626
|
+
raw: o.weight,
|
|
627
|
+
used: weight,
|
|
628
|
+
reason: typeof o.weight === 'number' && Number.isFinite(o.weight)
|
|
629
|
+
? `weight ${o.weight} is out of (0,1]`
|
|
630
|
+
: `weight ${JSON.stringify(o.weight) ?? String(o.weight)} is not a number in (0,1]`,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
goals.push({
|
|
634
|
+
id,
|
|
635
|
+
statement: o.statement as string,
|
|
636
|
+
weight,
|
|
637
|
+
keywords: Array.isArray(o.keywords) ? o.keywords.filter((k): k is string => typeof k === 'string') : [],
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
return { goalMap: { version, goals }, present: parsed.goals.length, dropped, repaired };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Defensive GoalMap reader — never throws; a missing/corrupt file ⇒ empty compass (the runtime path). */
|
|
644
|
+
export function readGoalMap(projectRoot: string): GoalMap {
|
|
645
|
+
return readGoalMapDetailed(projectRoot).goalMap;
|
|
391
646
|
}
|
|
392
647
|
|
|
393
648
|
/** The text embedded for a goal: statement + keywords (same convention across cache + score). */
|