@garygentry/system1-core 0.3.2 → 0.4.1
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/dist/config/load.d.ts +11 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +34 -5
- package/dist/config/load.js.map +1 -1
- package/dist/egress/scrub.d.ts.map +1 -1
- package/dist/egress/scrub.js +16 -0
- package/dist/egress/scrub.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/model/schema.d.ts +22 -0
- package/dist/model/schema.d.ts.map +1 -0
- package/dist/model/schema.js +26 -0
- package/dist/model/schema.js.map +1 -0
- package/dist/opportunities/backlog.d.ts +280 -0
- package/dist/opportunities/backlog.d.ts.map +1 -0
- package/dist/opportunities/backlog.js +393 -0
- package/dist/opportunities/backlog.js.map +1 -0
- package/dist/prepare.d.ts +8 -0
- package/dist/prepare.d.ts.map +1 -1
- package/dist/prepare.js +42 -6
- package/dist/prepare.js.map +1 -1
- package/dist/project/project.d.ts +36 -0
- package/dist/project/project.d.ts.map +1 -1
- package/dist/project/project.js +64 -18
- package/dist/project/project.js.map +1 -1
- package/dist/sources/filter.d.ts +19 -0
- package/dist/sources/filter.d.ts.map +1 -0
- package/dist/sources/filter.js +30 -0
- package/dist/sources/filter.js.map +1 -0
- package/dist/sources/read.d.ts +10 -0
- package/dist/sources/read.d.ts.map +1 -1
- package/dist/sources/read.js +7 -2
- package/dist/sources/read.js.map +1 -1
- package/dist/sources/types.d.ts +2 -2
- package/dist/sources/types.d.ts.map +1 -1
- package/dist/spec/lint.d.ts +35 -0
- package/dist/spec/lint.d.ts.map +1 -0
- package/dist/spec/lint.js +174 -0
- package/dist/spec/lint.js.map +1 -0
- package/dist/spec/spec.d.ts +2 -0
- package/dist/spec/spec.d.ts.map +1 -1
- package/dist/spec/spec.js +3 -1
- package/dist/spec/spec.js.map +1 -1
- package/dist/tools/ask.d.ts.map +1 -1
- package/dist/tools/ask.js +10 -13
- package/dist/tools/ask.js.map +1 -1
- package/dist/tools/context.d.ts +5 -0
- package/dist/tools/context.d.ts.map +1 -1
- package/dist/tools/context.js +24 -0
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/doctor.d.ts +3 -1
- package/dist/tools/doctor.d.ts.map +1 -1
- package/dist/tools/doctor.js +57 -10
- package/dist/tools/doctor.js.map +1 -1
- package/dist/tools/many.d.ts.map +1 -1
- package/dist/tools/many.js +3 -0
- package/dist/tools/many.js.map +1 -1
- package/dist/tools/opportunities.d.ts +33 -0
- package/dist/tools/opportunities.d.ts.map +1 -0
- package/dist/tools/opportunities.js +74 -0
- package/dist/tools/opportunities.js.map +1 -0
- package/dist/tools/schemas.d.ts +155 -12
- package/dist/tools/schemas.d.ts.map +1 -1
- package/dist/tools/schemas.js +32 -25
- package/dist/tools/schemas.js.map +1 -1
- package/dist/tools/spec-check.d.ts +6 -0
- package/dist/tools/spec-check.d.ts.map +1 -1
- package/dist/tools/spec-check.js +3 -0
- package/dist/tools/spec-check.js.map +1 -1
- package/dist/tools/spec-lint.d.ts +31 -0
- package/dist/tools/spec-lint.d.ts.map +1 -0
- package/dist/tools/spec-lint.js +53 -0
- package/dist/tools/spec-lint.js.map +1 -0
- package/dist/transport/openrouter.d.ts +4 -1
- package/dist/transport/openrouter.d.ts.map +1 -1
- package/dist/transport/openrouter.js +5 -2
- package/dist/transport/openrouter.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wiring.d.ts.map +1 -1
- package/dist/wiring.js +11 -2
- package/dist/wiring.js.map +1 -1
- package/package.json +2 -2
- package/dist/config.d.ts +0 -28
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -64
- package/dist/config.js.map +0 -1
- package/dist/testdata/index.d.ts +0 -5
- package/dist/testdata/index.d.ts.map +0 -1
- package/dist/testdata/index.js +0 -6
- package/dist/testdata/index.js.map +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
import { type Op } from "../project/project.js";
|
|
3
|
+
/**
|
|
4
|
+
* The scout backlog: places where an LLM call, a hand-written heuristic or an
|
|
5
|
+
* agent fan-out is doing a decision model's job. The `scout` skill decides what
|
|
6
|
+
* counts; this module validates and persists it, so the backlog is a typed
|
|
7
|
+
* artifact rather than prose in a transcript. Every saving in it is projected
|
|
8
|
+
* (arithmetic over the inputs shown beside it), never measured.
|
|
9
|
+
*/
|
|
10
|
+
export declare const BACKLOG_FORMAT = 1;
|
|
11
|
+
export declare const CandidateSchema: Type.TObject<{
|
|
12
|
+
/** `code`: a codebase (Mode A). `agents`: skills, hooks, agent configuration (Mode B). */
|
|
13
|
+
mode: Type.TUnion<[Type.TLiteral<"code">, Type.TLiteral<"agents">]>;
|
|
14
|
+
location: Type.TObject<{
|
|
15
|
+
path: Type.TString;
|
|
16
|
+
lines: Type.TOptional<Type.TObject<{
|
|
17
|
+
start: Type.TInteger;
|
|
18
|
+
end: Type.TInteger;
|
|
19
|
+
}>>;
|
|
20
|
+
}>;
|
|
21
|
+
/** What does the job today, e.g. "gpt-4o call parsed to an enum". */
|
|
22
|
+
mechanism: Type.TString;
|
|
23
|
+
shape: Type.TUnion<[Type.TLiteral<"single">, Type.TLiteral<"fanout">, Type.TLiteral<"cascade">, Type.TLiteral<"pairwise">]>;
|
|
24
|
+
/**
|
|
25
|
+
* The text that triggered it, taken verbatim from the screened item. The id is
|
|
26
|
+
* derived from it, so it must be copied, not paraphrased.
|
|
27
|
+
*/
|
|
28
|
+
evidence: Type.TString;
|
|
29
|
+
/** A draft question set that would replace the mechanism. */
|
|
30
|
+
questions: Type.TRecord<"^.*$", Type.TUnion<[Type.TObject<{
|
|
31
|
+
type: Type.TLiteral<"noul">;
|
|
32
|
+
instructions: Type.TString;
|
|
33
|
+
criteria: Type.TOptional<Type.TRecord<"^.*$", Type.TString>>;
|
|
34
|
+
}>, Type.TObject<{
|
|
35
|
+
type: Type.TLiteral<"choice">;
|
|
36
|
+
instructions: Type.TString;
|
|
37
|
+
criteria: Type.TRecord<"^.*$", Type.TString>;
|
|
38
|
+
}>, Type.TObject<{
|
|
39
|
+
type: Type.TLiteral<"score">;
|
|
40
|
+
instructions: Type.TString;
|
|
41
|
+
criteria: Type.TArray<Type.TString>;
|
|
42
|
+
}>]>>;
|
|
43
|
+
/**
|
|
44
|
+
* What replacing the mechanism would mainly buy. `cost`: cheaper calls.
|
|
45
|
+
* `quality`: better or more consistent verdicts (a regex or heuristic that
|
|
46
|
+
* costs nothing today, so its `savingUsd` can be negative). `latency`:
|
|
47
|
+
* faster answers. Rank within a benefit, not across them.
|
|
48
|
+
*/
|
|
49
|
+
benefit: Type.TUnion<[Type.TLiteral<"cost">, Type.TLiteral<"quality">, Type.TLiteral<"latency">]>;
|
|
50
|
+
/** The inputs of the projected saving. The saving itself is computed here. */
|
|
51
|
+
projected: Type.TObject<{
|
|
52
|
+
volume: Type.TNumber;
|
|
53
|
+
/** What `volume` counts per, e.g. "day", "PR", "run". */
|
|
54
|
+
per: Type.TString;
|
|
55
|
+
currentCostPerItemUsd: Type.TNumber;
|
|
56
|
+
decisionCostPerItemUsd: Type.TNumber;
|
|
57
|
+
/** Where the figures came from. */
|
|
58
|
+
note: Type.TOptional<Type.TString>;
|
|
59
|
+
}>;
|
|
60
|
+
risk: Type.TObject<{
|
|
61
|
+
level: Type.TUnion<[Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">]>;
|
|
62
|
+
/** What would go wrong if the replacement erred. */
|
|
63
|
+
note: Type.TString;
|
|
64
|
+
}>;
|
|
65
|
+
/** The next step, e.g. "save the spec with design, then adopt". */
|
|
66
|
+
next: Type.TString;
|
|
67
|
+
/** Defaults to `new`. `rejected` needs a `statusReason`. */
|
|
68
|
+
status: Type.TOptional<Type.TUnion<[Type.TLiteral<"new">, Type.TLiteral<"stale">, Type.TLiteral<"adopted">, Type.TLiteral<"rejected">]>>;
|
|
69
|
+
statusReason: Type.TOptional<Type.TString>;
|
|
70
|
+
/** Which sweep produced it, and whether its screening answers were live or replayed. */
|
|
71
|
+
source: Type.TObject<{
|
|
72
|
+
sweep: Type.TString;
|
|
73
|
+
answers: Type.TUnion<[Type.TLiteral<"live">, Type.TLiteral<"replay">]>;
|
|
74
|
+
}>;
|
|
75
|
+
}>;
|
|
76
|
+
export declare const OpportunitySchema: Type.TObject<{
|
|
77
|
+
/** `code`: a codebase (Mode A). `agents`: skills, hooks, agent configuration (Mode B). */
|
|
78
|
+
mode: Type.TUnion<[Type.TLiteral<"code">, Type.TLiteral<"agents">]>;
|
|
79
|
+
location: Type.TObject<{
|
|
80
|
+
path: Type.TString;
|
|
81
|
+
lines: Type.TOptional<Type.TObject<{
|
|
82
|
+
start: Type.TInteger;
|
|
83
|
+
end: Type.TInteger;
|
|
84
|
+
}>>;
|
|
85
|
+
}>;
|
|
86
|
+
/** What does the job today, e.g. "gpt-4o call parsed to an enum". */
|
|
87
|
+
mechanism: Type.TString;
|
|
88
|
+
shape: Type.TUnion<[Type.TLiteral<"single">, Type.TLiteral<"fanout">, Type.TLiteral<"cascade">, Type.TLiteral<"pairwise">]>;
|
|
89
|
+
/**
|
|
90
|
+
* The text that triggered it, taken verbatim from the screened item. The id is
|
|
91
|
+
* derived from it, so it must be copied, not paraphrased.
|
|
92
|
+
*/
|
|
93
|
+
evidence: Type.TString;
|
|
94
|
+
/** A draft question set that would replace the mechanism. */
|
|
95
|
+
questions: Type.TRecord<"^.*$", Type.TUnion<[Type.TObject<{
|
|
96
|
+
type: Type.TLiteral<"noul">;
|
|
97
|
+
instructions: Type.TString;
|
|
98
|
+
criteria: Type.TOptional<Type.TRecord<"^.*$", Type.TString>>;
|
|
99
|
+
}>, Type.TObject<{
|
|
100
|
+
type: Type.TLiteral<"choice">;
|
|
101
|
+
instructions: Type.TString;
|
|
102
|
+
criteria: Type.TRecord<"^.*$", Type.TString>;
|
|
103
|
+
}>, Type.TObject<{
|
|
104
|
+
type: Type.TLiteral<"score">;
|
|
105
|
+
instructions: Type.TString;
|
|
106
|
+
criteria: Type.TArray<Type.TString>;
|
|
107
|
+
}>]>>;
|
|
108
|
+
/**
|
|
109
|
+
* What replacing the mechanism would mainly buy. `cost`: cheaper calls.
|
|
110
|
+
* `quality`: better or more consistent verdicts (a regex or heuristic that
|
|
111
|
+
* costs nothing today, so its `savingUsd` can be negative). `latency`:
|
|
112
|
+
* faster answers. Rank within a benefit, not across them.
|
|
113
|
+
*/
|
|
114
|
+
benefit: Type.TUnion<[Type.TLiteral<"cost">, Type.TLiteral<"quality">, Type.TLiteral<"latency">]>;
|
|
115
|
+
risk: Type.TObject<{
|
|
116
|
+
level: Type.TUnion<[Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">]>;
|
|
117
|
+
/** What would go wrong if the replacement erred. */
|
|
118
|
+
note: Type.TString;
|
|
119
|
+
}>;
|
|
120
|
+
/** The next step, e.g. "save the spec with design, then adopt". */
|
|
121
|
+
next: Type.TString;
|
|
122
|
+
statusReason: Type.TOptional<Type.TString>;
|
|
123
|
+
/** Which sweep produced it, and whether its screening answers were live or replayed. */
|
|
124
|
+
source: Type.TObject<{
|
|
125
|
+
sweep: Type.TString;
|
|
126
|
+
answers: Type.TUnion<[Type.TLiteral<"live">, Type.TLiteral<"replay">]>;
|
|
127
|
+
}>;
|
|
128
|
+
id: Type.TString;
|
|
129
|
+
status: Type.TUnion<[Type.TLiteral<"new">, Type.TLiteral<"stale">, Type.TLiteral<"adopted">, Type.TLiteral<"rejected">]>;
|
|
130
|
+
projected: Type.TObject<{
|
|
131
|
+
volume: Type.TNumber;
|
|
132
|
+
/** What `volume` counts per, e.g. "day", "PR", "run". */
|
|
133
|
+
per: Type.TString;
|
|
134
|
+
currentCostPerItemUsd: Type.TNumber;
|
|
135
|
+
decisionCostPerItemUsd: Type.TNumber;
|
|
136
|
+
/** Where the figures came from. */
|
|
137
|
+
note: Type.TOptional<Type.TString>;
|
|
138
|
+
basis: Type.TLiteral<"projected">;
|
|
139
|
+
/** volume × (current − decision), per `per`. Negative means it would cost more. */
|
|
140
|
+
savingUsd: Type.TNumber;
|
|
141
|
+
}>;
|
|
142
|
+
firstSeenAt: Type.TString;
|
|
143
|
+
seenAt: Type.TString;
|
|
144
|
+
}>;
|
|
145
|
+
export declare const BacklogSchema: Type.TObject<{
|
|
146
|
+
version: Type.TLiteral<1>;
|
|
147
|
+
opportunities: Type.TArray<Type.TObject<{
|
|
148
|
+
/** `code`: a codebase (Mode A). `agents`: skills, hooks, agent configuration (Mode B). */
|
|
149
|
+
mode: Type.TUnion<[Type.TLiteral<"code">, Type.TLiteral<"agents">]>;
|
|
150
|
+
location: Type.TObject<{
|
|
151
|
+
path: Type.TString;
|
|
152
|
+
lines: Type.TOptional<Type.TObject<{
|
|
153
|
+
start: Type.TInteger;
|
|
154
|
+
end: Type.TInteger;
|
|
155
|
+
}>>;
|
|
156
|
+
}>;
|
|
157
|
+
/** What does the job today, e.g. "gpt-4o call parsed to an enum". */
|
|
158
|
+
mechanism: Type.TString;
|
|
159
|
+
shape: Type.TUnion<[Type.TLiteral<"single">, Type.TLiteral<"fanout">, Type.TLiteral<"cascade">, Type.TLiteral<"pairwise">]>;
|
|
160
|
+
/**
|
|
161
|
+
* The text that triggered it, taken verbatim from the screened item. The id is
|
|
162
|
+
* derived from it, so it must be copied, not paraphrased.
|
|
163
|
+
*/
|
|
164
|
+
evidence: Type.TString;
|
|
165
|
+
/** A draft question set that would replace the mechanism. */
|
|
166
|
+
questions: Type.TRecord<"^.*$", Type.TUnion<[Type.TObject<{
|
|
167
|
+
type: Type.TLiteral<"noul">;
|
|
168
|
+
instructions: Type.TString;
|
|
169
|
+
criteria: Type.TOptional<Type.TRecord<"^.*$", Type.TString>>;
|
|
170
|
+
}>, Type.TObject<{
|
|
171
|
+
type: Type.TLiteral<"choice">;
|
|
172
|
+
instructions: Type.TString;
|
|
173
|
+
criteria: Type.TRecord<"^.*$", Type.TString>;
|
|
174
|
+
}>, Type.TObject<{
|
|
175
|
+
type: Type.TLiteral<"score">;
|
|
176
|
+
instructions: Type.TString;
|
|
177
|
+
criteria: Type.TArray<Type.TString>;
|
|
178
|
+
}>]>>;
|
|
179
|
+
/**
|
|
180
|
+
* What replacing the mechanism would mainly buy. `cost`: cheaper calls.
|
|
181
|
+
* `quality`: better or more consistent verdicts (a regex or heuristic that
|
|
182
|
+
* costs nothing today, so its `savingUsd` can be negative). `latency`:
|
|
183
|
+
* faster answers. Rank within a benefit, not across them.
|
|
184
|
+
*/
|
|
185
|
+
benefit: Type.TUnion<[Type.TLiteral<"cost">, Type.TLiteral<"quality">, Type.TLiteral<"latency">]>;
|
|
186
|
+
risk: Type.TObject<{
|
|
187
|
+
level: Type.TUnion<[Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">]>;
|
|
188
|
+
/** What would go wrong if the replacement erred. */
|
|
189
|
+
note: Type.TString;
|
|
190
|
+
}>;
|
|
191
|
+
/** The next step, e.g. "save the spec with design, then adopt". */
|
|
192
|
+
next: Type.TString;
|
|
193
|
+
statusReason: Type.TOptional<Type.TString>;
|
|
194
|
+
/** Which sweep produced it, and whether its screening answers were live or replayed. */
|
|
195
|
+
source: Type.TObject<{
|
|
196
|
+
sweep: Type.TString;
|
|
197
|
+
answers: Type.TUnion<[Type.TLiteral<"live">, Type.TLiteral<"replay">]>;
|
|
198
|
+
}>;
|
|
199
|
+
id: Type.TString;
|
|
200
|
+
status: Type.TUnion<[Type.TLiteral<"new">, Type.TLiteral<"stale">, Type.TLiteral<"adopted">, Type.TLiteral<"rejected">]>;
|
|
201
|
+
projected: Type.TObject<{
|
|
202
|
+
volume: Type.TNumber;
|
|
203
|
+
/** What `volume` counts per, e.g. "day", "PR", "run". */
|
|
204
|
+
per: Type.TString;
|
|
205
|
+
currentCostPerItemUsd: Type.TNumber;
|
|
206
|
+
decisionCostPerItemUsd: Type.TNumber;
|
|
207
|
+
/** Where the figures came from. */
|
|
208
|
+
note: Type.TOptional<Type.TString>;
|
|
209
|
+
basis: Type.TLiteral<"projected">;
|
|
210
|
+
/** volume × (current − decision), per `per`. Negative means it would cost more. */
|
|
211
|
+
savingUsd: Type.TNumber;
|
|
212
|
+
}>;
|
|
213
|
+
firstSeenAt: Type.TString;
|
|
214
|
+
seenAt: Type.TString;
|
|
215
|
+
}>>;
|
|
216
|
+
}>;
|
|
217
|
+
export type Candidate = Static<typeof CandidateSchema>;
|
|
218
|
+
export type Opportunity = Static<typeof OpportunitySchema>;
|
|
219
|
+
export type Backlog = Static<typeof BacklogSchema>;
|
|
220
|
+
export type OpportunityStatus = Opportunity["status"];
|
|
221
|
+
export declare function backlogPath(repoRoot: string): string;
|
|
222
|
+
/**
|
|
223
|
+
* Stable across re-runs and file moves: the mode and the evidence, with
|
|
224
|
+
* whitespace collapsed. Not the path (a moved file keeps its id) and nothing
|
|
225
|
+
* the agent paraphrases.
|
|
226
|
+
*/
|
|
227
|
+
export declare function opportunityId(mode: Candidate["mode"], evidence: string): string;
|
|
228
|
+
export declare function projectedSaving(p: Candidate["projected"]): number;
|
|
229
|
+
/** Every problem with a backlog value, as `path message` lines. Empty when valid. */
|
|
230
|
+
export declare function backlogProblems(value: unknown): string[];
|
|
231
|
+
/**
|
|
232
|
+
* Read the backlog. A missing file is an empty backlog; a malformed one is an
|
|
233
|
+
* error, and is never repaired or overwritten.
|
|
234
|
+
*/
|
|
235
|
+
export declare function readBacklog(file: string): Backlog;
|
|
236
|
+
/**
|
|
237
|
+
* Run `body` holding the backlog's lock, so concurrent `add`s (two sweeps, or
|
|
238
|
+
* a sweep run in parallel) serialise instead of losing each other's entries.
|
|
239
|
+
* A lock older than 30 s is from a crashed run and is taken over.
|
|
240
|
+
*/
|
|
241
|
+
export declare function withBacklogLock<T>(file: string, body: () => T): T;
|
|
242
|
+
/** Written whole, through a temp file, so a crash never leaves half a backlog. */
|
|
243
|
+
export declare function writeBacklog(file: string, backlog: Backlog): void;
|
|
244
|
+
export interface MergeResult {
|
|
245
|
+
backlog: Backlog;
|
|
246
|
+
added: string[];
|
|
247
|
+
updated: string[];
|
|
248
|
+
/** Entries at a path this add covered, not seen again, and so superseded. */
|
|
249
|
+
staled: string[];
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Merge candidates into a backlog.
|
|
253
|
+
*
|
|
254
|
+
* - A candidate whose id exists updates that entry (location, draft, figures,
|
|
255
|
+
* `seenAt`), keeping `firstSeenAt`. Its status changes only when the
|
|
256
|
+
* candidate gives one, so a rejection survives a re-sweep.
|
|
257
|
+
* - A new id is added with status `new` unless the candidate says otherwise.
|
|
258
|
+
* - A `new` entry that this add's sweep produced before, at a path the add
|
|
259
|
+
* covers, in the same mode, and that the add didn't see again is marked
|
|
260
|
+
* `stale`: its evidence changed. It is never deleted. A stale entry seen again
|
|
261
|
+
* is `new` again.
|
|
262
|
+
*/
|
|
263
|
+
export declare function mergeCandidates(backlog: Backlog, candidates: readonly Candidate[], now: string): MergeResult;
|
|
264
|
+
/** Top-level fields of an entry, for `--fields`. */
|
|
265
|
+
export declare const ENTRY_FIELDS: readonly string[];
|
|
266
|
+
export interface RecordFilter {
|
|
267
|
+
field: string;
|
|
268
|
+
op: Op;
|
|
269
|
+
value: number | string | string[];
|
|
270
|
+
source: string;
|
|
271
|
+
}
|
|
272
|
+
/** `status=new`, `risk in medium,high`, `projected>=0.5`, `location.path=src/a.ts`. */
|
|
273
|
+
export declare function parseRecordFilter(text: string): RecordFilter;
|
|
274
|
+
export declare function parseRecordSort(text: string): {
|
|
275
|
+
field: string;
|
|
276
|
+
direction: "asc" | "desc";
|
|
277
|
+
};
|
|
278
|
+
export declare function readField(o: Opportunity, field: string): unknown;
|
|
279
|
+
export declare function recordMatches(o: Opportunity, f: RecordFilter): boolean;
|
|
280
|
+
//# sourceMappingURL=backlog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backlog.d.ts","sourceRoot":"","sources":["../../src/opportunities/backlog.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAM3C,OAAO,EAAE,KAAK,EAAE,EAAgC,MAAM,uBAAuB,CAAA;AAE7E;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,IAAI,CAAA;AAuF/B,eAAO,MAAM,eAAe;IAxE1B,0FAA0F;;;;;;;;;IAc1F,qEAAqE;;;IAQrE;;;OAGG;;IAEH,6DAA6D;;;;;;;;;;;;;;IAE7D;;;;;OAKG;;IAEH,8EAA8E;;;QAI1E,yDAAyD;;;;QAIzD,mCAAmC;;;;;QAQnC,oDAAoD;;;IAKxD,mEAAmE;;IAEnE,4DAA4D;;;IAG5D,wFAAwF;;;;;EAUvB,CAAA;AAEnE,eAAO,MAAM,iBAAiB;IA1E5B,0FAA0F;;;;;;;;;IAc1F,qEAAqE;;;IAQrE;;;OAGG;;IAEH,6DAA6D;;;;;;;;;;;;;;IAE7D;;;;;OAKG;;;;QAkBC,oDAAoD;;;IAKxD,mEAAmE;;;IAKnE,wFAAwF;;;;;;;;;QAtBpF,yDAAyD;;;;QAIzD,mCAAmC;;;QAuCjC,mFAAmF;;;;;EAS1F,CAAA;AAED,eAAO,MAAM,aAAa;;;QA9FxB,0FAA0F;;;;;;;;;QAc1F,qEAAqE;;;QAQrE;;;WAGG;;QAEH,6DAA6D;;;;;;;;;;;;;;QAE7D;;;;;WAKG;;;;YAkBC,oDAAoD;;;QAKxD,mEAAmE;;;QAKnE,wFAAwF;;;;;;;;;YAtBpF,yDAAyD;;;;YAIzD,mCAAmC;;;YAuCjC,mFAAmF;;;;;;EAiB1F,CAAA;AAED,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,OAAO,eAAe,CAAC,CAAA;AACtD,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAC1D,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,aAAa,CAAC,CAAA;AAClD,MAAM,MAAM,iBAAiB,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAErD,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG/E;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,SAAS,CAAC,WAAW,CAAC,GAAG,MAAM,CAEjE;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAmBxD;AAsBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAiBjD;AAaD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CA4BjE;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAKjE;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,OAAO,EAChB,UAAU,EAAE,SAAS,SAAS,EAAE,EAChC,GAAG,EAAE,MAAM,GACV,WAAW,CA8Cb;AAoCD,oDAAoD;AACpD,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAmD,CAAA;AAE7F,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,EAAE,EAAE,EAAE,CAAA;IACN,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC,MAAM,EAAE,MAAM,CAAA;CACf;AAED,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAgB5D;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;CAAE,CAS1F;AAsBD,wBAAgB,SAAS,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAOhE;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,YAAY,GAAG,OAAO,CAqBtE"}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
import { stateDir } from "../config/load.js";
|
|
7
|
+
import { DecisionsError } from "../errors.js";
|
|
8
|
+
import { QuestionSchema } from "../model/schema.js";
|
|
9
|
+
import { assertQuestionSet } from "../model/validate.js";
|
|
10
|
+
import { tokenizeFilter, tokenizeSort } from "../project/project.js";
|
|
11
|
+
/**
|
|
12
|
+
* The scout backlog: places where an LLM call, a hand-written heuristic or an
|
|
13
|
+
* agent fan-out is doing a decision model's job. The `scout` skill decides what
|
|
14
|
+
* counts; this module validates and persists it, so the backlog is a typed
|
|
15
|
+
* artifact rather than prose in a transcript. Every saving in it is projected
|
|
16
|
+
* (arithmetic over the inputs shown beside it), never measured.
|
|
17
|
+
*/
|
|
18
|
+
export const BACKLOG_FORMAT = 1;
|
|
19
|
+
const CLOSED = { additionalProperties: false };
|
|
20
|
+
const Text = Type.String({ minLength: 1 });
|
|
21
|
+
/** Bounded so the computed saving is always a finite number that survives JSON. */
|
|
22
|
+
const Usd = Type.Number({ minimum: 0, maximum: 1e6 });
|
|
23
|
+
const Status = Type.Union([
|
|
24
|
+
Type.Literal("new"),
|
|
25
|
+
Type.Literal("stale"),
|
|
26
|
+
Type.Literal("adopted"),
|
|
27
|
+
Type.Literal("rejected"),
|
|
28
|
+
]);
|
|
29
|
+
const candidateFields = {
|
|
30
|
+
/** `code`: a codebase (Mode A). `agents`: skills, hooks, agent configuration (Mode B). */
|
|
31
|
+
mode: Type.Union([Type.Literal("code"), Type.Literal("agents")]),
|
|
32
|
+
location: Type.Object({
|
|
33
|
+
path: Text,
|
|
34
|
+
lines: Type.Optional(Type.Object({ start: Type.Integer({ minimum: 1 }), end: Type.Integer({ minimum: 1 }) }, CLOSED)),
|
|
35
|
+
}, CLOSED),
|
|
36
|
+
/** What does the job today, e.g. "gpt-4o call parsed to an enum". */
|
|
37
|
+
mechanism: Text,
|
|
38
|
+
shape: Type.Union([
|
|
39
|
+
Type.Literal("single"),
|
|
40
|
+
Type.Literal("fanout"),
|
|
41
|
+
Type.Literal("cascade"),
|
|
42
|
+
Type.Literal("pairwise"),
|
|
43
|
+
]),
|
|
44
|
+
/**
|
|
45
|
+
* The text that triggered it, taken verbatim from the screened item. The id is
|
|
46
|
+
* derived from it, so it must be copied, not paraphrased.
|
|
47
|
+
*/
|
|
48
|
+
evidence: Type.String({ pattern: "\\S" }),
|
|
49
|
+
/** A draft question set that would replace the mechanism. */
|
|
50
|
+
questions: Type.Record(Type.String(), QuestionSchema, { minProperties: 1 }),
|
|
51
|
+
/**
|
|
52
|
+
* What replacing the mechanism would mainly buy. `cost`: cheaper calls.
|
|
53
|
+
* `quality`: better or more consistent verdicts (a regex or heuristic that
|
|
54
|
+
* costs nothing today, so its `savingUsd` can be negative). `latency`:
|
|
55
|
+
* faster answers. Rank within a benefit, not across them.
|
|
56
|
+
*/
|
|
57
|
+
benefit: Type.Union([Type.Literal("cost"), Type.Literal("quality"), Type.Literal("latency")]),
|
|
58
|
+
/** The inputs of the projected saving. The saving itself is computed here. */
|
|
59
|
+
projected: Type.Object({
|
|
60
|
+
volume: Type.Number({ minimum: 0, maximum: 1e12 }),
|
|
61
|
+
/** What `volume` counts per, e.g. "day", "PR", "run". */
|
|
62
|
+
per: Text,
|
|
63
|
+
currentCostPerItemUsd: Usd,
|
|
64
|
+
decisionCostPerItemUsd: Usd,
|
|
65
|
+
/** Where the figures came from. */
|
|
66
|
+
note: Type.Optional(Type.String()),
|
|
67
|
+
}, CLOSED),
|
|
68
|
+
risk: Type.Object({
|
|
69
|
+
level: Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]),
|
|
70
|
+
/** What would go wrong if the replacement erred. */
|
|
71
|
+
note: Text,
|
|
72
|
+
}, CLOSED),
|
|
73
|
+
/** The next step, e.g. "save the spec with design, then adopt". */
|
|
74
|
+
next: Text,
|
|
75
|
+
/** Defaults to `new`. `rejected` needs a `statusReason`. */
|
|
76
|
+
status: Type.Optional(Status),
|
|
77
|
+
statusReason: Type.Optional(Text),
|
|
78
|
+
/** Which sweep produced it, and whether its screening answers were live or replayed. */
|
|
79
|
+
source: Type.Object({
|
|
80
|
+
sweep: Text,
|
|
81
|
+
answers: Type.Union([Type.Literal("live"), Type.Literal("replay")]),
|
|
82
|
+
}, CLOSED),
|
|
83
|
+
};
|
|
84
|
+
export const CandidateSchema = Type.Object(candidateFields, CLOSED);
|
|
85
|
+
export const OpportunitySchema = Type.Object({
|
|
86
|
+
id: Type.String({ pattern: "^op-[0-9a-f]{12}$" }),
|
|
87
|
+
...candidateFields,
|
|
88
|
+
status: Status,
|
|
89
|
+
projected: Type.Object({
|
|
90
|
+
...candidateFields.projected.properties,
|
|
91
|
+
basis: Type.Literal("projected"),
|
|
92
|
+
/** volume × (current − decision), per `per`. Negative means it would cost more. */
|
|
93
|
+
savingUsd: Type.Number(),
|
|
94
|
+
}, CLOSED),
|
|
95
|
+
firstSeenAt: Type.String(),
|
|
96
|
+
seenAt: Type.String(),
|
|
97
|
+
}, CLOSED);
|
|
98
|
+
export const BacklogSchema = Type.Object({
|
|
99
|
+
version: Type.Literal(BACKLOG_FORMAT),
|
|
100
|
+
opportunities: Type.Array(OpportunitySchema),
|
|
101
|
+
}, CLOSED);
|
|
102
|
+
export function backlogPath(repoRoot) {
|
|
103
|
+
return join(stateDir(repoRoot), "opportunities.json");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Stable across re-runs and file moves: the mode and the evidence, with
|
|
107
|
+
* whitespace collapsed. Not the path (a moved file keeps its id) and nothing
|
|
108
|
+
* the agent paraphrases.
|
|
109
|
+
*/
|
|
110
|
+
export function opportunityId(mode, evidence) {
|
|
111
|
+
const normal = evidence.replace(/\s+/g, " ").trim();
|
|
112
|
+
return `op-${createHash("sha256").update(`${mode}\n${normal}`).digest("hex").slice(0, 12)}`;
|
|
113
|
+
}
|
|
114
|
+
export function projectedSaving(p) {
|
|
115
|
+
return round(p.volume * (p.currentCostPerItemUsd - p.decisionCostPerItemUsd));
|
|
116
|
+
}
|
|
117
|
+
/** Every problem with a backlog value, as `path message` lines. Empty when valid. */
|
|
118
|
+
export function backlogProblems(value) {
|
|
119
|
+
const problems = [...Value.Errors(BacklogSchema, value)].map((e) => `${e.instancePath || "/"} ${e.message}`);
|
|
120
|
+
if (problems.length)
|
|
121
|
+
return problems;
|
|
122
|
+
const backlog = value;
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
backlog.opportunities.forEach((o, i) => {
|
|
125
|
+
if (seen.has(o.id))
|
|
126
|
+
problems.push(`/opportunities/${i}/id ${o.id} appears twice`);
|
|
127
|
+
seen.add(o.id);
|
|
128
|
+
if (o.id !== opportunityId(o.mode, o.evidence)) {
|
|
129
|
+
problems.push(`/opportunities/${i}/id does not match its mode and evidence`);
|
|
130
|
+
}
|
|
131
|
+
if (o.projected.savingUsd !== projectedSaving(o.projected)) {
|
|
132
|
+
problems.push(`/opportunities/${i}/projected/savingUsd does not match its inputs`);
|
|
133
|
+
}
|
|
134
|
+
problems.push(...candidateProblems(o, `/opportunities/${i}`));
|
|
135
|
+
});
|
|
136
|
+
return problems;
|
|
137
|
+
}
|
|
138
|
+
/** The checks a schema can't express. */
|
|
139
|
+
function candidateProblems(c, at) {
|
|
140
|
+
const problems = [];
|
|
141
|
+
if (c.status === "rejected" && !c.statusReason) {
|
|
142
|
+
problems.push(`${at}/statusReason is required when status is rejected`);
|
|
143
|
+
}
|
|
144
|
+
if (!Number.isFinite(projectedSaving(c.projected))) {
|
|
145
|
+
problems.push(`${at}/projected gives a saving that isn't a finite number`);
|
|
146
|
+
}
|
|
147
|
+
if (c.location.lines && c.location.lines.end < c.location.lines.start) {
|
|
148
|
+
problems.push(`${at}/location/lines end is before start`);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
assertQuestionSet(c.questions);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
problems.push(`${at}/questions ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
return problems;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Read the backlog. A missing file is an empty backlog; a malformed one is an
|
|
160
|
+
* error, and is never repaired or overwritten.
|
|
161
|
+
*/
|
|
162
|
+
export function readBacklog(file) {
|
|
163
|
+
if (!existsSync(file))
|
|
164
|
+
return { version: BACKLOG_FORMAT, opportunities: [] };
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = readFileSync(file, "utf8");
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw malformed(file, [`cannot read it: ${error.message}`]);
|
|
171
|
+
}
|
|
172
|
+
let value;
|
|
173
|
+
try {
|
|
174
|
+
value = JSON.parse(text);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw malformed(file, [`not JSON: ${error.message}`]);
|
|
178
|
+
}
|
|
179
|
+
const problems = backlogProblems(value);
|
|
180
|
+
if (problems.length)
|
|
181
|
+
throw malformed(file, problems);
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
function malformed(file, problems) {
|
|
185
|
+
return new DecisionsError("invalid-request", `${file} is not a valid backlog (it was left as it is):\n ${problems.join("\n ")}`, { file, problems });
|
|
186
|
+
}
|
|
187
|
+
const LOCK_WAIT_MS = 5_000;
|
|
188
|
+
const LOCK_STALE_MS = 30_000;
|
|
189
|
+
/**
|
|
190
|
+
* Run `body` holding the backlog's lock, so concurrent `add`s (two sweeps, or
|
|
191
|
+
* a sweep run in parallel) serialise instead of losing each other's entries.
|
|
192
|
+
* A lock older than 30 s is from a crashed run and is taken over.
|
|
193
|
+
*/
|
|
194
|
+
export function withBacklogLock(file, body) {
|
|
195
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
196
|
+
const lock = `${file}.lock`;
|
|
197
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
198
|
+
for (;;) {
|
|
199
|
+
try {
|
|
200
|
+
closeSync(openSync(lock, "wx"));
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error.code !== "EEXIST")
|
|
205
|
+
throw error;
|
|
206
|
+
try {
|
|
207
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS)
|
|
208
|
+
rmSync(lock, { force: true });
|
|
209
|
+
}
|
|
210
|
+
catch { }
|
|
211
|
+
if (Date.now() > deadline) {
|
|
212
|
+
throw new DecisionsError("invalid-request", `${file} is locked by another \`decide opportunities add\` (${lock}). Retry, or delete the lock file if no add is running.`, { file, lock });
|
|
213
|
+
}
|
|
214
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
return body();
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
rmSync(lock, { force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Written whole, through a temp file, so a crash never leaves half a backlog. */
|
|
225
|
+
export function writeBacklog(file, backlog) {
|
|
226
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
227
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
228
|
+
writeFileSync(tmp, `${JSON.stringify(backlog, null, 2)}\n`);
|
|
229
|
+
renameSync(tmp, file);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Merge candidates into a backlog.
|
|
233
|
+
*
|
|
234
|
+
* - A candidate whose id exists updates that entry (location, draft, figures,
|
|
235
|
+
* `seenAt`), keeping `firstSeenAt`. Its status changes only when the
|
|
236
|
+
* candidate gives one, so a rejection survives a re-sweep.
|
|
237
|
+
* - A new id is added with status `new` unless the candidate says otherwise.
|
|
238
|
+
* - A `new` entry that this add's sweep produced before, at a path the add
|
|
239
|
+
* covers, in the same mode, and that the add didn't see again is marked
|
|
240
|
+
* `stale`: its evidence changed. It is never deleted. A stale entry seen again
|
|
241
|
+
* is `new` again.
|
|
242
|
+
*/
|
|
243
|
+
export function mergeCandidates(backlog, candidates, now) {
|
|
244
|
+
const problems = candidates.flatMap((c, i) => candidateProblems(c, `/candidates/${i}`));
|
|
245
|
+
if (problems.length) {
|
|
246
|
+
throw new DecisionsError("invalid-request", `Invalid candidates:\n ${problems.join("\n ")}`, {
|
|
247
|
+
problems,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const byId = new Map(backlog.opportunities.map((o) => [o.id, o]));
|
|
251
|
+
const existed = new Set(byId.keys());
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
for (const { statusReason: reason, ...c } of candidates) {
|
|
254
|
+
const id = opportunityId(c.mode, c.evidence);
|
|
255
|
+
seen.add(id);
|
|
256
|
+
const before = byId.get(id);
|
|
257
|
+
// A new status replaces the old reason; no status keeps both.
|
|
258
|
+
const statusReason = reason ?? (c.status ? undefined : before?.statusReason);
|
|
259
|
+
byId.set(id, {
|
|
260
|
+
id,
|
|
261
|
+
...c,
|
|
262
|
+
// Seeing the evidence again disproves "stale"; any other status stands.
|
|
263
|
+
status: c.status ?? (before?.status === "stale" ? "new" : before?.status) ?? "new",
|
|
264
|
+
...(statusReason ? { statusReason } : {}),
|
|
265
|
+
projected: { ...c.projected, basis: "projected", savingUsd: projectedSaving(c.projected) },
|
|
266
|
+
firstSeenAt: before?.firstSeenAt ?? now,
|
|
267
|
+
seenAt: now,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const added = [...seen].filter((id) => !existed.has(id));
|
|
271
|
+
const updated = [...seen].filter((id) => existed.has(id));
|
|
272
|
+
// Coverage is per sweep: another sweep's entries at the same path (different
|
|
273
|
+
// questions, or an incremental add) are not disproved by this one.
|
|
274
|
+
const key = (o) => `${o.mode}\n${o.location.path}\n${o.source.sweep}`;
|
|
275
|
+
const covered = new Set(candidates.map(key));
|
|
276
|
+
const staled = [];
|
|
277
|
+
for (const o of byId.values()) {
|
|
278
|
+
if (o.status === "new" && !seen.has(o.id) && covered.has(key(o))) {
|
|
279
|
+
byId.set(o.id, { ...o, status: "stale" });
|
|
280
|
+
staled.push(o.id);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
backlog: { version: BACKLOG_FORMAT, opportunities: [...byId.values()] },
|
|
285
|
+
added,
|
|
286
|
+
updated,
|
|
287
|
+
staled,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Fields a record filter or sort may name: the sub-fields each object field
|
|
292
|
+
* has, and the one a bare name means. Checked statically, so a typo is an
|
|
293
|
+
* error even on an empty backlog.
|
|
294
|
+
*/
|
|
295
|
+
const FIELDS = {
|
|
296
|
+
id: {},
|
|
297
|
+
mode: {},
|
|
298
|
+
benefit: {},
|
|
299
|
+
status: {},
|
|
300
|
+
statusReason: {},
|
|
301
|
+
shape: {},
|
|
302
|
+
mechanism: {},
|
|
303
|
+
evidence: {},
|
|
304
|
+
next: {},
|
|
305
|
+
firstSeenAt: {},
|
|
306
|
+
seenAt: {},
|
|
307
|
+
risk: { default: "level", subs: ["level", "note"] },
|
|
308
|
+
projected: {
|
|
309
|
+
default: "savingUsd",
|
|
310
|
+
subs: [
|
|
311
|
+
"savingUsd",
|
|
312
|
+
"volume",
|
|
313
|
+
"per",
|
|
314
|
+
"currentCostPerItemUsd",
|
|
315
|
+
"decisionCostPerItemUsd",
|
|
316
|
+
"basis",
|
|
317
|
+
"note",
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
location: { default: "path", subs: ["path", "lines.start", "lines.end"] },
|
|
321
|
+
source: { default: "sweep", subs: ["sweep", "answers"] },
|
|
322
|
+
};
|
|
323
|
+
/** Top-level fields of an entry, for `--fields`. */
|
|
324
|
+
export const ENTRY_FIELDS = [...Object.keys(OpportunitySchema.properties)];
|
|
325
|
+
/** `status=new`, `risk in medium,high`, `projected>=0.5`, `location.path=src/a.ts`. */
|
|
326
|
+
export function parseRecordFilter(text) {
|
|
327
|
+
const t = tokenizeFilter(text);
|
|
328
|
+
if (!t) {
|
|
329
|
+
throw new DecisionsError("invalid-request", `Cannot parse --keep "${text}". Use <field>[.<sub>]<op><value>, e.g. status=new or projected>=0.01`);
|
|
330
|
+
}
|
|
331
|
+
const field = recordField(t.name, t.path, text);
|
|
332
|
+
if ((t.op === ">" || t.op === ">=" || t.op === "<" || t.op === "<=") &&
|
|
333
|
+
typeof t.value !== "number") {
|
|
334
|
+
throw new DecisionsError("invalid-request", `--keep "${text}": ${t.op} needs a number`);
|
|
335
|
+
}
|
|
336
|
+
return { field, op: t.op, value: t.value, source: text };
|
|
337
|
+
}
|
|
338
|
+
export function parseRecordSort(text) {
|
|
339
|
+
const t = tokenizeSort(text);
|
|
340
|
+
if (!t) {
|
|
341
|
+
throw new DecisionsError("invalid-request", `Cannot parse --sort "${text}". Use <field>[.<sub>][:asc|desc]`);
|
|
342
|
+
}
|
|
343
|
+
return { field: recordField(t.name, t.path, text), direction: t.direction };
|
|
344
|
+
}
|
|
345
|
+
function recordField(name, path, text) {
|
|
346
|
+
const spec = Object.hasOwn(FIELDS, name) ? FIELDS[name] : undefined;
|
|
347
|
+
if (!spec) {
|
|
348
|
+
throw new DecisionsError("invalid-request", `"${text}": no field "${name}". Fields: ${Object.keys(FIELDS).join(", ")}`);
|
|
349
|
+
}
|
|
350
|
+
if (path && !spec.subs?.includes(path)) {
|
|
351
|
+
throw new DecisionsError("invalid-request", spec.subs
|
|
352
|
+
? `"${text}": ${name} has no "${path}". Use one of: ${spec.subs.join(", ")}`
|
|
353
|
+
: `"${text}": ${name} has no sub-fields`);
|
|
354
|
+
}
|
|
355
|
+
const sub = path || spec.default;
|
|
356
|
+
return sub ? `${name}.${sub}` : name;
|
|
357
|
+
}
|
|
358
|
+
export function readField(o, field) {
|
|
359
|
+
let value = o;
|
|
360
|
+
for (const key of field.split(".")) {
|
|
361
|
+
if (value === null || typeof value !== "object")
|
|
362
|
+
return undefined;
|
|
363
|
+
value = value[key];
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
export function recordMatches(o, f) {
|
|
368
|
+
const actual = readField(o, f.field);
|
|
369
|
+
switch (f.op) {
|
|
370
|
+
case "in":
|
|
371
|
+
return f.value.includes(String(actual));
|
|
372
|
+
case "=":
|
|
373
|
+
return String(actual) === String(f.value);
|
|
374
|
+
case "!=":
|
|
375
|
+
return String(actual) !== String(f.value);
|
|
376
|
+
default: {
|
|
377
|
+
if (typeof actual !== "number")
|
|
378
|
+
return false;
|
|
379
|
+
const v = f.value;
|
|
380
|
+
return f.op === ">="
|
|
381
|
+
? actual >= v
|
|
382
|
+
: f.op === ">"
|
|
383
|
+
? actual > v
|
|
384
|
+
: f.op === "<="
|
|
385
|
+
? actual <= v
|
|
386
|
+
: actual < v;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function round(n) {
|
|
391
|
+
return Math.round(n * 1e9) / 1e9;
|
|
392
|
+
}
|
|
393
|
+
//# sourceMappingURL=backlog.js.map
|