@dzhechkov/harness-core 0.3.142 → 0.3.144
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 +49 -25
- 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/compounding.d.ts +22 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +29 -10
- package/dist/compounding.js.map +1 -1
- package/dist/epoch-replay.d.ts +399 -0
- package/dist/epoch-replay.d.ts.map +1 -0
- package/dist/epoch-replay.js +695 -0
- package/dist/epoch-replay.js.map +1 -0
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +84 -24
- package/src/backlog.ts +282 -27
- package/src/compounding.ts +45 -8
- package/src/epoch-replay.ts +955 -0
- package/src/guard.ts +151 -1
- package/src/index.ts +52 -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
|
},
|
|
@@ -615,7 +615,7 @@
|
|
|
615
615
|
"hashes": [
|
|
616
616
|
{
|
|
617
617
|
"alg": "SHA-256",
|
|
618
|
-
"content": "
|
|
618
|
+
"content": "34eb7f1754a631ebe5f390dee53f1ea276a85e6cabd25806c384aeef23f64d3c"
|
|
619
619
|
}
|
|
620
620
|
]
|
|
621
621
|
},
|
|
@@ -625,7 +625,7 @@
|
|
|
625
625
|
"hashes": [
|
|
626
626
|
{
|
|
627
627
|
"alg": "SHA-256",
|
|
628
|
-
"content": "
|
|
628
|
+
"content": "22cddbd67b5a6204cccc2bf100735c33e138f7400769ad073dc694df4fb0f2b2"
|
|
629
629
|
}
|
|
630
630
|
]
|
|
631
631
|
},
|
|
@@ -635,7 +635,7 @@
|
|
|
635
635
|
"hashes": [
|
|
636
636
|
{
|
|
637
637
|
"alg": "SHA-256",
|
|
638
|
-
"content": "
|
|
638
|
+
"content": "dc8d3d48e8387391828175bff981016dfa449b19aa1a55e6420adbd688a112fb"
|
|
639
639
|
}
|
|
640
640
|
]
|
|
641
641
|
},
|
|
@@ -645,7 +645,7 @@
|
|
|
645
645
|
"hashes": [
|
|
646
646
|
{
|
|
647
647
|
"alg": "SHA-256",
|
|
648
|
-
"content": "
|
|
648
|
+
"content": "afc564287b7a548b15051e64151c05687b8a45b66df7c964e7168591bb93414c"
|
|
649
649
|
}
|
|
650
650
|
]
|
|
651
651
|
},
|
|
@@ -889,6 +889,46 @@
|
|
|
889
889
|
}
|
|
890
890
|
]
|
|
891
891
|
},
|
|
892
|
+
{
|
|
893
|
+
"type": "file",
|
|
894
|
+
"name": "dist/epoch-replay.d.ts",
|
|
895
|
+
"hashes": [
|
|
896
|
+
{
|
|
897
|
+
"alg": "SHA-256",
|
|
898
|
+
"content": "7b45f33f2948d102e09204dec03bdff93c70571d79c9466faf014126139cd0b9"
|
|
899
|
+
}
|
|
900
|
+
]
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
"type": "file",
|
|
904
|
+
"name": "dist/epoch-replay.d.ts.map",
|
|
905
|
+
"hashes": [
|
|
906
|
+
{
|
|
907
|
+
"alg": "SHA-256",
|
|
908
|
+
"content": "155f1584c236ef7d805522377e20d651c01bd07ad96a6dcc17847d9f6b3885e2"
|
|
909
|
+
}
|
|
910
|
+
]
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
"type": "file",
|
|
914
|
+
"name": "dist/epoch-replay.js",
|
|
915
|
+
"hashes": [
|
|
916
|
+
{
|
|
917
|
+
"alg": "SHA-256",
|
|
918
|
+
"content": "fbed667f04a07975915a35ed78d582c05a6669e744d457b3ac38a0cbb35d4539"
|
|
919
|
+
}
|
|
920
|
+
]
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
"type": "file",
|
|
924
|
+
"name": "dist/epoch-replay.js.map",
|
|
925
|
+
"hashes": [
|
|
926
|
+
{
|
|
927
|
+
"alg": "SHA-256",
|
|
928
|
+
"content": "f0c5eb87cdc3727e7eb857e5d07f8fd376902c66c48a23af61998677ea48acda"
|
|
929
|
+
}
|
|
930
|
+
]
|
|
931
|
+
},
|
|
892
932
|
{
|
|
893
933
|
"type": "file",
|
|
894
934
|
"name": "dist/feature-adr-claim-gate.d.ts",
|
|
@@ -1015,7 +1055,7 @@
|
|
|
1015
1055
|
"hashes": [
|
|
1016
1056
|
{
|
|
1017
1057
|
"alg": "SHA-256",
|
|
1018
|
-
"content": "
|
|
1058
|
+
"content": "7a1df1aa511a5d96669e660b99d29ba633393e1a20dc346e1dedfa9aec221260"
|
|
1019
1059
|
}
|
|
1020
1060
|
]
|
|
1021
1061
|
},
|
|
@@ -1025,7 +1065,7 @@
|
|
|
1025
1065
|
"hashes": [
|
|
1026
1066
|
{
|
|
1027
1067
|
"alg": "SHA-256",
|
|
1028
|
-
"content": "
|
|
1068
|
+
"content": "be03334a1746d3261afe69252f119c82f3718e0608957eebf5de311acf9d8a04"
|
|
1029
1069
|
}
|
|
1030
1070
|
]
|
|
1031
1071
|
},
|
|
@@ -1035,7 +1075,7 @@
|
|
|
1035
1075
|
"hashes": [
|
|
1036
1076
|
{
|
|
1037
1077
|
"alg": "SHA-256",
|
|
1038
|
-
"content": "
|
|
1078
|
+
"content": "dc20d0ea9ff7e1299b52ed3ce03dbd2f8bb13b1b86ee2799a49837f33ec33883"
|
|
1039
1079
|
}
|
|
1040
1080
|
]
|
|
1041
1081
|
},
|
|
@@ -1045,7 +1085,7 @@
|
|
|
1045
1085
|
"hashes": [
|
|
1046
1086
|
{
|
|
1047
1087
|
"alg": "SHA-256",
|
|
1048
|
-
"content": "
|
|
1088
|
+
"content": "1696212693b8cd400ff6d2af843e2901ad0e79679ddd09ea9831306aa75e342a"
|
|
1049
1089
|
}
|
|
1050
1090
|
]
|
|
1051
1091
|
},
|
|
@@ -1095,7 +1135,7 @@
|
|
|
1095
1135
|
"hashes": [
|
|
1096
1136
|
{
|
|
1097
1137
|
"alg": "SHA-256",
|
|
1098
|
-
"content": "
|
|
1138
|
+
"content": "88213a2320b2b99781919788883ab99da93c4043e7c235f14d7f97db034703e0"
|
|
1099
1139
|
}
|
|
1100
1140
|
]
|
|
1101
1141
|
},
|
|
@@ -1105,7 +1145,7 @@
|
|
|
1105
1145
|
"hashes": [
|
|
1106
1146
|
{
|
|
1107
1147
|
"alg": "SHA-256",
|
|
1108
|
-
"content": "
|
|
1148
|
+
"content": "e568286b5e7123dbd73a623268134af1b55555e61e90ea0271a9d4938184b6a0"
|
|
1109
1149
|
}
|
|
1110
1150
|
]
|
|
1111
1151
|
},
|
|
@@ -1115,7 +1155,7 @@
|
|
|
1115
1155
|
"hashes": [
|
|
1116
1156
|
{
|
|
1117
1157
|
"alg": "SHA-256",
|
|
1118
|
-
"content": "
|
|
1158
|
+
"content": "a779d72721eb025257beee64dd6e29a8e54e1dde1f152a0887d94aa2f9c3f1be"
|
|
1119
1159
|
}
|
|
1120
1160
|
]
|
|
1121
1161
|
},
|
|
@@ -1125,7 +1165,7 @@
|
|
|
1125
1165
|
"hashes": [
|
|
1126
1166
|
{
|
|
1127
1167
|
"alg": "SHA-256",
|
|
1128
|
-
"content": "
|
|
1168
|
+
"content": "7f3cd564e49f49d2fb6a736e98ce837f987994776730ff07f04cb71c7113c22e"
|
|
1129
1169
|
}
|
|
1130
1170
|
]
|
|
1131
1171
|
},
|
|
@@ -2575,7 +2615,7 @@
|
|
|
2575
2615
|
"hashes": [
|
|
2576
2616
|
{
|
|
2577
2617
|
"alg": "SHA-256",
|
|
2578
|
-
"content": "
|
|
2618
|
+
"content": "2b12d89fc52919b1963d3bd655cf21312df2306e3b7b3ff09c6812675197c58c"
|
|
2579
2619
|
}
|
|
2580
2620
|
]
|
|
2581
2621
|
},
|
|
@@ -2625,7 +2665,7 @@
|
|
|
2625
2665
|
"hashes": [
|
|
2626
2666
|
{
|
|
2627
2667
|
"alg": "SHA-256",
|
|
2628
|
-
"content": "
|
|
2668
|
+
"content": "7418f3809477f66b524d69f3f438aaaea818451396b360fe509285f52db21767"
|
|
2629
2669
|
}
|
|
2630
2670
|
]
|
|
2631
2671
|
},
|
|
@@ -2725,7 +2765,7 @@
|
|
|
2725
2765
|
"hashes": [
|
|
2726
2766
|
{
|
|
2727
2767
|
"alg": "SHA-256",
|
|
2728
|
-
"content": "
|
|
2768
|
+
"content": "7ccb87beb02047e78c192e544e5aa893eb4c95ab21542fc38fdd1e123e8fc2cd"
|
|
2729
2769
|
}
|
|
2730
2770
|
]
|
|
2731
2771
|
},
|
|
@@ -2789,6 +2829,16 @@
|
|
|
2789
2829
|
}
|
|
2790
2830
|
]
|
|
2791
2831
|
},
|
|
2832
|
+
{
|
|
2833
|
+
"type": "file",
|
|
2834
|
+
"name": "src/epoch-replay.ts",
|
|
2835
|
+
"hashes": [
|
|
2836
|
+
{
|
|
2837
|
+
"alg": "SHA-256",
|
|
2838
|
+
"content": "031c838e16e30617e5149ff16af75d8b4058922525d1851682323ba0bc0bb85f"
|
|
2839
|
+
}
|
|
2840
|
+
]
|
|
2841
|
+
},
|
|
2792
2842
|
{
|
|
2793
2843
|
"type": "file",
|
|
2794
2844
|
"name": "src/feature-adr-claim-gate.ts",
|
|
@@ -2825,7 +2875,7 @@
|
|
|
2825
2875
|
"hashes": [
|
|
2826
2876
|
{
|
|
2827
2877
|
"alg": "SHA-256",
|
|
2828
|
-
"content": "
|
|
2878
|
+
"content": "f56d5f5e59e9ddecae339b054b9f888b81b3410c56d3a09eedd7be3c11d3438c"
|
|
2829
2879
|
}
|
|
2830
2880
|
]
|
|
2831
2881
|
},
|
|
@@ -2845,7 +2895,7 @@
|
|
|
2845
2895
|
"hashes": [
|
|
2846
2896
|
{
|
|
2847
2897
|
"alg": "SHA-256",
|
|
2848
|
-
"content": "
|
|
2898
|
+
"content": "98f3820a8d7eafeb6405e2c80b05513ddce58dfccec57612f1136cfa6abdf40e"
|
|
2849
2899
|
}
|
|
2850
2900
|
]
|
|
2851
2901
|
},
|
|
@@ -3265,7 +3315,7 @@
|
|
|
3265
3315
|
"hashes": [
|
|
3266
3316
|
{
|
|
3267
3317
|
"alg": "SHA-256",
|
|
3268
|
-
"content": "
|
|
3318
|
+
"content": "9b6bf3726a14f774913440ee3053276e88a4dcaf1265ae48d8015bc4985489f1"
|
|
3269
3319
|
}
|
|
3270
3320
|
]
|
|
3271
3321
|
},
|
|
@@ -3459,6 +3509,16 @@
|
|
|
3459
3509
|
}
|
|
3460
3510
|
]
|
|
3461
3511
|
},
|
|
3512
|
+
{
|
|
3513
|
+
"type": "file",
|
|
3514
|
+
"name": "test/epoch-replay.test.ts",
|
|
3515
|
+
"hashes": [
|
|
3516
|
+
{
|
|
3517
|
+
"alg": "SHA-256",
|
|
3518
|
+
"content": "5f1f87608f41587f54f0b071b49ec19e1242ef94edc5c2a9be191534661f8ec9"
|
|
3519
|
+
}
|
|
3520
|
+
]
|
|
3521
|
+
},
|
|
3462
3522
|
{
|
|
3463
3523
|
"type": "file",
|
|
3464
3524
|
"name": "test/feature-adr-abs-paths.test.ts",
|
|
@@ -3565,7 +3625,7 @@
|
|
|
3565
3625
|
"hashes": [
|
|
3566
3626
|
{
|
|
3567
3627
|
"alg": "SHA-256",
|
|
3568
|
-
"content": "
|
|
3628
|
+
"content": "49829d24440130cfe403a410b9068a76631c68e9baa2cfc978f5029df2d72f3a"
|
|
3569
3629
|
}
|
|
3570
3630
|
]
|
|
3571
3631
|
},
|
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). */
|