@lmzhen/dsh-evolution-plan-validator 0.9.0 → 0.11.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/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
- /** v31 REV-08 (stated honestly): this gate is SEQUENCE-RANGE ONLY — every
25
- * evidence item must carry an integer seq within [0, sessionSeq]. There is NO
26
- * quoted-text/content verification: a model can satisfy it by citing any
27
- * in-range event (even an unrelated one). Anti-fabrication strength lives in
28
- * read-before-write, budgets, and the threat scan — not here. */
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
- if (!item || typeof item !== "object") return false;
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 = validateMemoryOp(op, context, index);
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 = validateSkillOp(op, context, index);
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 };
@@ -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.9.0",
4
+ "version": "0.11.1",
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.9.0"
29
+ "@lmzhen/dsh-evolution-core": "^0.11.1"
30
30
  }
31
31
  }