@lmzhen/dsh-evolution-plan-validator 0.8.0 → 0.10.0
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.md +13 -0
- package/lib/index.js +273 -88
- package/lib/types/index.d.ts +61 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,6 +4,19 @@ Deterministic validator for model-produced evolution plans: it exports the plan
|
|
|
4
4
|
(`EvolutionPlan`, `MemoryOp`, `SkillOp`) and the single `validateEvolutionPlan(plan,
|
|
5
5
|
context)` entry point.
|
|
6
6
|
|
|
7
|
+
The per-op questions are the named, ordered table `PLAN_RULES` (one table per op kind, same form
|
|
8
|
+
as the tool path's write gates): the first refusing rule names the reason, and every id is pinned
|
|
9
|
+
by the suite, so a rename or a reorder is a visible change. Container-level rejections (a
|
|
10
|
+
non-array `skillOps`, a malformed entry) stay outside the tables — they judge the container, not
|
|
11
|
+
an operation.
|
|
12
|
+
|
|
13
|
+
One row is **report-only**: `EVIDENCE_CLASS` records into `ValidationResult.reports` and never
|
|
14
|
+
refuses. It reads `ValidationContext.substantiveEvidenceSeqs` (core `evidenceKindIndex`) and
|
|
15
|
+
reports an op whose entire citation list is a turn/step boundary frame — the range rule cannot
|
|
16
|
+
tell such a frame from a real exchange. It is phase 1 of the evidence-consistency rule: an
|
|
17
|
+
observation window before anyone decides to refuse on it, and `undefined` (a log that cannot be
|
|
18
|
+
classified) stays silent rather than reporting every op.
|
|
19
|
+
|
|
7
20
|
## Model surface
|
|
8
21
|
|
|
9
22
|
- **Model-visible:** nothing of its own: no prompt, no tool schema and no service; consumers own the model-visible effects.
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,21 @@ import { DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_SKILL_CONT
|
|
|
3
3
|
/**
|
|
4
4
|
* Deterministic validator for model-produced evolution plans.
|
|
5
5
|
* The validator never calls the model and never mutates state.
|
|
6
|
+
*
|
|
7
|
+
* The per-op questions are a NAMED, ORDERED TABLE (design
|
|
8
|
+
* `dsh-evolution-skill-history-design.md` §3D), the same form as the tool path's
|
|
9
|
+
* `tool-skill-manage/src/write-gates.ts`: the rule BODIES read the shared core tables
|
|
10
|
+
* (`FORBIDDEN_CONTROL_KEYS`, `SKILL_ACTION_REQUIRED_FIELDS`, the restructure-target
|
|
11
|
+
* rule), and this module owns the ORDER and the wording. Order is part of the contract — the first
|
|
12
|
+
* refusing rule names the reason the model reads — and container-level rejections (a non-array
|
|
13
|
+
* `skillOps`, a malformed entry) stay outside the tables because they judge the container,
|
|
14
|
+
* not an operation.
|
|
15
|
+
*
|
|
16
|
+
* One row is REPORT-ONLY: `EVIDENCE_CLASS` records a finding in
|
|
17
|
+
* `ValidationResult.reports` and never refuses an op. It is phase 1 of the
|
|
18
|
+
* evidence-consistency rule — the range rule cannot tell a real exchange from a `turn/start`
|
|
19
|
+
* boundary, and refusing on that difference is a later decision this observation window has to
|
|
20
|
+
* earn (design §3D, §5.5).
|
|
6
21
|
* @module @lmzhen/dsh-evolution-plan-validator
|
|
7
22
|
*/
|
|
8
23
|
const MEMORY_ACTIONS = new Set([
|
|
@@ -21,20 +36,262 @@ const SKILL_ACTIONS = new Set([
|
|
|
21
36
|
"restructure"
|
|
22
37
|
]);
|
|
23
38
|
const FORBIDDEN_KEYS = FORBIDDEN_CONTROL_KEYS;
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
39
|
+
/** The seq ONE evidence item cites, or -1 when it cites none.
|
|
40
|
+
*
|
|
41
|
+
* P3 (v3 audit): only a REAL numeric seq passes — Number(null)/Number('')/Number(false) coerce
|
|
42
|
+
* to 0 and would mint fake evidence order. */
|
|
43
|
+
function citedEvidenceSeq(item) {
|
|
44
|
+
if (!item || typeof item !== "object") return -1;
|
|
45
|
+
const record = item;
|
|
46
|
+
return typeof record.event_seq === "number" ? record.event_seq : typeof record.seq === "number" ? record.seq : typeof record.event_seq === "string" && /^[0-9]+$/.test(record.event_seq) ? Number(record.event_seq) : -1;
|
|
47
|
+
}
|
|
48
|
+
/** v31 REV-08 (stated honestly): this gate is SEQUENCE-RANGE ONLY — every evidence item must
|
|
49
|
+
* carry an integer seq within [0, sessionSeq]. There is NO quoted-text/content verification: a
|
|
50
|
+
* model can satisfy it by citing any in-range event (even an unrelated one). Anti-fabrication
|
|
51
|
+
* strength lives in read-before-write, budgets, and the threat scan — not here. The CLASS of the
|
|
52
|
+
* cited frame is a separate, report-only question (`EVIDENCE_CLASS`). */
|
|
29
53
|
function hasValidEvidence(evidence, sessionSeq) {
|
|
30
54
|
if (!Array.isArray(evidence) || evidence.length === 0) return false;
|
|
31
55
|
return evidence.every((item) => {
|
|
32
|
-
|
|
33
|
-
const record = item;
|
|
34
|
-
const seq = typeof record.event_seq === "number" ? record.event_seq : typeof record.seq === "number" ? record.seq : typeof record.event_seq === "string" && /^\d+$/.test(record.event_seq) ? Number(record.event_seq) : -1;
|
|
56
|
+
const seq = citedEvidenceSeq(item);
|
|
35
57
|
return Number.isInteger(seq) && seq >= 0 && seq <= sessionSeq;
|
|
36
58
|
});
|
|
37
59
|
}
|
|
60
|
+
/** EVIDENCE_CLASS — phase 1 of the evidence-consistency question: does ANY cited frame carry
|
|
61
|
+
* content? An op whose whole citation list is a turn/step boundary is reported, never refused.
|
|
62
|
+
*
|
|
63
|
+
* `undefined` is not an empty set: a caller that could not classify the log has nothing to
|
|
64
|
+
* say, and reporting every op would be the false-positive flood this phase exists to avoid. */
|
|
65
|
+
function bookkeepingEvidenceReason(input) {
|
|
66
|
+
const substantive = input.context.substantiveEvidenceSeqs;
|
|
67
|
+
if (substantive === void 0) return null;
|
|
68
|
+
const evidence = input.op.evidence;
|
|
69
|
+
if (!Array.isArray(evidence) || evidence.length === 0) return null;
|
|
70
|
+
if (evidence.some((item) => substantive.has(citedEvidenceSeq(item)))) return null;
|
|
71
|
+
return `${input.kind} op ${input.index}: every cited evidence seq is a turn/step boundary frame — no content frame backs this op`;
|
|
72
|
+
}
|
|
73
|
+
/** P2-1 (v15): the string-typed fields the executors dereference with
|
|
74
|
+
* `.trim()`/`.length`. `target` is included for memory ops because the
|
|
75
|
+
* engine compares it against literals. */
|
|
76
|
+
function malformedStringField(op, fields) {
|
|
77
|
+
const record = op;
|
|
78
|
+
for (const field of fields) {
|
|
79
|
+
const value = record[field];
|
|
80
|
+
if (value !== void 0 && value !== null && typeof value !== "string") return field;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** The memory op's questions, in the order the model meets them. */
|
|
85
|
+
const MEMORY_PLAN_RULES = [
|
|
86
|
+
{
|
|
87
|
+
id: "FIELD_TYPE",
|
|
88
|
+
run: ({ op, index }) => {
|
|
89
|
+
const badField = malformedStringField(op, [
|
|
90
|
+
"facts",
|
|
91
|
+
"content",
|
|
92
|
+
"old_text",
|
|
93
|
+
"target"
|
|
94
|
+
]);
|
|
95
|
+
return badField === null ? null : `memory op ${index}: field ${badField} must be a string`;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "EVIDENCE_RANGE",
|
|
100
|
+
run: ({ op, index, context }) => hasValidEvidence(op.evidence, context.sessionSeq) ? null : `memory op ${index}: evidence is required and must reference a valid session seq`
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "CONTROL_KEYS",
|
|
104
|
+
run: ({ op, index }) => {
|
|
105
|
+
for (const key of FORBIDDEN_KEYS) if (key in op) return `memory op ${index}: forbidden field ${key}`;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "TARGET",
|
|
111
|
+
run: ({ op, index }) => op.target === "memory" || op.target === "user" ? null : `memory op ${index}: target must be memory or user`
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "ACTION",
|
|
115
|
+
run: ({ op, index }) => {
|
|
116
|
+
const action = op.action ?? "add";
|
|
117
|
+
return MEMORY_ACTIONS.has(action) ? null : `memory op ${index}: unknown action ${action}`;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "PAYLOAD",
|
|
122
|
+
run: ({ op, index }) => {
|
|
123
|
+
const action = op.action ?? "add";
|
|
124
|
+
const text = (op.facts ?? op.content ?? "").trim();
|
|
125
|
+
return action !== "remove" && text.length === 0 ? `memory op ${index}: ${action} requires facts/content` : null;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "ANCHOR",
|
|
130
|
+
run: ({ op, index }) => {
|
|
131
|
+
const action = op.action ?? "add";
|
|
132
|
+
return action !== "add" && !(op.old_text ?? "").trim() ? `memory op ${index}: ${action} requires old_text` : null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "BUDGET",
|
|
137
|
+
run: ({ op, index, context }) => {
|
|
138
|
+
const text = (op.facts ?? op.content ?? "").trim();
|
|
139
|
+
const budget = op.target === "user" ? context.maxUserChars ?? DEFAULT_USER_CHAR_LIMIT : context.maxMemoryChars ?? DEFAULT_MEMORY_CHAR_LIMIT;
|
|
140
|
+
return text.length > budget ? `memory op ${index}: content exceeds ${op.target === "user" ? "user" : "memory"} budget` : null;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "EVIDENCE_CLASS",
|
|
145
|
+
reportOnly: true,
|
|
146
|
+
run: bookkeepingEvidenceReason
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
/** The skill op's questions, in the order the model meets them. */
|
|
150
|
+
const SKILL_PLAN_RULES = [
|
|
151
|
+
{
|
|
152
|
+
id: "FIELD_TYPE",
|
|
153
|
+
run: ({ op, index }) => {
|
|
154
|
+
const badField = malformedStringField(op, [
|
|
155
|
+
"name",
|
|
156
|
+
"content",
|
|
157
|
+
"old_string",
|
|
158
|
+
"new_string",
|
|
159
|
+
"file_path",
|
|
160
|
+
"file_content",
|
|
161
|
+
"absorbed_into"
|
|
162
|
+
]);
|
|
163
|
+
return badField === null ? null : `skill op ${index}: field ${badField} must be a string`;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "REPLACE_ALL",
|
|
168
|
+
run: ({ op, index }) => op.replace_all === void 0 || typeof op.replace_all === "boolean" ? null : `skill op ${index}: field replace_all must be a boolean`
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: "CONTROL_KEYS",
|
|
172
|
+
run: ({ op, index }) => {
|
|
173
|
+
for (const key of FORBIDDEN_KEYS) if (key in op) return `skill op ${index}: forbidden field ${key}`;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "NAME",
|
|
179
|
+
run: ({ op, index }) => (op.name ?? "").trim() ? null : `skill op ${index}: name is required`
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: "PROTECTED",
|
|
183
|
+
run: ({ op, index, context }) => {
|
|
184
|
+
const name = (op.name ?? "").trim();
|
|
185
|
+
return context.protectedSkillNames?.has(name) ? `skill op ${index}: skill "${name}" is protected` : null;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "EVIDENCE_RANGE",
|
|
190
|
+
run: ({ op, index, context }) => hasValidEvidence(op.evidence, context.sessionSeq) ? null : `skill op ${index}: evidence is required and must reference a valid session seq`
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "ACTION",
|
|
194
|
+
run: ({ op, index }) => {
|
|
195
|
+
const action = op.action ?? "patch";
|
|
196
|
+
return SKILL_ACTIONS.has(action) ? null : `skill op ${index}: unknown action ${action}`;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "REQUIRED_FIELDS",
|
|
201
|
+
run: ({ op, index }) => {
|
|
202
|
+
const action = op.action ?? "patch";
|
|
203
|
+
const requiredFields = SKILL_ACTION_REQUIRED_FIELDS[action];
|
|
204
|
+
if (requiredFields === void 0) return null;
|
|
205
|
+
const record = op;
|
|
206
|
+
for (const field of requiredFields) if (record[field] === void 0 || record[field] === null) return `skill op ${index}: ${action} requires ${field}`;
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "WRITE_PAYLOAD",
|
|
212
|
+
run: ({ op, index }) => {
|
|
213
|
+
const action = op.action ?? "patch";
|
|
214
|
+
return (action === "create" || action === "edit" || action === "update") && !(op.content ?? "").trim() ? `skill op ${index}: ${action} requires content` : null;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "PATCH_ANCHOR",
|
|
219
|
+
run: ({ op, index }) => op.action === "patch" && !(op.old_string ?? "") ? `skill op ${index}: patch requires old_string` : null
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "DELETE_TARGET",
|
|
223
|
+
run: ({ op, index }) => op.action === "delete" && !(op.absorbed_into ?? "").trim() ? `skill op ${index}: delete requires absorbed_into` : null
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "SUPPORT_PAYLOAD",
|
|
227
|
+
run: ({ op, index }) => op.action === "write_file" && !(op.file_content ?? "").trim() ? `skill op ${index}: write_file requires file_content` : null
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "BUDGET",
|
|
231
|
+
run: ({ op, index, context }) => {
|
|
232
|
+
return [
|
|
233
|
+
op.file_content ?? "",
|
|
234
|
+
op.content ?? "",
|
|
235
|
+
op.new_string ?? ""
|
|
236
|
+
].reduce((max, value) => Math.max(max, value.length), 0) > (context.maxSkillContentChars ?? DEFAULT_SKILL_CONTENT_CHARS) ? `skill op ${index}: content exceeds skill budget` : null;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "RESTRUCTURE",
|
|
241
|
+
run: ({ op, index }) => {
|
|
242
|
+
if (op.action !== "restructure") return null;
|
|
243
|
+
if (!Array.isArray(op.restructure) || op.restructure.length === 0) return `skill op ${index}: restructure requires a non-empty restructure list`;
|
|
244
|
+
if (op.restructure.length > MAX_RESTRUCTURE_MOVES) return `skill op ${index}: restructure exceeds ${MAX_RESTRUCTURE_MOVES} moves`;
|
|
245
|
+
for (const [moveIndex, move] of op.restructure.entries()) {
|
|
246
|
+
if (!move || typeof move.heading !== "string" || !move.heading.trim()) return `skill op ${index}: restructure[${moveIndex}] requires a non-empty heading`;
|
|
247
|
+
if (typeof move.to_file !== "string") return `skill op ${index}: restructure[${moveIndex}] to_file must be references/<topic>.md`;
|
|
248
|
+
const targetIssue = validateRestructureTarget(move.to_file);
|
|
249
|
+
if (targetIssue) return `skill op ${index}: restructure[${moveIndex}] ${targetIssue}`;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "EVIDENCE_CLASS",
|
|
256
|
+
reportOnly: true,
|
|
257
|
+
run: bookkeepingEvidenceReason
|
|
258
|
+
}
|
|
259
|
+
];
|
|
260
|
+
/**
|
|
261
|
+
* The plan path's rule tables, by op kind — the named set whose ORDER this module owns.
|
|
262
|
+
*
|
|
263
|
+
* Exported so a rename or a reorder is a visible change (the suite pins the ids), and so the tool
|
|
264
|
+
* path's gate table and this one can be compared side by side without reading either
|
|
265
|
+
* implementation.
|
|
266
|
+
*/
|
|
267
|
+
const PLAN_RULES = {
|
|
268
|
+
memory: MEMORY_PLAN_RULES,
|
|
269
|
+
skill: SKILL_PLAN_RULES
|
|
270
|
+
};
|
|
271
|
+
/** Run one op's table in order: the first refusing rule names the reason, a report-only row
|
|
272
|
+
* records its verdict and lets the op continue. */
|
|
273
|
+
function runPlanRules(rules, op, context, index, kind, reports) {
|
|
274
|
+
for (const rule of rules) {
|
|
275
|
+
const reason = rule.run({
|
|
276
|
+
op,
|
|
277
|
+
kind,
|
|
278
|
+
index,
|
|
279
|
+
context
|
|
280
|
+
});
|
|
281
|
+
if (reason === null) continue;
|
|
282
|
+
if (rule.reportOnly === true) {
|
|
283
|
+
reports.push({
|
|
284
|
+
rule: rule.id,
|
|
285
|
+
kind,
|
|
286
|
+
index,
|
|
287
|
+
reason
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
return reason;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
38
295
|
/** Object root guard (V4-23, symmetric with the maintain validator): null,
|
|
39
296
|
* primitives and arrays are not a plan record and must be rejected with an
|
|
40
297
|
* explicit message, never a raw TypeError from `.memoryOps`/`.summary`. */
|
|
@@ -42,6 +299,7 @@ function isRecord(value) {
|
|
|
42
299
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
300
|
}
|
|
44
301
|
function validateEvolutionPlan(plan, context) {
|
|
302
|
+
const reports = [];
|
|
45
303
|
if (!isRecord(plan)) return {
|
|
46
304
|
accepted: {
|
|
47
305
|
memoryOps: [],
|
|
@@ -52,6 +310,7 @@ function validateEvolutionPlan(plan, context) {
|
|
|
52
310
|
kind: "memory",
|
|
53
311
|
reason: "plan root: must be an object"
|
|
54
312
|
}],
|
|
313
|
+
reports,
|
|
55
314
|
ok: false
|
|
56
315
|
};
|
|
57
316
|
const maxOps = context.maxOpsPerPlan ?? DEFAULT_MAX_OPS_PER_PLAN;
|
|
@@ -85,6 +344,7 @@ function validateEvolutionPlan(plan, context) {
|
|
|
85
344
|
return {
|
|
86
345
|
accepted,
|
|
87
346
|
rejected,
|
|
347
|
+
reports,
|
|
88
348
|
ok: false
|
|
89
349
|
};
|
|
90
350
|
}
|
|
@@ -97,6 +357,7 @@ function validateEvolutionPlan(plan, context) {
|
|
|
97
357
|
return {
|
|
98
358
|
accepted,
|
|
99
359
|
rejected,
|
|
360
|
+
reports,
|
|
100
361
|
ok: false
|
|
101
362
|
};
|
|
102
363
|
}
|
|
@@ -110,7 +371,7 @@ function validateEvolutionPlan(plan, context) {
|
|
|
110
371
|
continue;
|
|
111
372
|
}
|
|
112
373
|
const op = rawOp;
|
|
113
|
-
const reason =
|
|
374
|
+
const reason = runPlanRules(MEMORY_PLAN_RULES, op, context, index, "memory", reports);
|
|
114
375
|
if (reason) rejected.push({
|
|
115
376
|
index,
|
|
116
377
|
kind: "memory",
|
|
@@ -131,7 +392,7 @@ function validateEvolutionPlan(plan, context) {
|
|
|
131
392
|
continue;
|
|
132
393
|
}
|
|
133
394
|
const op = rawOp;
|
|
134
|
-
const reason =
|
|
395
|
+
const reason = runPlanRules(SKILL_PLAN_RULES, op, context, index, "skill", reports);
|
|
135
396
|
if (reason) rejected.push({
|
|
136
397
|
index,
|
|
137
398
|
kind: "skill",
|
|
@@ -145,85 +406,9 @@ function validateEvolutionPlan(plan, context) {
|
|
|
145
406
|
return {
|
|
146
407
|
accepted,
|
|
147
408
|
rejected,
|
|
409
|
+
reports,
|
|
148
410
|
ok: rejected.length === 0 && memoryOps.length + skillOps.length > 0
|
|
149
411
|
};
|
|
150
412
|
}
|
|
151
|
-
function validateMemoryOp(op, context, index) {
|
|
152
|
-
const badField = malformedStringField(op, [
|
|
153
|
-
"facts",
|
|
154
|
-
"content",
|
|
155
|
-
"old_text",
|
|
156
|
-
"target"
|
|
157
|
-
]);
|
|
158
|
-
if (badField) return `memory op ${index}: field ${badField} must be a string`;
|
|
159
|
-
if (!hasValidEvidence(op.evidence, context.sessionSeq)) return `memory op ${index}: evidence is required and must reference a valid session seq`;
|
|
160
|
-
for (const key of FORBIDDEN_KEYS) if (key in op) return `memory op ${index}: forbidden field ${key}`;
|
|
161
|
-
if (op.target !== "memory" && op.target !== "user") return `memory op ${index}: target must be memory or user`;
|
|
162
|
-
const action = op.action ?? "add";
|
|
163
|
-
if (!MEMORY_ACTIONS.has(action)) return `memory op ${index}: unknown action ${action}`;
|
|
164
|
-
const text = (op.facts ?? op.content ?? "").trim();
|
|
165
|
-
if (action !== "remove" && text.length === 0) return `memory op ${index}: ${action} requires facts/content`;
|
|
166
|
-
if (action !== "add" && !(op.old_text ?? "").trim()) return `memory op ${index}: ${action} requires old_text`;
|
|
167
|
-
const budget = op.target === "user" ? context.maxUserChars ?? DEFAULT_USER_CHAR_LIMIT : context.maxMemoryChars ?? DEFAULT_MEMORY_CHAR_LIMIT;
|
|
168
|
-
if (text.length > budget) return `memory op ${index}: content exceeds ${op.target === "user" ? "user" : "memory"} budget`;
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
/** P2-1 (v15): the string-typed fields the executors dereference with
|
|
172
|
-
* `.trim()`/`.length`. `target` is included for memory ops because the
|
|
173
|
-
* engine compares it against literals. */
|
|
174
|
-
function malformedStringField(op, fields) {
|
|
175
|
-
const record = op;
|
|
176
|
-
for (const field of fields) {
|
|
177
|
-
const value = record[field];
|
|
178
|
-
if (value !== void 0 && value !== null && typeof value !== "string") return field;
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
function validateSkillOp(op, context, index) {
|
|
183
|
-
const badField = malformedStringField(op, [
|
|
184
|
-
"name",
|
|
185
|
-
"content",
|
|
186
|
-
"old_string",
|
|
187
|
-
"new_string",
|
|
188
|
-
"file_path",
|
|
189
|
-
"file_content",
|
|
190
|
-
"absorbed_into"
|
|
191
|
-
]);
|
|
192
|
-
if (badField) return `skill op ${index}: field ${badField} must be a string`;
|
|
193
|
-
if (op.replace_all !== void 0 && typeof op.replace_all !== "boolean") return `skill op ${index}: field replace_all must be a boolean`;
|
|
194
|
-
for (const key of FORBIDDEN_KEYS) if (key in op) return `skill op ${index}: forbidden field ${key}`;
|
|
195
|
-
const name = (op.name ?? "").trim();
|
|
196
|
-
if (!name) return `skill op ${index}: name is required`;
|
|
197
|
-
if (context.protectedSkillNames?.has(name)) return `skill op ${index}: skill "${name}" is protected`;
|
|
198
|
-
if (!hasValidEvidence(op.evidence, context.sessionSeq)) return `skill op ${index}: evidence is required and must reference a valid session seq`;
|
|
199
|
-
const action = op.action ?? "patch";
|
|
200
|
-
if (!SKILL_ACTIONS.has(action)) return `skill op ${index}: unknown action ${action}`;
|
|
201
|
-
const requiredFields = SKILL_ACTION_REQUIRED_FIELDS[action];
|
|
202
|
-
if (requiredFields !== void 0) {
|
|
203
|
-
const record = op;
|
|
204
|
-
for (const field of requiredFields) if (record[field] === void 0 || record[field] === null) return `skill op ${index}: ${action} requires ${field}`;
|
|
205
|
-
}
|
|
206
|
-
if ((action === "create" || action === "edit" || action === "update") && !(op.content ?? "").trim()) return `skill op ${index}: ${action} requires content`;
|
|
207
|
-
if (action === "patch" && !(op.old_string ?? "")) return `skill op ${index}: patch requires old_string`;
|
|
208
|
-
if (action === "delete" && !(op.absorbed_into ?? "").trim()) return `skill op ${index}: delete requires absorbed_into`;
|
|
209
|
-
const writeBytes = [
|
|
210
|
-
op.file_content ?? "",
|
|
211
|
-
op.content ?? "",
|
|
212
|
-
op.new_string ?? ""
|
|
213
|
-
].reduce((max, value) => Math.max(max, value.length), 0);
|
|
214
|
-
if (action === "write_file" && !(op.file_content ?? "").trim()) return `skill op ${index}: write_file requires file_content`;
|
|
215
|
-
if (writeBytes > (context.maxSkillContentChars ?? DEFAULT_SKILL_CONTENT_CHARS)) return `skill op ${index}: content exceeds skill budget`;
|
|
216
|
-
if (action === "restructure") {
|
|
217
|
-
if (!Array.isArray(op.restructure) || op.restructure.length === 0) return `skill op ${index}: restructure requires a non-empty restructure list`;
|
|
218
|
-
if (op.restructure.length > MAX_RESTRUCTURE_MOVES) return `skill op ${index}: restructure exceeds ${MAX_RESTRUCTURE_MOVES} moves`;
|
|
219
|
-
for (const [moveIndex, move] of op.restructure.entries()) {
|
|
220
|
-
if (!move || typeof move.heading !== "string" || !move.heading.trim()) return `skill op ${index}: restructure[${moveIndex}] requires a non-empty heading`;
|
|
221
|
-
if (typeof move.to_file !== "string") return `skill op ${index}: restructure[${moveIndex}] to_file must be references/<topic>.md`;
|
|
222
|
-
const targetIssue = validateRestructureTarget(move.to_file);
|
|
223
|
-
if (targetIssue) return `skill op ${index}: restructure[${moveIndex}] ${targetIssue}`;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
413
|
//#endregion
|
|
229
|
-
export { validateEvolutionPlan };
|
|
414
|
+
export { PLAN_RULES, validateEvolutionPlan };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic validator for model-produced evolution plans.
|
|
3
3
|
* The validator never calls the model and never mutates state.
|
|
4
|
+
*
|
|
5
|
+
* The per-op questions are a NAMED, ORDERED TABLE (design
|
|
6
|
+
* `dsh-evolution-skill-history-design.md` §3D), the same form as the tool path's
|
|
7
|
+
* `tool-skill-manage/src/write-gates.ts`: the rule BODIES read the shared core tables
|
|
8
|
+
* (`FORBIDDEN_CONTROL_KEYS`, `SKILL_ACTION_REQUIRED_FIELDS`, the restructure-target
|
|
9
|
+
* rule), and this module owns the ORDER and the wording. Order is part of the contract — the first
|
|
10
|
+
* refusing rule names the reason the model reads — and container-level rejections (a non-array
|
|
11
|
+
* `skillOps`, a malformed entry) stay outside the tables because they judge the container,
|
|
12
|
+
* not an operation.
|
|
13
|
+
*
|
|
14
|
+
* One row is REPORT-ONLY: `EVIDENCE_CLASS` records a finding in
|
|
15
|
+
* `ValidationResult.reports` and never refuses an op. It is phase 1 of the
|
|
16
|
+
* evidence-consistency rule — the range rule cannot tell a real exchange from a `turn/start`
|
|
17
|
+
* boundary, and refusing on that difference is a later decision this observation window has to
|
|
18
|
+
* earn (design §3D, §5.5).
|
|
4
19
|
* @module @lmzhen/dsh-evolution-plan-validator
|
|
5
20
|
*/
|
|
6
21
|
export interface MemoryOp {
|
|
@@ -42,6 +57,12 @@ export interface EvolutionPlan {
|
|
|
42
57
|
export interface ValidationContext {
|
|
43
58
|
/** Upper bound for the latest valid session seq. */
|
|
44
59
|
sessionSeq: number;
|
|
60
|
+
/** The seqs of the frames that carry CONTENT (core `evidenceKindIndex`), or
|
|
61
|
+
* `undefined` when the caller cannot classify the log (a session whose events carry no
|
|
62
|
+
* seq). Read by the report-only `EVIDENCE_CLASS` row only: an op whose entire citation
|
|
63
|
+
* list is a bookkeeping frame (`turn/start`, `step/start`, …) becomes visible
|
|
64
|
+
* without being refused. */
|
|
65
|
+
substantiveEvidenceSeqs?: ReadonlySet<number> | undefined;
|
|
45
66
|
maxOpsPerPlan?: number;
|
|
46
67
|
protectedSkillNames?: ReadonlySet<string>;
|
|
47
68
|
maxMemoryChars?: number;
|
|
@@ -53,10 +74,50 @@ export interface RejectedOp {
|
|
|
53
74
|
kind: 'memory' | 'skill';
|
|
54
75
|
reason: string;
|
|
55
76
|
}
|
|
77
|
+
/** The op kinds a plan carries, and the two keys of {@link PLAN_RULES}. */
|
|
78
|
+
export type PlanOpKind = 'memory' | 'skill';
|
|
79
|
+
/** One report-only finding: a rule that let the op proceed and still had something to say.
|
|
80
|
+
* `ValidationResult.ok` ignores this list — a report is never a refusal. */
|
|
81
|
+
export interface PlanRuleReport {
|
|
82
|
+
/** The reporting rule's id from {@link PLAN_RULES}. */
|
|
83
|
+
rule: string;
|
|
84
|
+
kind: PlanOpKind;
|
|
85
|
+
index: number;
|
|
86
|
+
reason: string;
|
|
87
|
+
}
|
|
56
88
|
export interface ValidationResult {
|
|
57
89
|
accepted: EvolutionPlan;
|
|
58
90
|
rejected: RejectedOp[];
|
|
91
|
+
/** Findings from the report-only rows; empty when every op was either clean or refused. */
|
|
92
|
+
reports: PlanRuleReport[];
|
|
59
93
|
ok: boolean;
|
|
60
94
|
}
|
|
95
|
+
/** What one rule reads: the op, which table it came from, and the caller's context. */
|
|
96
|
+
export interface PlanRuleInput<Op> {
|
|
97
|
+
readonly op: Op;
|
|
98
|
+
readonly kind: PlanOpKind;
|
|
99
|
+
readonly index: number;
|
|
100
|
+
readonly context: ValidationContext;
|
|
101
|
+
}
|
|
102
|
+
/** One named question the plan path asks about ONE op. */
|
|
103
|
+
export interface PlanRule<Op> {
|
|
104
|
+
/** Stable id: a report cites it, and the table's order is the refusal order. */
|
|
105
|
+
readonly id: string;
|
|
106
|
+
/** Records the verdict in `ValidationResult.reports` instead of refusing the op. */
|
|
107
|
+
readonly reportOnly?: boolean;
|
|
108
|
+
/** @returns the refusal reason, or null to let the op proceed. */
|
|
109
|
+
run(input: PlanRuleInput<Op>): string | null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The plan path's rule tables, by op kind — the named set whose ORDER this module owns.
|
|
113
|
+
*
|
|
114
|
+
* Exported so a rename or a reorder is a visible change (the suite pins the ids), and so the tool
|
|
115
|
+
* path's gate table and this one can be compared side by side without reading either
|
|
116
|
+
* implementation.
|
|
117
|
+
*/
|
|
118
|
+
export declare const PLAN_RULES: {
|
|
119
|
+
readonly memory: readonly PlanRule<MemoryOp>[];
|
|
120
|
+
readonly skill: readonly PlanRule<SkillOp>[];
|
|
121
|
+
};
|
|
61
122
|
export declare function validateEvolutionPlan(plan: EvolutionPlan, context: ValidationContext): ValidationResult;
|
|
62
123
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmzhen/dsh-evolution-plan-validator",
|
|
3
3
|
"description": "Deterministic validator for model-produced evolution plans (community build)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
],
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@lmzhen/dsh-evolution-core": "^0.
|
|
29
|
+
"@lmzhen/dsh-evolution-core": "^0.10.0"
|
|
30
30
|
}
|
|
31
31
|
}
|