dsh-logicprobe 1.1.0 → 1.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 (39) hide show
  1. package/README.en-US.md +17 -6
  2. package/README.md +17 -6
  3. package/lib/compose-tool.js +8 -2
  4. package/lib/concurrency-tool.js +8 -2
  5. package/lib/data-tool.js +8 -2
  6. package/lib/engine.js +3151 -3151
  7. package/lib/export-tool.js +8 -2
  8. package/lib/index.js +28 -40
  9. package/lib/report-diff-tool.js +8 -2
  10. package/lib/report-view.js +295 -0
  11. package/lib/structure-tool.js +8 -2
  12. package/lib/tool.js +8 -2
  13. package/lib/types/engine.d.ts +419 -419
  14. package/lib/types/index.d.ts +5 -4
  15. package/lib/types/report-view.d.ts +43 -0
  16. package/lib/types/uml.d.ts +293 -256
  17. package/lib/uml-tool.js +21 -14
  18. package/lib/uml.js +76 -12
  19. package/package.json +2 -2
  20. package/skills/logicprobe/SKILL.md +42 -6
  21. package/skills/logicprobe/references/dsh-model-schema.md +1 -1
  22. package/skills/logicprobe/references/gap-routing-guide.md +10 -9
  23. package/skills/logicprobe/references/logic-verification-guide.md +1 -1
  24. package/skills/logicprobe-concurrency/SKILL.md +2 -0
  25. package/skills/logicprobe-datamodel/SKILL.md +5 -0
  26. package/skills/logicprobe-structure/SKILL.md +1 -0
  27. package/skills/logicprobe-uml/SKILL.md +3 -0
  28. package/skills/logicprobe-uml/references/uml-modeling-guide.md +70 -2
  29. package/src/compose-tool.ts +8 -2
  30. package/src/concurrency-tool.ts +8 -2
  31. package/src/data-tool.ts +9 -3
  32. package/src/export-tool.ts +8 -2
  33. package/src/index.ts +28 -40
  34. package/src/report-diff-tool.ts +8 -2
  35. package/src/report-view.ts +343 -0
  36. package/src/structure-tool.ts +8 -2
  37. package/src/tool.ts +8 -2
  38. package/src/uml-tool.ts +21 -14
  39. package/src/uml.ts +94 -11
package/src/data-tool.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { runDataVerification, DATA_ENGINE_SCHEMA_VERSION } from './data-engine.js'
4
+ import { renderReportText } from './report-view.js'
4
5
 
5
6
  export const LOGICPROBE_DATAMODEL_VERIFY_TOOL_NAME = 'logicprobe_datamodel_verify'
6
7
 
@@ -40,14 +41,19 @@ export const logicProbeDataModelVerifyTool = defineTool({
40
41
  type: 'json',
41
42
  description: 'Optional backup/restore pairs for DA7 rollback symmetry.',
42
43
  },
44
+ reportView: {
45
+ type: 'string',
46
+ enum: ['digest', 'full'],
47
+ description: 'digest (default) prints the readable body; full prints the structured report, which logicprobe_report_diff needs.',
48
+ },
43
49
  },
