dsh-model-everything 0.1.0 → 0.3.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.
Files changed (62) hide show
  1. package/README.en-US.md +102 -81
  2. package/README.md +88 -76
  3. package/lib/automaton-tool.js +69 -0
  4. package/lib/automaton.js +327 -0
  5. package/lib/compose-tool.js +11 -2
  6. package/lib/concurrency-tool.js +6 -1
  7. package/lib/data-engine.js +1 -1
  8. package/lib/data-tool.js +18 -5
  9. package/lib/design-tool.js +21 -8
  10. package/lib/edit-tool.js +111 -0
  11. package/lib/editor.js +293 -0
  12. package/lib/engine.js +8 -1
  13. package/lib/export-tool.js +6 -1
  14. package/lib/index.js +4 -0
  15. package/lib/model-file.js +48 -0
  16. package/lib/presentation.js +81 -0
  17. package/lib/report-diff-tool.js +6 -1
  18. package/lib/report-view.js +92 -15
  19. package/lib/routing.js +85 -0
  20. package/lib/scan.js +547 -0
  21. package/lib/structure-tool.js +34 -1
  22. package/lib/structure.js +28 -3
  23. package/lib/tool.js +19 -4
  24. package/lib/types/automaton-tool.d.ts +2 -0
  25. package/lib/types/automaton.d.ts +57 -0
  26. package/lib/types/edit-tool.d.ts +2 -0
  27. package/lib/types/editor.d.ts +46 -0
  28. package/lib/types/engine.d.ts +1 -0
  29. package/lib/types/model-file.d.ts +7 -0
  30. package/lib/types/presentation.d.ts +59 -0
  31. package/lib/types/report-view.d.ts +8 -1
  32. package/lib/types/routing.d.ts +31 -0
  33. package/lib/types/scan.d.ts +37 -0
  34. package/lib/types/structure.d.ts +16 -0
  35. package/lib/uml-tool.js +16 -6
  36. package/package.json +8 -3
  37. package/skills/model-everything/SKILL.md +51 -79
  38. package/skills/model-everything/references/automata.md +102 -0
  39. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -60
  40. package/skills/model-everything-report-reading/SKILL.md +137 -137
  41. package/src/automaton-tool.ts +73 -0
  42. package/src/automaton.ts +375 -0
  43. package/src/compose-tool.ts +13 -3
  44. package/src/concurrency-tool.ts +7 -1
  45. package/src/data-engine.ts +1 -1
  46. package/src/data-tool.ts +19 -5
  47. package/src/design-tool.ts +74 -60
  48. package/src/edit-tool.ts +119 -0
  49. package/src/editor.ts +297 -0
  50. package/src/engine.ts +8 -1
  51. package/src/export-tool.ts +87 -81
  52. package/src/index.ts +4 -0
  53. package/src/model-file.ts +49 -0
  54. package/src/presentation.ts +125 -0
  55. package/src/report-diff-tool.ts +7 -1
  56. package/src/report-view.ts +534 -451
  57. package/src/routing.ts +96 -0
  58. package/src/scan.ts +564 -0
  59. package/src/structure-tool.ts +33 -1
  60. package/src/structure.ts +37 -3
  61. package/src/tool.ts +19 -4
  62. package/src/uml-tool.ts +240 -230
