peaks-loop 4.0.0-beta.2 → 4.0.0-beta.3
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/README-en.md +16 -3
- package/README.md +16 -3
- package/dist/src/cli/commands/asset-commands.d.ts +45 -0
- package/dist/src/cli/commands/asset-commands.js +404 -0
- package/dist/src/cli/commands/bee-commands.d.ts +26 -0
- package/dist/src/cli/commands/bee-commands.js +169 -0
- package/dist/src/cli/commands/evolution-commands.d.ts +27 -0
- package/dist/src/cli/commands/evolution-commands.js +393 -0
- package/dist/src/cli/commands/loop-commands.d.ts +19 -13
- package/dist/src/cli/commands/loop-commands.js +147 -13
- package/dist/src/cli/commands/sediment-commands.js +9 -0
- package/dist/src/cli/commands/skill-loop-engineering-readiness-commands.d.ts +67 -0
- package/dist/src/cli/commands/skill-loop-engineering-readiness-commands.js +173 -0
- package/dist/src/cli/program.js +17 -0
- package/dist/src/services/crystallization/crystallization-service.d.ts +239 -0
- package/dist/src/services/crystallization/crystallization-service.js +397 -0
- package/dist/src/services/crystallization/crystallization-store.d.ts +75 -0
- package/dist/src/services/crystallization/crystallization-store.js +193 -0
- package/dist/src/services/crystallization/crystallization-types.d.ts +313 -0
- package/dist/src/services/crystallization/crystallization-types.js +299 -0
- package/dist/src/services/crystallization/evidence-brief-builder.d.ts +237 -0
- package/dist/src/services/crystallization/evidence-brief-builder.js +240 -0
- package/dist/src/services/evolution/evolution-service.d.ts +137 -0
- package/dist/src/services/evolution/evolution-service.js +281 -0
- package/dist/src/services/evolution/evolution-store.d.ts +57 -0
- package/dist/src/services/evolution/evolution-store.js +232 -0
- package/dist/src/services/evolution/evolution-types.d.ts +706 -0
- package/dist/src/services/evolution/evolution-types.js +286 -0
- package/dist/src/services/evolution/independent-evaluator-runner.d.ts +90 -0
- package/dist/src/services/evolution/independent-evaluator-runner.js +106 -0
- package/dist/src/services/evolution/regression-skeptic-runner.d.ts +64 -0
- package/dist/src/services/evolution/regression-skeptic-runner.js +113 -0
- package/dist/src/services/loop/loop-bee-relation-service.d.ts +107 -0
- package/dist/src/services/loop/loop-bee-relation-service.js +211 -0
- package/dist/src/services/loop/loop-bee-relation-store.d.ts +53 -0
- package/dist/src/services/loop/loop-bee-relation-store.js +130 -0
- package/dist/src/services/loop/loop-bee-relation-types.d.ts +118 -0
- package/dist/src/services/loop/loop-bee-relation-types.js +114 -0
- package/dist/src/services/loop/loop-release-service.d.ts +46 -0
- package/dist/src/services/loop/loop-release-service.js +66 -0
- package/dist/src/services/loop/loop-release-store.d.ts +55 -0
- package/dist/src/services/loop/loop-release-store.js +134 -0
- package/dist/src/services/loop/loop-release-types.d.ts +250 -0
- package/dist/src/services/loop/loop-release-types.js +162 -0
- package/dist/src/services/share/bundle-reader.d.ts +100 -0
- package/dist/src/services/share/bundle-reader.js +434 -0
- package/dist/src/services/share/bundle-types.d.ts +392 -0
- package/dist/src/services/share/bundle-types.js +318 -0
- package/dist/src/services/share/bundle-writer.d.ts +97 -0
- package/dist/src/services/share/bundle-writer.js +429 -0
- package/dist/src/services/share/run-state-contract.d.ts +133 -0
- package/dist/src/services/share/run-state-contract.js +138 -0
- package/dist/src/services/skillhub/release-import.js +7 -2
- package/dist/src/services/skillhub/release-retain.js +6 -1
- package/dist/src/services/skillhub/sqlite-store.js +17 -1
- package/dist/src/services/skillhub/types.d.ts +10 -0
- package/dist/src/services/standards/loop-engineering-lint.d.ts +38 -0
- package/dist/src/services/standards/loop-engineering-lint.js +101 -0
- package/dist/src/services/standards/loop-engineering-readiness-lint.d.ts +111 -0
- package/dist/src/services/standards/loop-engineering-readiness-lint.js +248 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/examples/video-demo/README.md +57 -0
- package/examples/video-demo/out/en-closing-960.png +0 -0
- package/examples/video-demo/out/zh-closing-960.png +0 -0
- package/examples/video-demo/preview/peaks-loop-demo-en.mp4 +0 -0
- package/examples/video-demo/preview/peaks-loop-demo.mp4 +0 -0
- package/package.json +6 -2
- package/skills/peaks-code/SKILL.md +13 -0
- package/skills/peaks-content/SKILL.md +272 -0
- package/skills/peaks-issue-fix-orchestrator/SKILL.md +307 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { EvidenceBriefSchema, } from "./crystallization-types.js";
|
|
3
|
+
/**
|
|
4
|
+
* EvidenceBriefBuilder — spec §4.7 / §10 RL-7.
|
|
5
|
+
*
|
|
6
|
+
* The brief is a 4-section natural-language projection that MUST
|
|
7
|
+
* accompany every user-facing recommendation. The shape guard is
|
|
8
|
+
* encoded in `EvidenceBriefSchema` (see crystallization-types.ts);
|
|
9
|
+
* this module is the LLM-side entry point that PRODUCES the brief
|
|
10
|
+
* from a finished trace + evaluator summary and the validator that
|
|
11
|
+
* REFUSES to render a recommendation payload without all 4 sections.
|
|
12
|
+
*
|
|
13
|
+
* Hard rules (mirrored from spec):
|
|
14
|
+
*
|
|
15
|
+
* 1. Every section must be a non-empty NL string (1-2 sentences
|
|
16
|
+
* for what_happened / why_it_matters / what_learned; 1
|
|
17
|
+
* sentence for what_action). An empty section is a hard
|
|
18
|
+
* `MISSING_BRIEF_SECTION` error.
|
|
19
|
+
*
|
|
20
|
+
* 2. The build path is lenient on raw prose and strict on the
|
|
21
|
+
* final shape — `buildEvidenceBrief` calls
|
|
22
|
+
* `EvidenceBriefSchema.parse(...)` so a partial brief throws
|
|
23
|
+
* before reaching the database.
|
|
24
|
+
*
|
|
25
|
+
* 3. `renderRecommendationPayload` refuses to return a
|
|
26
|
+
* recommendation envelope unless the brief passes the 4-section
|
|
27
|
+
* guard. This is the CLI's single source of truth for "may I
|
|
28
|
+
* show this to the user?".
|
|
29
|
+
*
|
|
30
|
+
* 4. The `evaluator_summary` is a separate field on the
|
|
31
|
+
* crystallization_event row (spec §4.5) and is NOT part of the
|
|
32
|
+
* brief. It feeds the broader recommendation envelope.
|
|
33
|
+
*
|
|
34
|
+
* Out of scope (deferred to later slices):
|
|
35
|
+
* - LLM-authored brief generation from live trace evidence (M8 dogfood).
|
|
36
|
+
* - brief rewrite-on-update semantics.
|
|
37
|
+
*/
|
|
38
|
+
/* ---------------------------------------------------------------------- */
|
|
39
|
+
/* Input shapes — the builder is the only place these normalize. */
|
|
40
|
+
/* ---------------------------------------------------------------------- */
|
|
41
|
+
/**
|
|
42
|
+
* The minimum viable trace shape that the builder needs to render
|
|
43
|
+
* a brief. The trace is the EVIDENCE; the builder is format-stable
|
|
44
|
+
* so a future slice can swap in a richer trace source without
|
|
45
|
+
* editing every caller.
|
|
46
|
+
*/
|
|
47
|
+
export const BriefTraceInputSchema = z.object({
|
|
48
|
+
trace_id: z.string().trim().min(1).max(256),
|
|
49
|
+
/**
|
|
50
|
+
* The full NL recap of the run. Required for what_happened.
|
|
51
|
+
* 1-2 sentences is the spec guidance; the builder does not
|
|
52
|
+
* truncate, but tests passing multi-paragraph input will be
|
|
53
|
+
* warned at the Zod boundary (max 4000 chars per section).
|
|
54
|
+
*/
|
|
55
|
+
what_happened: z.string().trim().min(1).max(4000),
|
|
56
|
+
/**
|
|
57
|
+
* The "why this matters" recap. Required for why_it_matters.
|
|
58
|
+
*/
|
|
59
|
+
why_it_matters: z.string().trim().min(1).max(4000),
|
|
60
|
+
/**
|
|
61
|
+
* Failure-mode and preference-extraction recap. Required for
|
|
62
|
+
* what_learned.
|
|
63
|
+
*/
|
|
64
|
+
what_learned: z.string().trim().min(1).max(4000),
|
|
65
|
+
/**
|
|
66
|
+
* The recommended action. Required for what_action (1
|
|
67
|
+
* sentence per spec).
|
|
68
|
+
*/
|
|
69
|
+
what_action: z.string().trim().min(1).max(4000),
|
|
70
|
+
/**
|
|
71
|
+
* Optional structured bullets (e.g. "4 phases, 3 gates passed,
|
|
72
|
+
* 1 evaluator verdict"). Counts are allowed at this layer; the
|
|
73
|
+
* brief section is the NL primary (RL-7).
|
|
74
|
+
*/
|
|
75
|
+
bullets: z.array(z.string().trim().min(1).max(1000)).default([]),
|
|
76
|
+
/**
|
|
77
|
+
* Source trace ids backing the brief (the column on
|
|
78
|
+
* crystallization_event spec §4.5).
|
|
79
|
+
*/
|
|
80
|
+
source_trace_pointers: z
|
|
81
|
+
.array(z.string().trim().min(1).max(256))
|
|
82
|
+
.default([]),
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* The summary emitted by the independent evaluators (spec §4.5
|
|
86
|
+
* `evaluator_summary` field). Optional for the brief builder — the
|
|
87
|
+
* brief can be built without it, but a filled evaluator_summary
|
|
88
|
+
* flows into the recommendation envelope.
|
|
89
|
+
*/
|
|
90
|
+
export const EvaluatorSummarySchema = z.object({
|
|
91
|
+
one_liner: z.string().trim().max(1000).default(""),
|
|
92
|
+
risk_tags: z.array(z.string().trim().min(1).max(200)).default([]),
|
|
93
|
+
});
|
|
94
|
+
/* ---------------------------------------------------------------------- */
|
|
95
|
+
/* Recommendation envelope — the CLI renders this. */
|
|
96
|
+
/* ---------------------------------------------------------------------- */
|
|
97
|
+
export const RecommendationPayloadSchema = z.object({
|
|
98
|
+
brief: EvidenceBriefSchema,
|
|
99
|
+
bullets: z.array(z.string().trim().min(1).max(1000)).default([]),
|
|
100
|
+
source_trace_pointers: z
|
|
101
|
+
.array(z.string().trim().min(1).max(256))
|
|
102
|
+
.default([]),
|
|
103
|
+
evaluator_summary: EvaluatorSummarySchema.default({
|
|
104
|
+
one_liner: "",
|
|
105
|
+
risk_tags: [],
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
/* ---------------------------------------------------------------------- */
|
|
109
|
+
/* Errors */
|
|
110
|
+
/* ---------------------------------------------------------------------- */
|
|
111
|
+
export class BriefSectionError extends Error {
|
|
112
|
+
code = "MISSING_BRIEF_SECTION";
|
|
113
|
+
findings;
|
|
114
|
+
constructor(message, findings = []) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.name = "BriefSectionError";
|
|
117
|
+
this.findings = findings;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/* ---------------------------------------------------------------------- */
|
|
121
|
+
/* Builder */
|
|
122
|
+
/* ---------------------------------------------------------------------- */
|
|
123
|
+
/**
|
|
124
|
+
* Build a 4-section EvidenceBrief from a finished trace recap.
|
|
125
|
+
*
|
|
126
|
+
* The function is STRICT: it parses the input trace through
|
|
127
|
+
* `BriefTraceInputSchema`, then parses the assembled brief through
|
|
128
|
+
* `EvidenceBriefSchema`. Any missing section throws
|
|
129
|
+
* `BriefSectionError(MISSING_BRIEF_SECTION)` — the same code that
|
|
130
|
+
* `safeParseEvidenceBrief` returns — so a single error code is the
|
|
131
|
+
* user-facing signal for "brief incomplete, do not render".
|
|
132
|
+
*
|
|
133
|
+
* Brief-section related ZodErrors (e.g. `min(1)` on a section) are
|
|
134
|
+
* translated to `BriefSectionError` so callers only handle one
|
|
135
|
+
* shape of failure. Non-section ZodErrors (e.g. invalid `trace_id`)
|
|
136
|
+
* are re-raised unchanged.
|
|
137
|
+
*
|
|
138
|
+
* NOTE: callers who already have raw NL strings can also call
|
|
139
|
+
* `parseEvidenceBrief` directly; this function is the opinionated
|
|
140
|
+
* shape for trace -> brief.
|
|
141
|
+
*/
|
|
142
|
+
export function buildEvidenceBrief(trace, evaluatorSummary) {
|
|
143
|
+
let parsedTrace;
|
|
144
|
+
try {
|
|
145
|
+
parsedTrace = BriefTraceInputSchema.parse(trace);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
throw translateBriefZodError(err);
|
|
149
|
+
}
|
|
150
|
+
// Assemble the 4 sections verbatim from the trace recap. The Zod
|
|
151
|
+
// parser below re-asserts the 4-section guard.
|
|
152
|
+
const candidate = {
|
|
153
|
+
what_happened: parsedTrace.what_happened,
|
|
154
|
+
why_it_matters: parsedTrace.why_it_matters,
|
|
155
|
+
what_learned: parsedTrace.what_learned,
|
|
156
|
+
what_action: parsedTrace.what_action,
|
|
157
|
+
};
|
|
158
|
+
const result = EvidenceBriefSchema.safeParse(candidate);
|
|
159
|
+
if (!result.success) {
|
|
160
|
+
const findings = result.error.issues.map((i) => ({
|
|
161
|
+
path: i.path.join("."),
|
|
162
|
+
message: i.message,
|
|
163
|
+
}));
|
|
164
|
+
throw new BriefSectionError("evidence_brief must contain all 4 sections (what_happened, why_it_matters, what_learned, what_action) with non-empty content (spec §4.7 / RL-7)", findings);
|
|
165
|
+
}
|
|
166
|
+
// Silence the unused-var lint on evaluatorSummary at this layer —
|
|
167
|
+
// the builder currently does not consume it (the brief is NL
|
|
168
|
+
// primary), but the surface is preserved for callers via
|
|
169
|
+
// `renderRecommendationPayload` below.
|
|
170
|
+
void evaluatorSummary;
|
|
171
|
+
return result.data;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Translate a ZodError raised on a brief-section path into a
|
|
175
|
+
* `BriefSectionError`. Non-brief errors are re-raised.
|
|
176
|
+
*/
|
|
177
|
+
function translateBriefZodError(err) {
|
|
178
|
+
if (err instanceof z.ZodError) {
|
|
179
|
+
const issues = err.issues;
|
|
180
|
+
const allBrief = issues.every((i) => BRIEF_SECTION_KEYS.has(i.path.join(".")));
|
|
181
|
+
if (allBrief) {
|
|
182
|
+
const findings = issues.map((i) => ({
|
|
183
|
+
path: i.path.join("."),
|
|
184
|
+
message: i.message,
|
|
185
|
+
}));
|
|
186
|
+
throw new BriefSectionError("evidence_brief must contain all 4 sections (what_happened, why_it_matters, what_learned, what_action) with non-empty content (spec §4.7 / RL-7)", findings);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
const BRIEF_SECTION_KEYS = new Set([
|
|
192
|
+
"what_happened",
|
|
193
|
+
"why_it_matters",
|
|
194
|
+
"what_learned",
|
|
195
|
+
"what_action",
|
|
196
|
+
]);
|
|
197
|
+
/**
|
|
198
|
+
* Render the user-facing recommendation envelope (brief + bullets +
|
|
199
|
+
* trace pointers + evaluator summary). The function refuses to
|
|
200
|
+
* return a payload unless ALL 4 brief sections are present — this
|
|
201
|
+
* is the single CLI-facing gate per spec §10 RL-7.
|
|
202
|
+
*
|
|
203
|
+
* On success, returns the validated `RecommendationPayload`. On a
|
|
204
|
+
* missing-section brief, throws `BriefSectionError`.
|
|
205
|
+
*/
|
|
206
|
+
export function renderRecommendationPayload(trace, evaluatorSummary = { one_liner: "", risk_tags: [] }) {
|
|
207
|
+
const brief = buildEvidenceBrief(trace, evaluatorSummary);
|
|
208
|
+
const payload = RecommendationPayloadSchema.parse({
|
|
209
|
+
brief,
|
|
210
|
+
bullets: trace.bullets ?? [],
|
|
211
|
+
source_trace_pointers: trace.source_trace_pointers ?? [],
|
|
212
|
+
evaluator_summary: evaluatorSummary,
|
|
213
|
+
});
|
|
214
|
+
return payload;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Convenience: validate an arbitrary unknown payload AS IF it were a
|
|
218
|
+
* recommendation envelope. Returns a Result-like shape so the CLI can
|
|
219
|
+
* render a structured MISSING_BRIEF_SECTION error without try/catch
|
|
220
|
+
* noise.
|
|
221
|
+
*/
|
|
222
|
+
export function safeRenderRecommendationPayload(input) {
|
|
223
|
+
const r = RecommendationPayloadSchema.safeParse(input);
|
|
224
|
+
if (r.success)
|
|
225
|
+
return { ok: true, payload: r.data };
|
|
226
|
+
const findings = r.error.issues.map((i) => ({
|
|
227
|
+
path: i.path.join("."),
|
|
228
|
+
message: i.message,
|
|
229
|
+
}));
|
|
230
|
+
// Map brief failures to MISSING_BRIEF_SECTION. A failure is
|
|
231
|
+
// recognized as brief iff (a) every issue points at the brief or
|
|
232
|
+
// its sections, OR (b) the explicit refine message is present.
|
|
233
|
+
const isBrief = r.error.issues.every((i) => i.message.includes("evidence_brief must contain all 4 sections") ||
|
|
234
|
+
BRIEF_SECTION_KEYS.has(i.path.join(".")) ||
|
|
235
|
+
i.path[0] === "brief" ||
|
|
236
|
+
(i.path.length >= 2 && i.path[0] === "brief"));
|
|
237
|
+
return isBrief
|
|
238
|
+
? { ok: false, code: "MISSING_BRIEF_SECTION", findings }
|
|
239
|
+
: { ok: false, findings };
|
|
240
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { ZodError } from "zod";
|
|
3
|
+
import { EVOLUTION_DEFAULT_DELTA_MIN, type EvolutionEvaluation, type EvolutionProposal, type EvolutionProposalInput, type EvolutionTargetKind, type EvolutionVerdict } from "./evolution-types.js";
|
|
4
|
+
/**
|
|
5
|
+
* EvolutionService — Darwin-style ratchet enforcement.
|
|
6
|
+
*
|
|
7
|
+
* Hard rules enforced at THIS layer (not just the Zod schema) so
|
|
8
|
+
* the ratchet cannot be bypassed by a malformed input:
|
|
9
|
+
*
|
|
10
|
+
* - AC-8: exactly one target (target_count === 1) AND
|
|
11
|
+
* exactly one optimization dimension per round.
|
|
12
|
+
* Code: EVOLUTION_MULTI_OBJECT, EVOLUTION_MULTI_DIMENSION.
|
|
13
|
+
* - AC-10: the scorer (evaluator) MUST NOT be the author.
|
|
14
|
+
* Code: EVOLUTION_SELF_SCORE.
|
|
15
|
+
* - AC-11: a `keep` verdict is BLOCKED when
|
|
16
|
+
* score_delta < score_delta_min (default 1.0).
|
|
17
|
+
* Code: EVOLUTION_DELTA_BELOW_THRESHOLD.
|
|
18
|
+
* - AC-12: evaluator_id MUST be a separate agent from
|
|
19
|
+
* author_id and skeptic_id.
|
|
20
|
+
* - AC-14: skeptic_id MUST be a separate agent from
|
|
21
|
+
* author_id and evaluator_id.
|
|
22
|
+
*
|
|
23
|
+
* The service is intentionally narrow: createProposal / score /
|
|
24
|
+
* markVerdict / list / get. The actual scoring agents
|
|
25
|
+
* (IndependentEvaluatorRunner, RegressionSkepticRunner) live in
|
|
26
|
+
* separate files so the author reasoning is NEVER imported into
|
|
27
|
+
* the evaluator's process boundary.
|
|
28
|
+
*/
|
|
29
|
+
export type EvolutionIntegrityErrorCode = "EVOLUTION_MULTI_OBJECT" | "EVOLUTION_MULTI_DIMENSION" | "EVOLUTION_SELF_SCORE" | "EVOLUTION_DELTA_BELOW_THRESHOLD" | "EVOLUTION_MISSING_USER_CONFIRMATION" | "EVOLUTION_BELOW_RUBRIC";
|
|
30
|
+
export declare class EvolutionIntegrityError extends Error {
|
|
31
|
+
readonly code: EvolutionIntegrityErrorCode;
|
|
32
|
+
readonly findings: ReadonlyArray<{
|
|
33
|
+
path: string;
|
|
34
|
+
message: string;
|
|
35
|
+
}>;
|
|
36
|
+
constructor(code: EvolutionIntegrityErrorCode, message: string, findings?: ReadonlyArray<{
|
|
37
|
+
path: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}>);
|
|
40
|
+
}
|
|
41
|
+
export declare class EvolutionService {
|
|
42
|
+
private readonly db;
|
|
43
|
+
constructor(db: Database.Database);
|
|
44
|
+
/**
|
|
45
|
+
* Persist a new EvolutionProposal. Enforces AC-8 (single object /
|
|
46
|
+
* single dimension) BEFORE writing the row.
|
|
47
|
+
*
|
|
48
|
+
* The proposal id is auto-generated (`eval-<hex>`); callers supply
|
|
49
|
+
* the proposal content only. The proposal's `verdict` is set to
|
|
50
|
+
* `needs-user-decision` until the score + skeptic step lands.
|
|
51
|
+
*
|
|
52
|
+
* Throws:
|
|
53
|
+
* - ZodError if the input fails schema validation.
|
|
54
|
+
* - EvolutionIntegrityError(EVOLUTION_MULTI_OBJECT) if
|
|
55
|
+
* single_object !== true.
|
|
56
|
+
* - EvolutionIntegrityError(EVOLUTION_MULTI_DIMENSION) if
|
|
57
|
+
* single_optimization_dimension !== true.
|
|
58
|
+
*/
|
|
59
|
+
createProposal(input: EvolutionProposalInput): EvolutionProposal;
|
|
60
|
+
/**
|
|
61
|
+
* Score an existing proposal. Enforces AC-10 (no self-score:
|
|
62
|
+
* `evaluator_id !== proposal.author_id`) and AC-11 (delta
|
|
63
|
+
* threshold: cannot mark `keep` when `score_delta <
|
|
64
|
+
* score_delta_min`).
|
|
65
|
+
*
|
|
66
|
+
* Returns the FINAL evaluation row with verdict derived from the
|
|
67
|
+
* evaluator + skeptic results:
|
|
68
|
+
* - `verdict = 'revert'` if the skeptic returned a blocker.
|
|
69
|
+
* - `verdict = 'revert'` if the score delta is below threshold.
|
|
70
|
+
* - `verdict = 'needs-user-decision'` otherwise; the CLI
|
|
71
|
+
* (`peaks evolution evaluate`) surfaces this to the user for
|
|
72
|
+
* a `keep` / `revert` choice.
|
|
73
|
+
*
|
|
74
|
+
* Throws:
|
|
75
|
+
* - EvolutionIntegrityError(EVOLUTION_SELF_SCORE) if
|
|
76
|
+
* evaluator_id === proposal.author_id.
|
|
77
|
+
* - EvolutionIntegrityError(EVOLUTION_DELTA_BELOW_THRESHOLD) if
|
|
78
|
+
* a `verdict = 'keep'` is requested and the delta is below
|
|
79
|
+
* the proposal's `score_delta_min`.
|
|
80
|
+
*/
|
|
81
|
+
score(proposalId: string, args: {
|
|
82
|
+
evaluator_id: string;
|
|
83
|
+
skeptic_id: string;
|
|
84
|
+
evaluator_result: import("./evolution-types.js").IndependentEvaluatorResult;
|
|
85
|
+
skeptic_result: import("./evolution-types.js").RegressionSkepticResult;
|
|
86
|
+
brief_pointer?: string;
|
|
87
|
+
user_confirmation_pointer?: string;
|
|
88
|
+
}): EvolutionEvaluation;
|
|
89
|
+
/**
|
|
90
|
+
* Mark a verdict explicitly (after the user has confirmed via
|
|
91
|
+
* natural-language or a pick). Enforces:
|
|
92
|
+
* - AC-11: cannot mark `keep` when score_delta <
|
|
93
|
+
* score_delta_min.
|
|
94
|
+
* - AC-15: `keep` requires a user_confirmation_pointer.
|
|
95
|
+
*
|
|
96
|
+
* Throws:
|
|
97
|
+
* - EvolutionIntegrityError(EVOLUTION_DELTA_BELOW_THRESHOLD)
|
|
98
|
+
* when `verdict = 'keep'` and the delta is below threshold.
|
|
99
|
+
* - EvolutionIntegrityError(EVOLUTION_MISSING_USER_CONFIRMATION)
|
|
100
|
+
* when `verdict = 'keep'` and no user_confirmation_pointer
|
|
101
|
+
* is supplied.
|
|
102
|
+
*/
|
|
103
|
+
markVerdict(proposalId: string, verdict: EvolutionVerdict, userConfirmationPointer?: string): EvolutionEvaluation | undefined;
|
|
104
|
+
/** Revert a proposal (universal recovery). Always allowed. */
|
|
105
|
+
revert(proposalId: string, userConfirmationPointer?: string): EvolutionEvaluation | undefined;
|
|
106
|
+
/** Read an evolution evaluation by id. */
|
|
107
|
+
read(proposalId: string): EvolutionEvaluation | undefined;
|
|
108
|
+
/** List evaluations for a given target. */
|
|
109
|
+
listByTarget(opts: {
|
|
110
|
+
target_kind: EvolutionTargetKind;
|
|
111
|
+
target_release_id: string;
|
|
112
|
+
verdict?: EvolutionVerdict;
|
|
113
|
+
}): EvolutionEvaluation[];
|
|
114
|
+
/**
|
|
115
|
+
* Status snapshot for `peaks evolution status`. Returns counts of
|
|
116
|
+
* evaluations by verdict for the given target, plus the latest
|
|
117
|
+
* evaluation id (if any).
|
|
118
|
+
*/
|
|
119
|
+
status(target: {
|
|
120
|
+
target_kind: EvolutionTargetKind;
|
|
121
|
+
target_release_id: string;
|
|
122
|
+
}): {
|
|
123
|
+
target_kind: EvolutionTargetKind;
|
|
124
|
+
target_release_id: string;
|
|
125
|
+
total: number;
|
|
126
|
+
byVerdict: Record<EvolutionVerdict, number>;
|
|
127
|
+
latest: EvolutionEvaluation | undefined;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Convenience: re-export the Zod error guard.
|
|
132
|
+
*/
|
|
133
|
+
export declare function isZodError(err: unknown): err is ZodError;
|
|
134
|
+
/**
|
|
135
|
+
* Convenience: get the default `score_delta_min` from the spec.
|
|
136
|
+
*/
|
|
137
|
+
export { EVOLUTION_DEFAULT_DELTA_MIN };
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { ZodError } from "zod";
|
|
2
|
+
import { EvolutionEvaluationSchema, EvolutionProposalInputSchema, EVOLUTION_DEFAULT_DELTA_MIN, } from "./evolution-types.js";
|
|
3
|
+
import { buildProposal, computeScoreDelta, ensureEvolutionEvaluationTable, getEvolutionEvaluation, insertEvolutionEvaluation, listEvolutionEvaluationsByTarget, newEvaluationId, updateEvolutionVerdict, } from "./evolution-store.js";
|
|
4
|
+
export class EvolutionIntegrityError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
findings;
|
|
7
|
+
constructor(code, message, findings = []) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "EvolutionIntegrityError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.findings = findings;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/* ---------------------------------------------------------------------- */
|
|
15
|
+
/* Service */
|
|
16
|
+
/* ---------------------------------------------------------------------- */
|
|
17
|
+
export class EvolutionService {
|
|
18
|
+
db;
|
|
19
|
+
constructor(db) {
|
|
20
|
+
this.db = db;
|
|
21
|
+
// Idempotent — safe to call on every constructor invocation; the
|
|
22
|
+
// openStateDb pipeline already applied the SQL migration, but a
|
|
23
|
+
// caller-built DB (e.g. tests) needs this.
|
|
24
|
+
ensureEvolutionEvaluationTable(this.db);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Persist a new EvolutionProposal. Enforces AC-8 (single object /
|
|
28
|
+
* single dimension) BEFORE writing the row.
|
|
29
|
+
*
|
|
30
|
+
* The proposal id is auto-generated (`eval-<hex>`); callers supply
|
|
31
|
+
* the proposal content only. The proposal's `verdict` is set to
|
|
32
|
+
* `needs-user-decision` until the score + skeptic step lands.
|
|
33
|
+
*
|
|
34
|
+
* Throws:
|
|
35
|
+
* - ZodError if the input fails schema validation.
|
|
36
|
+
* - EvolutionIntegrityError(EVOLUTION_MULTI_OBJECT) if
|
|
37
|
+
* single_object !== true.
|
|
38
|
+
* - EvolutionIntegrityError(EVOLUTION_MULTI_DIMENSION) if
|
|
39
|
+
* single_optimization_dimension !== true.
|
|
40
|
+
*/
|
|
41
|
+
createProposal(input) {
|
|
42
|
+
// Parse with the input schema first (so we get defaults applied).
|
|
43
|
+
// The input schema already enforces `single_object: literal(true)`
|
|
44
|
+
// and `single_optimization_dimension: literal(true)`; we map
|
|
45
|
+
// the ZodError to EvolutionIntegrityError so the CLI / tests
|
|
46
|
+
// can assert on a single error code.
|
|
47
|
+
let parsedInput;
|
|
48
|
+
try {
|
|
49
|
+
parsedInput =
|
|
50
|
+
EvolutionProposalInputSchema.parse(input);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof ZodError) {
|
|
54
|
+
const issue = err.issues[0];
|
|
55
|
+
if (issue?.path[0] === "single_object") {
|
|
56
|
+
throw new EvolutionIntegrityError("EVOLUTION_MULTI_OBJECT", "proposal must target exactly one object (single_object=true); split multi-object changes into multiple rounds", [
|
|
57
|
+
{
|
|
58
|
+
path: "single_object",
|
|
59
|
+
message: issue.message,
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
if (issue?.path[0] === "single_optimization_dimension") {
|
|
64
|
+
throw new EvolutionIntegrityError("EVOLUTION_MULTI_DIMENSION", "proposal must declare exactly one optimization dimension (single_optimization_dimension=true); split multi-dimension changes into multiple rounds", [
|
|
65
|
+
{
|
|
66
|
+
path: "single_optimization_dimension",
|
|
67
|
+
message: issue.message,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
// AC-8 defense in depth: in case a future schema version relaxes
|
|
75
|
+
// the literal(true), still enforce at the service boundary.
|
|
76
|
+
if (parsedInput.single_object !== true) {
|
|
77
|
+
throw new EvolutionIntegrityError("EVOLUTION_MULTI_OBJECT", "proposal must target exactly one object (single_object=true); split multi-object changes into multiple rounds", [{ path: "single_object", message: "must be true (AC-8)" }]);
|
|
78
|
+
}
|
|
79
|
+
if (parsedInput.single_optimization_dimension !== true) {
|
|
80
|
+
throw new EvolutionIntegrityError("EVOLUTION_MULTI_DIMENSION", "proposal must declare exactly one optimization dimension (single_optimization_dimension=true); split multi-dimension changes into multiple rounds", [
|
|
81
|
+
{
|
|
82
|
+
path: "single_optimization_dimension",
|
|
83
|
+
message: "must be true (AC-8)",
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
const id = newEvaluationId();
|
|
88
|
+
const proposal = buildProposal(id, parsedInput);
|
|
89
|
+
// Persist a stub evaluation row with verdict='needs-user-decision';
|
|
90
|
+
// the actual scoring happens in `score()` below.
|
|
91
|
+
const stub = {
|
|
92
|
+
id,
|
|
93
|
+
proposal,
|
|
94
|
+
evaluator_id: "pending",
|
|
95
|
+
skeptic_id: "pending",
|
|
96
|
+
evaluator_result: { score: parsedInput.before_score, riskTags: [], refuteParagraph: "pending" },
|
|
97
|
+
skeptic_result: {
|
|
98
|
+
driftRisks: [],
|
|
99
|
+
overfitRisks: [],
|
|
100
|
+
safetyRegressionRisks: [],
|
|
101
|
+
},
|
|
102
|
+
verdict: "needs-user-decision",
|
|
103
|
+
schema_version: "peaks.evolution/1",
|
|
104
|
+
created_at: proposal.created_at,
|
|
105
|
+
};
|
|
106
|
+
insertEvolutionEvaluation(this.db, stub);
|
|
107
|
+
return proposal;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Score an existing proposal. Enforces AC-10 (no self-score:
|
|
111
|
+
* `evaluator_id !== proposal.author_id`) and AC-11 (delta
|
|
112
|
+
* threshold: cannot mark `keep` when `score_delta <
|
|
113
|
+
* score_delta_min`).
|
|
114
|
+
*
|
|
115
|
+
* Returns the FINAL evaluation row with verdict derived from the
|
|
116
|
+
* evaluator + skeptic results:
|
|
117
|
+
* - `verdict = 'revert'` if the skeptic returned a blocker.
|
|
118
|
+
* - `verdict = 'revert'` if the score delta is below threshold.
|
|
119
|
+
* - `verdict = 'needs-user-decision'` otherwise; the CLI
|
|
120
|
+
* (`peaks evolution evaluate`) surfaces this to the user for
|
|
121
|
+
* a `keep` / `revert` choice.
|
|
122
|
+
*
|
|
123
|
+
* Throws:
|
|
124
|
+
* - EvolutionIntegrityError(EVOLUTION_SELF_SCORE) if
|
|
125
|
+
* evaluator_id === proposal.author_id.
|
|
126
|
+
* - EvolutionIntegrityError(EVOLUTION_DELTA_BELOW_THRESHOLD) if
|
|
127
|
+
* a `verdict = 'keep'` is requested and the delta is below
|
|
128
|
+
* the proposal's `score_delta_min`.
|
|
129
|
+
*/
|
|
130
|
+
score(proposalId, args) {
|
|
131
|
+
const existing = getEvolutionEvaluation(this.db, proposalId);
|
|
132
|
+
if (!existing) {
|
|
133
|
+
throw new EvolutionIntegrityError("EVOLUTION_BELOW_RUBRIC", `evolution_evaluation row '${proposalId}' not found`, [{ path: "id", message: "row not found" }]);
|
|
134
|
+
}
|
|
135
|
+
// AC-10: scorer MUST NOT be the author.
|
|
136
|
+
if (args.evaluator_id === existing.proposal.author_id) {
|
|
137
|
+
throw new EvolutionIntegrityError("EVOLUTION_SELF_SCORE", `evaluator_id '${args.evaluator_id}' equals author_id '${existing.proposal.author_id}'; self-scoring is forbidden (AC-10)`, [{ path: "evaluator_id", message: "must differ from author_id" }]);
|
|
138
|
+
}
|
|
139
|
+
// AC-12 / AC-14: skeptic and evaluator are SEPARATE agents.
|
|
140
|
+
if (args.skeptic_id === existing.proposal.author_id) {
|
|
141
|
+
throw new EvolutionIntegrityError("EVOLUTION_SELF_SCORE", `skeptic_id '${args.skeptic_id}' equals author_id '${existing.proposal.author_id}'; the skeptic must be a separate agent (AC-14)`, [{ path: "skeptic_id", message: "must differ from author_id" }]);
|
|
142
|
+
}
|
|
143
|
+
if (args.skeptic_id === args.evaluator_id) {
|
|
144
|
+
throw new EvolutionIntegrityError("EVOLUTION_SELF_SCORE", `skeptic_id '${args.skeptic_id}' equals evaluator_id '${args.evaluator_id}'; the skeptic must be a separate agent from the evaluator (AC-12/AC-14)`, [{ path: "skeptic_id", message: "must differ from evaluator_id" }]);
|
|
145
|
+
}
|
|
146
|
+
const after_score = args.evaluator_result.score;
|
|
147
|
+
const score_delta = computeScoreDelta(existing.proposal.before_score, after_score);
|
|
148
|
+
const min = existing.proposal.score_delta_min;
|
|
149
|
+
// Derive verdict. The skeptic's `blocker` is a hard revert.
|
|
150
|
+
let verdict = "needs-user-decision";
|
|
151
|
+
if (args.skeptic_result.blocker !== undefined) {
|
|
152
|
+
verdict = "revert";
|
|
153
|
+
}
|
|
154
|
+
else if (score_delta < min) {
|
|
155
|
+
verdict = "revert";
|
|
156
|
+
}
|
|
157
|
+
const next = {
|
|
158
|
+
id: existing.id,
|
|
159
|
+
proposal: {
|
|
160
|
+
...existing.proposal,
|
|
161
|
+
after_score,
|
|
162
|
+
score_delta,
|
|
163
|
+
},
|
|
164
|
+
evaluator_id: args.evaluator_id,
|
|
165
|
+
skeptic_id: args.skeptic_id,
|
|
166
|
+
evaluator_result: args.evaluator_result,
|
|
167
|
+
skeptic_result: args.skeptic_result,
|
|
168
|
+
verdict,
|
|
169
|
+
...(args.brief_pointer !== undefined ? { brief_pointer: args.brief_pointer } : {}),
|
|
170
|
+
...(args.user_confirmation_pointer !== undefined
|
|
171
|
+
? { user_confirmation_pointer: args.user_confirmation_pointer }
|
|
172
|
+
: {}),
|
|
173
|
+
schema_version: "peaks.evolution/1",
|
|
174
|
+
created_at: new Date().toISOString(),
|
|
175
|
+
};
|
|
176
|
+
// AC-11 (delta threshold) explicit guard: if the caller
|
|
177
|
+
// explicitly asks to mark `keep` but delta < min, throw.
|
|
178
|
+
if (verdict === "needs-user-decision" && score_delta < min) {
|
|
179
|
+
// The auto-derivation already pinned verdict to `revert`; the
|
|
180
|
+
// guard is for the explicit `markVerdict('keep')` path below.
|
|
181
|
+
}
|
|
182
|
+
// Re-validate before persisting.
|
|
183
|
+
const validated = EvolutionEvaluationSchema.parse({
|
|
184
|
+
...next,
|
|
185
|
+
score_delta,
|
|
186
|
+
});
|
|
187
|
+
// Delete + re-insert keeps the row simple; volume is tiny.
|
|
188
|
+
this.db
|
|
189
|
+
.prepare("DELETE FROM evolution_evaluation WHERE id = ?")
|
|
190
|
+
.run(existing.id);
|
|
191
|
+
insertEvolutionEvaluation(this.db, validated);
|
|
192
|
+
return validated;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Mark a verdict explicitly (after the user has confirmed via
|
|
196
|
+
* natural-language or a pick). Enforces:
|
|
197
|
+
* - AC-11: cannot mark `keep` when score_delta <
|
|
198
|
+
* score_delta_min.
|
|
199
|
+
* - AC-15: `keep` requires a user_confirmation_pointer.
|
|
200
|
+
*
|
|
201
|
+
* Throws:
|
|
202
|
+
* - EvolutionIntegrityError(EVOLUTION_DELTA_BELOW_THRESHOLD)
|
|
203
|
+
* when `verdict = 'keep'` and the delta is below threshold.
|
|
204
|
+
* - EvolutionIntegrityError(EVOLUTION_MISSING_USER_CONFIRMATION)
|
|
205
|
+
* when `verdict = 'keep'` and no user_confirmation_pointer
|
|
206
|
+
* is supplied.
|
|
207
|
+
*/
|
|
208
|
+
markVerdict(proposalId, verdict, userConfirmationPointer) {
|
|
209
|
+
const existing = getEvolutionEvaluation(this.db, proposalId);
|
|
210
|
+
if (!existing)
|
|
211
|
+
return undefined;
|
|
212
|
+
if (verdict === "keep") {
|
|
213
|
+
if (existing.proposal.score_delta < existing.proposal.score_delta_min) {
|
|
214
|
+
throw new EvolutionIntegrityError("EVOLUTION_DELTA_BELOW_THRESHOLD", `score_delta ${existing.proposal.score_delta} is below score_delta_min ${existing.proposal.score_delta_min}; cannot mark keep (AC-11)`, [
|
|
215
|
+
{
|
|
216
|
+
path: "verdict",
|
|
217
|
+
message: "score_delta below score_delta_min; revert instead or raise the score",
|
|
218
|
+
},
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
if (userConfirmationPointer === undefined ||
|
|
222
|
+
userConfirmationPointer.trim().length === 0) {
|
|
223
|
+
throw new EvolutionIntegrityError("EVOLUTION_MISSING_USER_CONFIRMATION", "marking verdict='keep' requires a user_confirmation_pointer (AC-15)", [
|
|
224
|
+
{
|
|
225
|
+
path: "user_confirmation_pointer",
|
|
226
|
+
message: "required for verdict='keep'",
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return updateEvolutionVerdict(this.db, proposalId, verdict, userConfirmationPointer);
|
|
232
|
+
}
|
|
233
|
+
/** Revert a proposal (universal recovery). Always allowed. */
|
|
234
|
+
revert(proposalId, userConfirmationPointer) {
|
|
235
|
+
return updateEvolutionVerdict(this.db, proposalId, "revert", userConfirmationPointer);
|
|
236
|
+
}
|
|
237
|
+
/** Read an evolution evaluation by id. */
|
|
238
|
+
read(proposalId) {
|
|
239
|
+
return getEvolutionEvaluation(this.db, proposalId);
|
|
240
|
+
}
|
|
241
|
+
/** List evaluations for a given target. */
|
|
242
|
+
listByTarget(opts) {
|
|
243
|
+
return listEvolutionEvaluationsByTarget(this.db, opts);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Status snapshot for `peaks evolution status`. Returns counts of
|
|
247
|
+
* evaluations by verdict for the given target, plus the latest
|
|
248
|
+
* evaluation id (if any).
|
|
249
|
+
*/
|
|
250
|
+
status(target) {
|
|
251
|
+
const all = listEvolutionEvaluationsByTarget(this.db, {
|
|
252
|
+
target_kind: target.target_kind,
|
|
253
|
+
target_release_id: target.target_release_id,
|
|
254
|
+
});
|
|
255
|
+
const byVerdict = {
|
|
256
|
+
keep: 0,
|
|
257
|
+
revert: 0,
|
|
258
|
+
"needs-user-decision": 0,
|
|
259
|
+
};
|
|
260
|
+
for (const row of all)
|
|
261
|
+
byVerdict[row.verdict] += 1;
|
|
262
|
+
const latest = all.length > 0 ? all[0] : undefined;
|
|
263
|
+
return {
|
|
264
|
+
target_kind: target.target_kind,
|
|
265
|
+
target_release_id: target.target_release_id,
|
|
266
|
+
total: all.length,
|
|
267
|
+
byVerdict,
|
|
268
|
+
latest,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Convenience: re-export the Zod error guard.
|
|
274
|
+
*/
|
|
275
|
+
export function isZodError(err) {
|
|
276
|
+
return err instanceof ZodError;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Convenience: get the default `score_delta_min` from the spec.
|
|
280
|
+
*/
|
|
281
|
+
export { EVOLUTION_DEFAULT_DELTA_MIN };
|