44
50
  output: {
45
51
  schema: {
46
52
  type: 'json',
47
53
  description: 'logicprobe-datamodel verification report with summary and per-check findings.',
48
54
  },
49
- render(_args, value) {
50
- return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
55
+ render(args, value) {
56
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
51
57
  },
52
58
  },
53
59
  timeoutMs: 10_000,
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
4
4
  import { exportModel, type ExportFormat } from './exporters.js'
5
+ import { renderReportText } from './report-view.js'
5
6
 
6
7
  /** The model hash of an export's input, when the input validates (the success path always does). */
7
8
  function exportHashes(model: unknown): { hashSpec: typeof DEFAULT_HASH_SPEC; modelHash?: string } {
@@ -34,14 +35,19 @@ export const logicProbeExportTool = defineTool({
34
35
  enum: ['uppaal', 'tla', 'prism', 'spin'],
35
36
  description: 'Target tool format.',
36
37
  },
38
+ reportView: {
39
+ type: 'string',
40
+ enum: ['digest', 'full'],
41
+ description: 'digest (default) prints the generated file verbatim; full prints the structured result as JSON.',
42
+ },
37
43
  },
38
44
  output: {
39
45
  schema: {
40
46
  type: 'json',
41
47
  description: 'Export result with generated file content.',
42
48
  },
43
- render(_args, value) {
44
- return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
49
+ render(args, value) {
50
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
45
51
  },
46
52
  },
47
53
  timeoutMs: 10_000,
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * logicprobe — DeepSeek Harness native plugin for the Logic Probe toolbox.
3
- * Injects the session-start gate text (what this plugin checks, the 1% Rule,
4
- * the Red Flags, the routing to the skills, the tool list, how to read a report)
3
+ * Injects the session-start gate text (what this plugin checks, the routing to
4
+ * the skills, the tool list, how to read a report)
5
5
  * into the first model step of every agent session, mirroring the SessionStart
6
6
  * hook the Claude Code plugin installs.
7
7
  * The skill ships in this package's `skills/` directory and is registered at
@@ -18,8 +18,9 @@
18
18
  * to the first step after their promotion, and the history guard re-injects it
19
19
  * there. The default gate text is the dsh-native adaptation of
20
20
  * `hooks/session-start-content.md`. Both are a guide to calling the tools: what
21
- * this plugin checks, the 1% Rule and the Red Flags that raise the chance of a
22
- * call, the routing to the five skills, the tool list, and how to read a report.
21
+ * this plugin checks, the routing to the sibling skills, the tool list, and how to
22
+ * read a report. The Red Flags table is enforcement scaffolding and lives in the
23
+ * skill body instead, where a session that needs it can load it.
23
24
  * They stay in sync. Method, doctrine and per-domain detail live in the skills
24
25
  * and their references, not here. Deployments override via Config.
25
26
  *
@@ -75,41 +76,28 @@ const GATE_SOURCE_KIND: 'plugin:logicprobe' = 'plugin:logicprobe'
75
76
 
76
77
  export type InteractionMode = 'ask' | 'auto' | 'follow-approval'
77
78
 
78
- const DEFAULT_GATE_CONTENT = `
79
- <EXTREMELY_IMPORTANT>
80
- Plugin logicprobe is active. It checks claims about designs and code: facts against the source, behaviour as an executable model.
81
-
82
- **1% Rule**: if any claim about this code deserves a check, load the matching skill first. Loading is cheap. A false claim is not.
83
-
84
- **Unsure whether a check applies?** Load \`logicprobe\` first. It is the entry point: it owns the doctrine, the model schema, the depth rules and the routing to its siblings.
85
-
86
- **Load the skill that matches the claim**: \`logicprobe\` (entry point; claim and behaviour checks), \`logicprobe-uml\` (diagrams), \`logicprobe-structure\` (architecture, dependencies), \`logicprobe-concurrency\` (concurrency claims), \`logicprobe-datamodel\` (data models, migrations).
87
-
88
- **Tools** (dsh):
89
-
90
- - \`logicprobe_verify\` — state machines: structural checks, adversarial probes, budgets, deadlines, before/after regression.
91
- - \`logicprobe_datamodel_verify\` — data models and migrations: structure, migration coverage, copy consistency, rollback, before/after data.
92
- - \`logicprobe_compose_verify\` — two or more machines: deadlock, and a rendezvous that never fires.
93
- - \`logicprobe_concurrency_scan\` — finds concurrency claims and routes them. It proves nothing about concurrency.
94
- - \`logicprobe_export\` — UPPAAL, TLA+, PRISM and SPIN input from a verified model.
95
- - \`logicprobe_uml\` — render, parse or review a UML diagram.
96
- - \`logicprobe_structure_verify\` — dependency review of a structure diagram, at one level or several.
97
- - \`logicprobe_report_diff\` — baseline against current: what a change added, removed or changed.
98
-
99
- **Read a report**: gate on \`verdict\` (\`pass\`, \`pass_with_findings\`, \`fail\`), never on \`ok\`. Reports carry \`verdictReason\`, \`hashes\` and \`nextSteps\`. Keys starting with \`_\` are notes: ignored, and left out of the model hash.
100
-
101
- **Red Flags** — if you think any of these, stop:
102
-
103
- | You think | Reality |
104
- |-----------|---------|
105
- | "Too simple to check" | The check is cheap. The silent skip is not. |
106
- | "I read the code, so I know" | Knowing is not evidence. Cite \`file:line\`, or run the model, or call the claim unverified. |
107
- | "The spec or the diagram says so" | Both are claims about the code. Reconcile them with a source-side scan. |
108
- | "I'll check while implementing" | Check before the decision, not after. |
109
- | "Reasoning is enough" | Reasoning is not a run. One counter-example kills a universal claim. |
110
- | "The report passed, so the code is fine" | The report covers the model you gave it. Reconcile it, and say what you did not check. |
111
-
112
- </EXTREMELY_IMPORTANT>
79
+ const DEFAULT_GATE_CONTENT = `
80
+ <EXTREMELY_IMPORTANT>
81
+ Plugin logicprobe is active. It checks claims about designs and code: facts against the source, behaviour as an executable model.
82
+
83
+ **Unsure whether a check applies?** Load \`logicprobe\` first. It is the entry point: it owns the doctrine, the model schema, the depth rules and the routing to its siblings.
84
+
85
+ **Load the skill that matches the claim**: \`logicprobe\` (entry point; claim and behaviour checks), \`logicprobe-uml\` (diagrams), \`logicprobe-structure\` (architecture, dependencies), \`logicprobe-concurrency\` (concurrency claims), \`logicprobe-datamodel\` (data models, migrations).
86
+
87
+ **Tools** (dsh):
88
+
89
+ - \`logicprobe_verify\` — state machines: structural checks, adversarial probes, budgets, deadlines, before/after regression.
90
+ - \`logicprobe_datamodel_verify\` — data models and migrations: structure, migration coverage, copy consistency, rollback, before/after data.
91
+ - \`logicprobe_compose_verify\` — two or more machines: deadlock, and a rendezvous that never fires.
92
+ - \`logicprobe_concurrency_scan\` — finds concurrency claims and routes them. It proves nothing about concurrency.
93
+ - \`logicprobe_export\` — UPPAAL, TLA+, PRISM and SPIN input from a verified model.
94
+ - \`logicprobe_uml\` — render, parse or review a UML diagram.
95
+ - \`logicprobe_structure_verify\` — dependency review of a structure diagram, at one level or several.
96
+ - \`logicprobe_report_diff\` — baseline against current: what a change added, removed or changed.
97
+
98
+ **Read a report**: gate on \`verdict\` (\`pass\`, \`pass_with_findings\`, \`fail\`), never on \`ok\`. Reports carry \`verdictReason\`, \`hashes\` and \`nextSteps\`. Keys starting with \`_\` are notes: ignored, and left out of the model hash. A tool prints the report body: passing checks stay in the inventory line, repeated facts are grouped, and warning evidence is omitted — pass \`reportView: "full"\` only when you need the structured report itself, which \`logicprobe_report_diff\` consumes.
99
+
100
+ </EXTREMELY_IMPORTANT>
113
101
  `
114
102
 
115
103
  /** A schemastery field that may or may not carry `.volatile()`. */
@@ -261,7 +249,7 @@ function inspectProvider(config: Config, isToolRegistered: () => boolean, isData
261
249
  return {
262
250
  manifest: {
263
251
  id: 'logicprobe',
264
- description: 'Session-start gate injection and native verification tooling for the Logic Probe toolbox. The gate is a guide to calling the tools: what the plugin checks, the 1% Rule and Red Flags, the routing to the five skills, the tool list, and how to read a report. Doctrine and per-domain detail live in the skills.',
252
+ description: 'Session-start gate injection and native verification tooling for the Logic Probe toolbox. The gate is a guide to calling the tools: what the plugin checks, the routing to the sibling skills, the tool list, and how to read a report. Doctrine and per-domain detail live in the skills.',
265
253
  methods: [
266
254
  {
267
255
  name: 'status',
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { diffReports } from './baseline.js'
4
+ import { renderReportText } from './report-view.js'
4
5
 
5
6
  export const LOGICPROBE_REPORT_DIFF_TOOL_NAME = 'logicprobe_report_diff'
6
7
 
@@ -25,14 +26,19 @@ export const logicProbeReportDiffTool = defineTool({
25
26
  required: true,
26
27
  description: 'The current report to compare against the baseline.',
27
28
  },
29
+ reportView: {
30
+ type: 'string',
31
+ enum: ['digest', 'full'],
32
+ description: 'digest (default) prints the readable body; full prints the structured diff report.',
33
+ },
28
34
  },
29
35
  output: {
30
36
  schema: {
31
37
  type: 'json',
32
38
  description: 'Baseline diff report: added/removed/changed findings, delta verdict, current verdict, summary and next steps.',
33
39
  },
34
- render(_args, value) {
35
- return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
40
+ render(args, value) {
41
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
36
42
  },
37
43
  },
38
44
  timeoutMs: 10_000,
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The report body: the text a model reads for a logicprobe result.
3
+ *
4
+ * Why this is not `JSON.stringify(value, null, 2)` any more: the structured report is a
5
+ * contract (schema, verdict, hashes, check ids, finding codes, evidence — a finding's
6
+ * identity is `check|code|<evidence + path>`, so `logicprobe_report_diff` and the test
7
+ * suite read the object, never this text). The object is unchanged and still reaches the
8
+ * client and the file path; this module only decides what the *model* pays for.
9
+ *
10
+ * Measured on a real 37,167-character report: 33.1% indentation, 21.7% repeated key names,
11
+ * 12.7% punctuation; 105 findings carried 47 distinct facts. So the work this module does —
12
+ * silencing passing checks, grouping repeated (state, event) facts, collapsing identical
13
+ * lines, dropping warning evidence — is work the model would otherwise have to redo by
14
+ * reading every finding. Everything a consumer does not need to act on is handled here.
15
+ *
16
+ * Determinism: the same report always renders to the same text (first-seen order, stable
17
+ * check order, no clock, no randomness), so two runs over the same input stay comparable.
18
+ *
19
+ * @module logicprobe/report-view
20
+ */
21
+ import type { JsonValue } from './json-value.js'
22
+
23
+ /** Rendering limits. Defaults are the shipped behaviour; tests pin them. */
24
+ export interface ReportViewOptions {
25
+ /** Maximum finding lines per check. Error findings are never dropped, only warnings are capped. */
26
+ maxFindingsPerCheck?: number
27
+ /** Evidence is inlined for `error` findings only, truncated to this many characters. */
28
+ maxEvidenceChars?: number
29
+ /** Include passing checks' bodies (default false: a passing check carries no actionable finding). */
30
+ includePassing?: boolean
31
+ /**
32
+ * Print the whole structured report instead of the body. This is the escape hatch for a
33
+ * caller that must hand the report to another tool verbatim — `logicprobe_report_diff`
34
+ * matches on check + code + evidence, so a body cannot stand in for the object.
35
+ */
36
+ full?: boolean
37
+ }
38
+
39
+ const DEFAULTS = { maxFindingsPerCheck: 24, maxEvidenceChars: 160, includePassing: false, full: false } as const
40
+
41
+ type Record_ = Record<string, unknown>
42
+
43
+ const record = (value: unknown): Record_ | undefined =>
44
+ typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record_) : undefined
45
+ const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
46
+ const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
47
+ const count = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
48
+
49
+ /** One normalized finding. `evidence` is kept because a finding's identity depends on it. */
50
+ export interface ViewFinding {
51
+ code: string
52
+ severity: string
53
+ message: string
54
+ evidence?: Record_
55
+ file?: string
56
+ line?: number
57
+ check?: string
58
+ }
59
+
60
+ /** Normalize one raw finding. Unknown fields are ignored; a missing message renders as the code. */
61
+ export function toViewFinding(value: unknown, check?: string): ViewFinding {
62
+ const raw = record(value) ?? {}
63
+ const code = text(raw.code) ?? 'FINDING'
64
+ const file = text(raw.file)
65
+ const line = count(raw.line)
66
+ return {
67
+ code,
68
+ severity: text(raw.severity) ?? 'warning',
69
+ message: text(raw.message) ?? code,
70
+ ...(record(raw.evidence) === undefined ? {} : { evidence: record(raw.evidence) as Record_ }),
71
+ ...(file === undefined ? {} : { file }),
72
+ ...(line === undefined ? {} : { line }),
73
+ ...(check === undefined ? {} : { check }),
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The fact a finding states, as a comparable key. The verify/datamodel families report the
79
+ * same (state, event) fact from more than one check, so the key ignores which check said it;
80
+ * anything else falls back to code + message.
81
+ */
82
+ export function factKey(finding: ViewFinding): string {
83
+ const stateEvent = /^(\S+) silently ignores event (\S+)/.exec(finding.message)
84
+ if (stateEvent !== null) return `event-in-state|${stateEvent[1] ?? ''}|${stateEvent[2] ?? ''}`
85
+ const eventState = /^Event (\S+) in state (\S+) /.exec(finding.message)
86
+ if (eventState !== null) return `event-in-state|${eventState[2] ?? ''}|${eventState[1] ?? ''}`
87
+ return `message|${finding.code}|${finding.message}`
88
+ }
89
+
90
+ /** `[severity/CODE] message` plus, for errors, a compact evidence tail. */
91
+ function findingLine(finding: ViewFinding, options: Required<ReportViewOptions>): string {
92
+ const where = finding.file === undefined ? '' : ` (${finding.file}${finding.line === undefined ? '' : ':' + String(finding.line)})`
93
+ const evidence =
94
+ finding.evidence === undefined || finding.severity !== 'error'
95
+ ? ''
96
+ : ' evidence=' + truncate(JSON.stringify(finding.evidence), options.maxEvidenceChars)
97
+ return ` [${finding.severity}/${finding.code}] ${finding.message}${where}${evidence}`
98
+ }
99
+
100
+ function truncate(value: string, limit: number): string {
101
+ return value.length <= limit ? value : value.slice(0, limit - 1) + '…'
102
+ }
103
+
104
+ /** Collapse identical lines into `line (xN)`, keeping first-seen order. */
105
+ function collapse(lines: string[]): string[] {
106
+ const order: string[] = []
107
+ const seen = new Map<string, number>()
108
+ for (const line of lines) {
109
+ if (!seen.has(line)) order.push(line)
110
+ seen.set(line, (seen.get(line) ?? 0) + 1)
111
+ }
112
+ return order.map((line) => (seen.get(line) === 1 ? line : `${line} (x${String(seen.get(line))})`))
113
+ }
114
+
115
+ /**
116
+ * Body lines for one group of findings. Findings that restate a fact an earlier check already
117
+ * reported are dropped (only their count survives), because re-reading them is pure cost;
118
+ * `error` findings are never dropped.
119
+ */
120
+ function findingLines(findings: ViewFinding[], options: Required<ReportViewOptions>, seenFacts: Set<string>): string[] {
121
+ const lines: string[] = []
122
+ let repeats = 0
123
+ const fresh: ViewFinding[] = []
124
+ const addedHere = new Set<string>()
125
+ for (const finding of findings) {
126
+ const key = factKey(finding)
127
+ // A fact another check already reported is dropped here (only its count survives), but a
128
+ // fact repeated inside this same group is not a cross-check repeat: it collapses below
129
+ // into one line with a multiplier, which keeps the count visible where it belongs.
130
+ if (finding.severity !== 'error' && seenFacts.has(key) && !addedHere.has(key)) {
131
+ repeats += 1
132
+ continue
133
+ }
134
+ addedHere.add(key)
135
+ seenFacts.add(key)
136
+ fresh.push(finding)
137
+ }
138
+ const stateGroups = new Map<string, { severity: string; code: string; events: string[] }>()
139
+ const singles: ViewFinding[] = []
140
+ for (const finding of fresh) {
141
+ const match = /^(\S+) silently ignores event (\S+)/.exec(finding.message) ?? /^Event (\S+) in state (\S+) /.exec(finding.message)
142
+ if (match === null) {
143
+ singles.push(finding)
144
+ continue
145
+ }
146
+ // `event in state` puts the state second; `state ignores event` puts it first.
147
+ const state = / silently ignores event /.test(finding.message) ? match[1] ?? '' : match[2] ?? ''
148
+ const event = / silently ignores event /.test(finding.message) ? match[2] ?? '' : match[1] ?? ''
149
+ const group = stateGroups.get(state) ?? { severity: finding.severity, code: finding.code, events: [] }
150
+ group.events.push(event)
151
+ stateGroups.set(state, group)
152
+ }
153
+ for (const [state, group] of stateGroups) {
154
+ lines.push(` ${state} (${String(group.events.length)}) [${group.severity}/${group.code}]: ${group.events.join(', ')}`)
155
+ }
156
+ const rendered = collapse(singles.map((finding) => findingLine(finding, options)))
157
+ const budget = options.maxFindingsPerCheck
158
+ const kept: string[] = []
159
+ let dropped = 0
160
+ for (const line of [...lines, ...rendered]) {
161
+ if (kept.length < budget || line.includes('/error]')) kept.push(line)
162
+ else dropped += 1
163
+ }
164
+ if (dropped > 0) kept.push(` +${String(dropped)} more warning line(s) — ask for this check by id to see them all`)
165
+ if (repeats > 0) kept.push(` (${String(repeats)} finding(s) here repeat facts already reported above)`)
166
+ return kept
167
+ }
168
+
169
+ /** `ok`/`verdict` header fields, shared by every report family. */
170
+ function headerLines(report: Record_, options: Required<ReportViewOptions>): string[] {
171
+ const schema = text(report.schema) ?? 'logicprobe/report'
172
+ const verdict = text(report.verdict)
173
+ const ok = report.ok
174
+ const summary = record(report.summary) ?? {}
175
+ const lines: string[] = []
176
+ const bits = [
177
+ `${schema}`,
178
+ verdict === undefined ? `ok=${String(ok)}` : `verdict=${verdict}`,
179
+ text(report.verdictReason) ?? '',
180
+ ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
181
+ ].filter((bit) => bit !== '')
182
+ lines.push(bits.join(' '))
183
+ const hash = text(report.modelHash) ?? text(record(report.hashes)?.modelHash)
184
+ const counts = ['states', 'transitions', 'errors', 'warnings', 'checksRun']
185
+ .map((key) => (count(summary[key]) === undefined ? '' : `${key}=${String(count(summary[key]))}`))
186
+ .filter((bit) => bit !== '')
187
+ if (counts.length > 0 || hash !== undefined) lines[0] += ' ' + [...(hash === undefined ? [] : [`modelHash=${hash.slice(0, 12)}…`]), ...counts].join(' ')
188
+ if (report.ran === false) lines.push('note: the tool did not run (the engine refused the input) — the fields below describe the refusal.')
189
+ const coverage = record(report.narrativeCoverage)
190
+ if (coverage !== undefined) {
191
+ const parts = Object.entries(coverage)
192
+ .filter(([, value]) => count(value) !== undefined)
193
+ .map(([key, value]) => `${key} ${String(count(value))}`)
194
+ if (parts.length > 0) lines.push(`NARRATIVE ${parts.join(' / ')} documented`)
195
+ }
196
+ void options
197
+ return lines
198
+ }
199
+
200
+ /** Trailing lines: notes about what was not checked, then the next steps. */
201
+ function footerLines(report: Record_): string[] {
202
+ const lines: string[] = []
203
+ const notes = array(report.coverageNotes).map(String).filter((note) => note !== '')
204
+ if (notes.length > 0) lines.push(`COVERAGE ${notes.join(' ')}`)
205
+ const steps = array(report.nextSteps).map(String).filter((step) => step !== '')
206
+ for (const step of steps) lines.push(`NEXT ${step}`)
207
+ const comparison = record(report.comparison)
208
+ if (comparison !== undefined) {
209
+ const parts = Object.entries(comparison)
210
+ .filter(([, value]) => count(value) !== undefined || typeof value === 'string')
211
+ .map(([key, value]) => `${key}=${String(value)}`)
212
+ if (parts.length > 0) lines.push(`COMPARISON ${parts.join(' ')}`)
213
+ }
214
+ return lines
215
+ }
216
+
217
+ /** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
218
+ function renderChecksReport(report: Record_, options: Required<ReportViewOptions>): string[] {
219
+ const checks = array(report.checks).map((value) => record(value) ?? {})
220
+ const byStatus = (status: string): string[] =>
221
+ checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?')
222
+ const inventory = [
223
+ ['pass', byStatus('pass')],
224
+ ['fail', byStatus('fail')],
225
+ ['skip', byStatus('skip')],
226
+ ]
227
+ .filter(([, ids]) => (ids as string[]).length > 0)
228
+ .map(([status, ids]) => `${String(status)} ${(ids as string[]).join(' ')}`)
229
+ .join(' | ')
230
+ const lines = [...headerLines(report, options), `CHECKS ${inventory}`]
231
+ const seenFacts = new Set<string>()
232
+ for (const check of checks) {
233
+ const status = text(check.status) ?? 'skip'
234
+ const findings = array(check.findings).map((finding) => toViewFinding(finding, text(check.id)))
235
+ if (status === 'pass' && !options.includePassing) continue
236
+ const body = findingLines(findings, options, seenFacts)
237
+ if (status !== 'fail' && body.length === 0) continue
238
+ lines.push('')
239
+ lines.push(
240
+ `${status.toUpperCase()} ${text(check.id) ?? '?'} ${text(check.name) ?? ''} — ${text(check.detail) ?? ''} (${String(findings.length)} finding(s))`.replace(/ +— +$/, ''),
241
+ )
242
+ lines.push(...body)
243
+ }
244
+ return [...lines, ...wrapFooter(report)]
245
+ }
246
+
247
+ /** A flat `findings[]` report (uml review, structure, granularity, concurrency). */
248
+ function renderFindingsReport(report: Record_, options: Required<ReportViewOptions>): string[] {
249
+ const findings = array(report.findings).map((finding) => toViewFinding(finding))
250
+ const errors = findings.filter((finding) => finding.severity === 'error').length
251
+ const warnings = findings.length - errors
252
+ const byCode = new Map<string, number>()
253
+ for (const finding of findings) byCode.set(finding.code, (byCode.get(finding.code) ?? 0) + 1)
254
+ const lines = [
255
+ ...headerLines(report, options),
256
+ `FINDINGS ${String(findings.length)} total (${String(errors)} error, ${String(warnings)} warning): ${[...byCode].map(([code, n]) => `${code} x${String(n)}`).join(' | ')}`,
257
+ ]
258
+ const body = findingLines(findings, options, new Set<string>())
259
+ if (body.length > 0) lines.push('', ...body)
260
+ return [...lines, ...wrapFooter(report)]
261
+ }
262
+
263
+ /** A baseline diff report: what a change added, removed or changed. */
264
+ function renderDeltaReport(report: Record_, options: Required<ReportViewOptions>): string[] {
265
+ const lines = headerLines(report, options)
266
+ const delta = record(report.delta) ?? report
267
+ const section = (key: string): ViewFinding[] => array(delta[key]).map((finding) => toViewFinding(finding))
268
+ for (const key of ['added', 'removed', 'changed']) {
269
+ const findings = section(key)
270
+ if (findings.length === 0) continue
271
+ lines.push('', `${key.toUpperCase()} (${String(findings.length)})`)
272
+ lines.push(...findingLines(findings, options, new Set<string>()))
273
+ }
274
+ const current = text(report.currentVerdict) ?? text(record(report.current)?.verdict)
275
+ if (current !== undefined) lines.push(`CURRENT verdict=${current}`)
276
+ return [...lines, ...wrapFooter(report)]
277
+ }
278
+
279
+ /** A payload result (a rendered diagram, an exported file): print the payload, not JSON of it. */
280
+ function renderPayloadReport(report: Record_, options: Required<ReportViewOptions>): string[] {
281
+ const lines = headerLines(report, options)
282
+ const primary = text(report.diagram) ?? text(report.primary)
283
+ if (primary !== undefined) lines.push('', primary.trimEnd())
284
+ const extras = record(report.extras)
285
+ for (const [name, content] of Object.entries(extras ?? {})) {
286
+ const body = text(content)
287
+ if (body !== undefined) lines.push('', `--- ${name} ---`, body.trimEnd())
288
+ }
289
+ const warnings = array(report.warnings).map(String).filter((warning) => warning !== '')
290
+ for (const warning of warnings) lines.push(`WARNING ${warning}`)
291
+ return [...lines, ...wrapFooter(report)]
292
+ }
293
+
294
+ function wrapFooter(report: Record_): string[] {
295
+ const footer = footerLines(report)
296
+ return footer.length === 0 ? [] : ['', ...footer]
297
+ }
298
+
299
+ /**
300
+ * A refusal, or a shape this module does not recognize: state the reason first, then the
301
+ * remaining fields compactly so nothing is lost, without paying for indentation.
302
+ */
303
+ function renderFallbackReport(report: Record_, options: Required<ReportViewOptions>): string[] {
304
+ const lines = headerLines(report, options)
305
+ const message = text(report.error)
306
+ if (message !== undefined) {
307
+ const code = text(report.errorCode)
308
+ lines.push(`ERROR ${message}${code === undefined ? '' : ` [${code}]`}`)
309
+ }
310
+ const rest: Record_ = { ...report }
311
+ delete rest.error
312
+ delete rest.errorCode
313
+ lines.push('', JSON.stringify(rest))
314
+ return [...lines, ...wrapFooter(report)]
315
+ }
316
+
317
+ /**
318
+ * Render any logicprobe result as the text a model reads.
319
+ *
320
+ * Order of recognition: a `checks[]` report, a flat `findings[]` report, a baseline delta,
321
+ * a payload result (`diagram`/`primary`), and finally compact JSON so an unrecognized shape
322
+ * is still readable rather than silently empty.
323
+ */
324
+ export function renderReportText(value: unknown, options: ReportViewOptions = {}): string {
325
+ const resolved: Required<ReportViewOptions> = { ...DEFAULTS, ...options }
326
+ if (resolved.full) return JSON.stringify(value as JsonValue, null, 2)
327
+ const report = record(value)
328
+ if (report === undefined) return JSON.stringify(value as JsonValue) ?? String(value)
329
+ const hasChecks = array(report.checks).some((check) => record(check) !== undefined)
330
+ const hasFindings = array(report.findings).length > 0
331
+ const isDelta = array(report.added).length + array(report.removed).length + array(report.changed).length > 0
332
+ const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string'
333
+ const lines = hasChecks
334
+ ? renderChecksReport(report, resolved)
335
+ : isDelta
336
+ ? renderDeltaReport(report, resolved)
337
+ : hasFindings
338
+ ? renderFindingsReport(report, resolved)
339
+ : isPayload
340
+ ? renderPayloadReport(report, resolved)
341
+ : renderFallbackReport(report, resolved)
342
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
343
+ }
@@ -3,6 +3,7 @@ import type { JsonValue } from './json-value.js'
3
3
  import { REPORT_SCHEMAS } from './engine.js'
4
4
  import { reviewStructure } from './structure.js'
5
5
  import { reviewGranularity, type GranularityDiagram } from './granularity.js'
6
+ import { renderReportText } from './report-view.js'
6
7
 
7
8
  export const LOGICPROBE_STRUCTURE_TOOL_NAME = 'logicprobe_structure_verify'
8
9
 
@@ -36,14 +37,19 @@ export const logicProbeStructureTool = defineTool({
36
37
  type: 'json',
37
38
  description: 'Optional dependency matrix: {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Every edge is judged against it and the matched rule ids are reported.',
38
39
  },
40
+ reportView: {
41
+ type: 'string',
42
+ enum: ['digest', 'full'],
43
+ description: 'digest (default) prints the readable body; full prints the structured report, which logicprobe_report_diff needs.',
44
+ },
39
45
  },
40
46
  output: {
41
47
  schema: {
42
48
  type: 'json',
43
49
  description: 'Structure review report: graph (nodes/edges), findings UML020-UML026, per-edge matrix verdicts, summary and next steps.',
44
50
  },
45
- render(_args, value) {
46
- return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
51
+ render(args, value) {
52
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
47
53
  },
48
54
  },
49
55
  timeoutMs: 10_000,
package/src/tool.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { runVerification } from './engine.js'
4
+ import { renderReportText } from './report-view.js'
4
5
 
5
6
  export const LOGICPROBE_VERIFY_TOOL_NAME = 'logicprobe_verify'
6
7
 
@@ -39,14 +40,19 @@ export const logicProbeVerifyTool = defineTool({
39
40
  type: 'json',
40
41
  description: 'Optional object mapping BEFORE state ids to AFTER state ids. Omit for identity mapping (same state names).',
41
42
  },
43
+ reportView: {
44
+ type: 'string',
45
+ enum: ['digest', 'full'],
46
+ description: 'digest (default) prints the readable body; full prints the structured report, which logicprobe_report_diff needs.',
47
+ },
42
48
  },
43
49
  output: {
44
50
  schema: {
45
51
  type: 'json',
46
52
  description: 'logicprobe verification report with summary and per-check findings.',
47
53
  },
48
- render(_args, value) {
49
- return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
54
+ render(args, value) {
55
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
50
56
  },
51
57
  },
52
58
  timeoutMs: 10_000,