package/lib/editor.js ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Applying edits to a model file: the delta side of the verification loop.
3
+ *
4
+ * A model is the largest thing any of these tools takes. Handing it back whole on every
5
+ * iteration is what makes a four-round fix cost four models. Here an edit names one change,
6
+ * the file carries the rest, and the caller gets the structural diff plus - when asked - the
7
+ * difference in what the engine decided, in the same call.
8
+ *
9
+ * Every operation works on a copy and the result is validated before anything is written. A
10
+ * refused edit leaves the file exactly as it was: an invalid model on disk would make the next
11
+ * verification spend a round reporting that, which is the cost this tool exists to remove.
12
+ */
13
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+ import { validateModel } from './engine.js';
16
+ import { OptionShapeError } from './option-shape.js';
17
+ const CLONE = (value) => JSON.parse(JSON.stringify(value));
18
+ /** Read the model at `path`; a missing file is a refusal, not an empty model. */
19
+ export function readModelFile(path) {
20
+ if (typeof path !== 'string' || path.trim() === '') {
21
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must be a non-empty path to a JSON file.');
22
+ }
23
+ const full = resolve(path);
24
+ let text;
25
+ try {
26
+ text = readFileSync(full, 'utf8');
27
+ }
28
+ catch (error) {
29
+ const reason = error instanceof Error ? error.message : String(error);
30
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath could not be read: ${reason}. Edits apply to a file that already exists; model_verify can create the model inline first, or write it yourself.`);
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(text.replace(/^\uFEFF/, ''));
35
+ }
36
+ catch (error) {
37
+ const reason = error instanceof Error ? error.message : String(error);
38
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath is not valid JSON: ${reason}.`);
39
+ }
40
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
41
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must hold a model object, not a list or a scalar.');
42
+ }
43
+ return { model: parsed, full };
44
+ }
45
+ function statesOf(model) {
46
+ return Array.isArray(model.states) ? model.states : [];
47
+ }
48
+ function transitionsOf(model) {
49
+ return Array.isArray(model.transitions) ? model.transitions : [];
50
+ }
51
+ function requireString(value, name) {
52
+ if (typeof value !== 'string' || value === '')
53
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} must be a non-empty string.`);
54
+ return value;
55
+ }
56
+ /** JSON pointer, so anything without a named operation still has a way to be edited. */
57
+ function pointerParts(path) {
58
+ const text = requireString(path, 'path');
59
+ if (!text.startsWith('/'))
60
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path must be a JSON pointer starting with "/", got ${JSON.stringify(text)}.`);
61
+ return text.split('/').slice(1).map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
62
+ }
63
+ function atPointer(model, parts, create) {
64
+ let node = model;
65
+ for (const part of parts.slice(0, -1)) {
66
+ const next = node[part];
67
+ if (next === undefined) {
68
+ if (!create)
69
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path does not exist at "${part}".`);
70
+ const created = {};
71
+ node[part] = created;
72
+ node = created;
73
+ }
74
+ else if (next !== null && typeof next === 'object') {
75
+ node = next;
76
+ }
77
+ else {
78
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path runs through "${part}", which is not an object.`);
79
+ }
80
+ }
81
+ return { parent: node, key: parts[parts.length - 1] ?? '' };
82
+ }
83
+ /**
84
+ * Accept the compact spelling of an operation as well as the long one.
85
+ *
86
+ * An operation is a small thing - add a state, wire a transition - and the long form spends more
87
+ * tokens on key names than on the change. Five operations cost 76 tokens spelled out and 39 in the
88
+ * compact form, and a batch of twenty is where that difference starts to matter.
89
+ *
90
+ * {addState: "c"} {addState: {id: "c", terminal: true}}
91
+ * {removeState: "c"}
92
+ * {addTransition: ["a", "reset", "c"]} {addTransition: {from, event, to, guard?, ...}}
93
+ * {removeTransition: ["a", "go"]} {updateTransition: ["a", "go", {guard: ...}]}
94
+ * {set: ["/init", "a"]} {remove: "/init"}
95
+ *
96
+ * The long form is unchanged and is what the errors talk about.
97
+ */
98
+ export function normalizeOperation(raw) {
99
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
100
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'every op must be an object.');
101
+ }
102
+ const value = raw;
103
+ if (typeof value.op === 'string')
104
+ return value;
105
+ const keys = Object.keys(value);
106
+ if (keys.length !== 1) {
107
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `an operation names exactly one op, got ${JSON.stringify(keys)}.`);
108
+ }
109
+ const name = keys[0];
110
+ const argument = value[name];
111
+ if (name === 'addState' || name === 'removeState') {
112
+ if (typeof argument === 'string') {
113
+ return name === 'addState' ? { op: name, state: { id: argument } } : { op: name, id: argument };
114
+ }
115
+ if (name === 'addState' && argument !== null && typeof argument === 'object')
116
+ return { op: name, state: argument };
117
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a state id, or a state object for addState.`);
118
+ }
119
+ if (name === 'addTransition' || name === 'removeTransition' || name === 'updateTransition') {
120
+ if (Array.isArray(argument)) {
121
+ if (name === 'addTransition' && argument.length === 3) {
122
+ const [from, event, to] = argument;
123
+ return { op: name, transition: { from, event, to } };
124
+ }
125
+ if (name === 'removeTransition' && argument.length === 2) {
126
+ const [from, event] = argument;
127
+ return { op: name, from, event };
128
+ }
129
+ if (name === 'updateTransition' && argument.length === 3) {
130
+ const [from, event, patch] = argument;
131
+ return { op: name, from, event, patch };
132
+ }
133
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes ${name === 'updateTransition' ? '["from", "event", {patch}]' : name === 'addTransition' ? '["from", "event", "to"]' : '["from", "event"]'}.`);
134
+ }
135
+ if (name === 'addTransition' && argument !== null && typeof argument === 'object')
136
+ return { op: name, transition: argument };
137
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a list, or a transition object for addTransition.`);
138
+ }
139
+ if (name === 'set') {
140
+ if (Array.isArray(argument) && argument.length === 2)
141
+ return { op: 'set', path: argument[0], value: argument[1] };
142
+ if (argument !== null && typeof argument === 'object' && 'path' in argument) {
143
+ const { path, value } = argument;
144
+ return { op: 'set', path, value };
145
+ }
146
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'set takes ["/path", value] or {path, value}.');
147
+ }
148
+ if (name === 'remove') {
149
+ if (typeof argument === 'string')
150
+ return { op: 'remove', path: argument };
151
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'remove takes a JSON pointer string.');
152
+ }
153
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(name)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`);
154
+ }
155
+ /** Apply one operation to a model copy, returning the changes it made. */
156
+ export function applyOperation(model, operation) {
157
+ const op = requireString(operation.op, 'op');
158
+ const changes = [];
159
+ if (op === 'addState') {
160
+ const state = operation.state;
161
+ const id = requireString(state?.id, 'state.id');
162
+ if (statesOf(model).some((entry) => entry.id === id)) {
163
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `state "${id}" already exists; use update on it instead of adding it twice.`);
164
+ }
165
+ model.states = [...statesOf(model), state];
166
+ changes.push({ kind: 'added', what: 'state ' + id, detail: state?.terminal === true ? 'terminal' : '' });
167
+ return changes;
168
+ }
169
+ if (op === 'removeState') {
170
+ const id = requireString(operation.id, 'id');
171
+ const before = statesOf(model);
172
+ if (!before.some((entry) => entry.id === id))
173
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `no state "${id}" to remove.`);
174
+ model.states = before.filter((entry) => entry.id !== id);
175
+ const dropped = transitionsOf(model).filter((entry) => entry.from === id || entry.to === id);
176
+ model.transitions = transitionsOf(model).filter((entry) => entry.from !== id && entry.to !== id);
177
+ changes.push({ kind: 'removed', what: 'state ' + id, detail: dropped.length > 0 ? `and ${String(dropped.length)} transition(s) that used it` : '' });
178
+ return changes;
179
+ }
180
+ if (op === 'addTransition') {
181
+ const transition = operation.transition;
182
+ const from = requireString(transition?.from, 'transition.from');
183
+ const event = requireString(transition?.event, 'transition.event');
184
+ const to = requireString(transition?.to, 'transition.to');
185
+ if (transitionsOf(model).some((entry) => entry.from === from && entry.event === event)) {
186
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `a transition for ${from} + ${event} already exists; update it instead of adding another.`);
187
+ }
188
+ model.transitions = [...transitionsOf(model), transition];
189
+ changes.push({ kind: 'added', what: `transition ${from} + ${event}`, detail: 'to ' + to });
190
+ return changes;
191
+ }
192
+ if (op === 'removeTransition') {
193
+ const from = requireString(operation.from, 'from');
194
+ const event = requireString(operation.event, 'event');
195
+ const before = transitionsOf(model);
196
+ const kept = before.filter((entry) => !(entry.from === from && entry.event === event));
197
+ if (kept.length === before.length)
198
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to remove.`);
199
+ model.transitions = kept;
200
+ changes.push({ kind: 'removed', what: `transition ${from} + ${event}`, detail: '' });
201
+ return changes;
202
+ }
203
+ if (op === 'updateTransition') {
204
+ const from = requireString(operation.from, 'from');
205
+ const event = requireString(operation.event, 'event');
206
+ const patch = operation.patch;
207
+ if (patch === undefined || typeof patch !== 'object' || Array.isArray(patch)) {
208
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'patch must be an object of the fields to replace.');
209
+ }
210
+ const list = transitionsOf(model);
211
+ const index = list.findIndex((entry) => entry.from === from && entry.event === event);
212
+ if (index < 0)
213
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to update.`);
214
+ list[index] = { ...list[index], ...patch };
215
+ model.transitions = list;
216
+ changes.push({ kind: 'updated', what: `transition ${from} + ${event}`, detail: Object.keys(patch).join(', ') });
217
+ return changes;
218
+ }
219
+ if (op === 'set') {
220
+ const parts = pointerParts(operation.path);
221
+ if (parts.length === 0)
222
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.');
223
+ const { parent, key } = atPointer(model, parts, true);
224
+ parent[key] = CLONE(operation.value);
225
+ changes.push({ kind: 'updated', what: 'set ' + String(operation.path), detail: '' });
226
+ return changes;
227
+ }
228
+ if (op === 'remove') {
229
+ const parts = pointerParts(operation.path);
230
+ if (parts.length === 0)
231
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.');
232
+ const { parent, key } = atPointer(model, parts, false);
233
+ if (!(key in parent))
234
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `nothing at ${String(operation.path)} to remove.`);
235
+ delete parent[key];
236
+ changes.push({ kind: 'removed', what: 'remove ' + String(operation.path), detail: '' });
237
+ return changes;
238
+ }
239
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(op)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`);
240
+ }
241
+ export function applyEdits(path, operations, options = {}) {
242
+ if (!Array.isArray(operations) || operations.length === 0) {
243
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'ops must be a non-empty array of operations.');
244
+ }
245
+ const { model: original, full } = readModelFile(path);
246
+ const next = CLONE(original);
247
+ const changes = [];
248
+ const normalized = operations.map((raw, index) => {
249
+ try {
250
+ return normalizeOperation(raw);
251
+ }
252
+ catch (error) {
253
+ const reason = error instanceof Error ? error.message : String(error);
254
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} was refused: ${reason}`);
255
+ }
256
+ });
257
+ for (const [index, raw] of normalized.entries()) {
258
+ try {
259
+ changes.push(...applyOperation(next, raw));
260
+ }
261
+ catch (error) {
262
+ const reason = error instanceof Error ? error.message : String(error);
263
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} (${String(raw.op)}) was refused: ${reason}`);
264
+ }
265
+ }
266
+ const validation = validateModel(next);
267
+ if (!validation.ok && options.allowInvalid !== true) {
268
+ // Name the operation the batch first stops validating at, not just the symptom: a caller that
269
+ // reads "a transition to an unknown state" still has to find which entry introduced it.
270
+ let firstBad = -1;
271
+ const partial = CLONE(original);
272
+ for (const [index, raw] of operations.entries()) {
273
+ try {
274
+ applyOperation(partial, raw);
275
+ }
276
+ catch {
277
+ firstBad = index;
278
+ break;
279
+ }
280
+ if (!validateModel(partial).ok) {
281
+ firstBad = index;
282
+ break;
283
+ }
284
+ }
285
+ const where = firstBad < 0 ? '' : ` Operation ${String(firstBad + 1)} (${String(operations[firstBad]?.op)}) is where it first stops validating.`;
286
+ throw new OptionShapeError('EDIT_RESULT_INVALID', 'the batch would leave an invalid model, so nothing was written: ' + validation.errors.join('; ') + '.' + where + ' Fix the list, or pass allowInvalid to write it anyway and continue in a second call.');
287
+ }
288
+ const text = JSON.stringify(validation.ok ? validation.model : next, null, 2) + '\n';
289
+ const temporary = full + '.tmp';
290
+ writeFileSync(temporary, text, 'utf8');
291
+ renameSync(temporary, full);
292
+ return { model: next, changes, full, valid: validation.ok, errors: validation.ok ? [] : validation.errors };
293
+ }
package/lib/engine.js CHANGED
@@ -52,7 +52,7 @@ function verificationNextSteps(checks, coverage) {
52
52
  // named check looked at the model. A list of failures does not say that, and neither does `pass`.
53
53
  const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.name + ' (' + check.id + ')');
54
54
  if (skipped.length > 0) {
55
- steps.push('These examined nothing, so their silence is not a result: ' + skipped.join(', ') + '. Declare what each one would need, or accept that the model is unverified on those points.');
55
+ steps.push('Not checked: ' + skipped.join(', ') + ' examined nothing — silence there is not a pass. Declare what each needs, or treat the model as unverified on those points.');
56
56
  }
57
57
  // Named checks above, bare codes here. That is deliberate: each warning is a different kind of thing,
58
58
  // so there is no single name to give the set, and the sentence points at where each one IS explained
@@ -62,6 +62,12 @@ function verificationNextSteps(checks, coverage) {
62
62
  if (coverage !== undefined && !narrativeComplete(coverage)) {
63
63
  steps.push('Complete the narrative (states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + ') — a partial narrative is valid, but a reader still has to re-derive the missing symbols.');
64
64
  }
65
+ // A truncated search is the one outcome where "look harder" is the wrong advice: the wall is
66
+ // combinatorial, and raising maxStates buys a constant factor. Name the ladder instead, so the reader
67
+ // knows this is not a bug to fix but a model to reduce, or a different tool to use.
68
+ if (hasUndecidedFinding(checks)) {
69
+ steps.push('The search stopped before deciding, so the undecided checks are neither proven nor refuted. Do not just raise maxStates: first reduce the model (drop variables the property does not read, collapse symmetric states, order independent events), and if it still explodes, move to a tool that does not enumerate every state - SPIN for partial-order reduction, NuSMV/nuXMV for symbolic search, CBMC for bounded SAT. This plugin establishes no abstraction and reads no program source, so a property that needs one is outside it.');
70
+ }
65
71
  // The range is dropped: the code that would be reported back is already named in `findings`, and a
66
72
  // reader who has not seen a report yet learns nothing from four digits.
67
73
  steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress: behaviour preserved, invariants continuous, the state mapping consistent. Then compare the two reports with `report_diff` rather than reading them side by side.');
@@ -181,6 +187,7 @@ export const REPORT_SCHEMAS = {
181
187
  granularity: 'model-everything/granularity/v1',
182
188
  design: 'model-everything/design/v1',
183
189
  baseline: 'model-everything/baseline/v1',
190
+ edit: 'model-everything/edit/v1',
184
191
  };
185
192
  export function narrativeCoverageOf(model) {
186
193
  const narrative = model.narrative;
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js';
3
+ import { reportCard, reportResultView } from './presentation.js';
3
4
  import { ExportError, exportModel } from './exporters.js';
4
5
  import { renderReportText } from './report-view.js';
5
6
  /** The model hash of an export's input, when the input validates (the success path always does). */
@@ -44,7 +45,11 @@ export const modelExportTool = defineTool({
44
45
  render(args, value) {
45
46
  return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
46
47
  },
47
- },
48
+ // The card a UI shows, separated from the text the model reads.
49
+ presentationMeta(_args, value) {
50
+ return reportCard(value, EXPORT_TOOL_NAME);
51
+ },
52
+ }, presentResult: reportResultView(EXPORT_TOOL_NAME),
48
53
  timeoutMs: 10_000,
49
54
  isConcurrencySafe: () => true,
50
55
  async execute(args) {
package/lib/index.js CHANGED
@@ -33,6 +33,8 @@ import { umlRenderTool, umlParseTool, umlReviewTool, umlExplainLabelsTool } from
33
33
  import { umlStructureTool } from './structure-tool.js';
34
34
  import { designTool } from './design-tool.js';
35
35
  import { reportDiffTool } from './report-diff-tool.js';
36
+ import { modelEditTool } from './edit-tool.js';
37
+ import { modelAutomatonTool } from './automaton-tool.js';
36
38
  import { ENGINE_SCHEMA_VERSION } from './engine.js';
37
39
  // DSH 0.1.7-alpha.1 (session format v4) retires the shared
38
40
  // `{ kind: 'plugin', plugin }` wrapper: native admission rejects it in every
@@ -192,6 +194,8 @@ export function apply(ctx, config) {
192
194
  ctx.effect(() => tools.register(umlStructureTool), 'dsh-model-everything: structure tool');
193
195
  ctx.effect(() => tools.register(designTool), 'dsh-model-everything: design review tool');
194
196
  ctx.effect(() => tools.register(reportDiffTool), 'dsh-model-everything: report diff tool');
197
+ ctx.effect(() => tools.register(modelEditTool), 'dsh-model-everything: model edit tool');
198
+ ctx.effect(() => tools.register(modelAutomatonTool), 'dsh-model-everything: automaton tool');
195
199
  toolRegistered = true;
196
200
  dataToolRegistered = true;
197
201
  concurrencyToolRegistered = true;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Read a JSON model from a file instead of requiring it inline.
3
+ *
4
+ * A model is the largest argument any of these tools takes, and it is the one that arrives as
5
+ * streamed tool-call JSON: the provider rejected one session with `tool input is invalid JSON`
6
+ * after the model spent sixteen steps hand-editing a nested model argument. Reading it from the
7
+ * workspace removes that failure mode and the tokens.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+ import { OptionShapeError } from './option-shape.js';
12
+ /** Read and parse one JSON file. Formatting is irrelevant: whitespace, BOM and pretty printing all parse. */
13
+ export function readJsonFile(path, option, code) {
14
+ if (typeof path !== 'string' || path.trim() === '') {
15
+ throw new OptionShapeError(code, `${option} must be a non-empty path to a JSON file.`);
16
+ }
17
+ const full = resolve(path);
18
+ let text;
19
+ try {
20
+ text = readFileSync(full, 'utf8');
21
+ }
22
+ catch (error) {
23
+ const reason = error instanceof Error ? error.message : String(error);
24
+ throw new OptionShapeError(code, `${option} could not be read: ${reason}. The path resolves against the process working directory; relative paths in a session resolve against the session's workspace.`);
25
+ }
26
+ try {
27
+ return JSON.parse(text.replace(/^\uFEFF/, ''));
28
+ }
29
+ catch (error) {
30
+ const reason = error instanceof Error ? error.message : String(error);
31
+ throw new OptionShapeError(code, `${option} is not valid JSON: ${reason}.`);
32
+ }
33
+ }
34
+ /**
35
+ * Exactly one of the inline value and the file path. Both is ambiguous, neither is a missing
36
+ * argument, and each gets its own sentence so the caller knows which one to drop.
37
+ */
38
+ export function oneOf(value, path, inlineName, pathName, code) {
39
+ const hasValue = value !== undefined;
40
+ const hasPath = path !== undefined;
41
+ if (hasValue && hasPath) {
42
+ throw new OptionShapeError(code, `Pass either ${inlineName} or ${pathName}, not both: the file would be read and then ignored, or the other way round.`);
43
+ }
44
+ if (!hasValue && !hasPath) {
45
+ throw new OptionShapeError(code, `Pass ${inlineName} or ${pathName}: the model has to come from somewhere.`);
46
+ }
47
+ return hasPath ? readJsonFile(path, pathName, code) : value;
48
+ }
@@ -0,0 +1,81 @@
1
+ import { routeFor } from './routing.js';
2
+ const SHORT = 160;
3
+ function shorten(text, limit = SHORT) {
4
+ const flat = text.replace(/\s+/g, ' ').trim();
5
+ return flat.length <= limit ? flat : flat.slice(0, limit - 1).trimEnd() + '…';
6
+ }
7
+ function record(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
9
+ }
10
+ function array(value) {
11
+ return Array.isArray(value) ? value : [];
12
+ }
13
+ /**
14
+ * Project a report into the card payload.
15
+ *
16
+ * Read defensively: this runs on replay too, and a payload that once had a shape may come back with a
17
+ * field missing. A card that throws would take the whole session view with it.
18
+ */
19
+ export function reportCard(value, family) {
20
+ const report = record(value) ?? {};
21
+ const findings = array(report.findings)
22
+ .map((entry) => record(entry))
23
+ .filter((entry) => entry !== undefined);
24
+ const severity = (entry) => String(entry.severity ?? 'info');
25
+ const errors = findings.filter((entry) => severity(entry) === 'error');
26
+ const warnings = findings.filter((entry) => severity(entry) === 'warning');
27
+ const summary = record(report.summary);
28
+ const checksRun = typeof summary?.checksRun === 'number'
29
+ ? summary.checksRun
30
+ : array(report.checks).length > 0
31
+ ? array(report.checks).length
32
+ : undefined;
33
+ const first = errors[0] ?? warnings[0];
34
+ const next = array(report.nextSteps).map((step) => String(step))[0];
35
+ return {
36
+ ...(typeof report.verdict === 'string' ? { verdict: report.verdict } : {}),
37
+ ...(typeof report.verdictReason === 'string' ? { verdictReason: shorten(report.verdictReason) } : {}),
38
+ errors: errors.length,
39
+ warnings: warnings.length,
40
+ ...(checksRun === undefined ? {} : { checksRun }),
41
+ first: first === undefined
42
+ ? null
43
+ : { code: String(first.code ?? 'UNKNOWN'), severity: severity(first), message: shorten(String(first.message ?? '')) },
44
+ next: next === undefined ? null : shorten(next),
45
+ family,
46
+ ...(routeFor(family) === undefined
47
+ ? {}
48
+ : { route: { owns: routeFor(family)?.owns ?? '', instead: routeFor(family)?.notFor.tool ?? '' } }),
49
+ };
50
+ }
51
+ /** The card body: four lines, in the order a reader asks the questions. */
52
+ export function cardBlocks(card) {
53
+ const lines = [];
54
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
55
+ const counts = card.checksRun === undefined
56
+ ? `${String(card.errors)} error(s), ${String(card.warnings)} warning(s)`
57
+ : `${String(card.checksRun)} checks · ${String(card.errors)} error(s) · ${String(card.warnings)} warning(s)`;
58
+ lines.push(head + ' — ' + counts);
59
+ if (card.verdictReason !== undefined)
60
+ lines.push(card.verdictReason);
61
+ if (card.first !== null) {
62
+ lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message);
63
+ }
64
+ if (card.next !== null)
65
+ lines.push('next: ' + card.next);
66
+ return [{ type: 'text', text: lines.join('\n') }];
67
+ }
68
+ /**
69
+ * `presentResult` for a report tool: render the card from the persisted payload, and fall back to the
70
+ * raw content by returning undefined when there is none.
71
+ */
72
+ export function reportResultView(family) {
73
+ return (_args, result) => {
74
+ const card = record(result.meta);
75
+ if (card === undefined || card === null)
76
+ return undefined;
77
+ if (card.verdict === undefined && card.next === null && card.first === null)
78
+ return undefined;
79
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: cardBlocks(card) };
80
+ };
81
+ }
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { diffReports } from './baseline.js';
3
3
  import { refusalVerdict } from './engine.js';
4
+ import { reportCard, reportResultView } from './presentation.js';
4
5
  import { renderReportText } from './report-view.js';
5
6
  export const REPORT_DIFF_TOOL_NAME = 'report_diff';
6
7
  /**
@@ -49,7 +50,11 @@ export const reportDiffTool = defineTool({
49
50
  render(args, value) {
50
51
  return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
51
52
  },
52
- },
53
+ // The card a UI shows, separated from the text the model reads.
54
+ presentationMeta(_args, value) {
55
+ return reportCard(value, REPORT_DIFF_TOOL_NAME);
56
+ },
57
+ }, presentResult: reportResultView(REPORT_DIFF_TOOL_NAME),
53
58
  timeoutMs: 10_000,
54
59
  isConcurrencySafe: () => true,
55
60
  async execute(args) {