dsh-logicprobe 0.8.1 → 0.9.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/src/engine.ts CHANGED
@@ -154,7 +154,7 @@ export type InvariantSpec =
154
154
  when?: InvariantWhen
155
155
  }
156
156
  | { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
157
- | { id: string; description: string; kind: 'leads-to'; from: string; to: string }
157
+ | { id: string; description: string; kind: 'leads-to'; from: string; to: string | string[] }
158
158
  | { id: string; description: string; kind: 'sequence'; events: string[] }
159
159
  | { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
160
160
  | { id: string; description: string; kind: 'budget'; budget: number }
@@ -469,8 +469,27 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
469
469
  } else if (invariant.kind === 'leads-to') {
470
470
  if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
471
471
  else if (!invariantStateIds.has(invariant.from)) bad(path + '.from', 'references unknown state ' + invariant.from)
472
- if (typeof invariant.to !== 'string' || invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
473
- else if (!invariantStateIds.has(invariant.to)) bad(path + '.to', 'references unknown state ' + invariant.to)
472
+ // `to` is one state id or a target set. A set with a duplicate member is a
473
+ // model-authoring slip that would otherwise give one meaning two hashes, so it
474
+ // is rejected rather than silently deduplicated.
475
+ if (typeof invariant.to === 'string') {
476
+ if (invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
477
+ else if (!invariantStateIds.has(invariant.to)) bad(path + '.to', 'references unknown state ' + invariant.to)
478
+ } else if (Array.isArray(invariant.to)) {
479
+ if (invariant.to.length === 0) bad(path + '.to', 'must be a non-empty array of state ids')
480
+ else {
481
+ const targets = new Set<string>()
482
+ invariant.to.forEach((target, targetIndex) => {
483
+ const at = path + '.to[' + targetIndex + ']'
484
+ if (typeof target !== 'string' || target.length === 0) bad(at, 'must be a non-empty string')
485
+ else if (!invariantStateIds.has(target)) bad(at, 'references unknown state ' + target)
486
+ else if (targets.has(target)) bad(at, 'duplicates state ' + target + ' in the target set')
487
+ else targets.add(target)
488
+ })
489
+ }
490
+ } else {
491
+ bad(path + '.to', 'must be a state id or a non-empty array of state ids')
492
+ }
474
493
  } else if (invariant.kind === 'sequence') {
475
494
  if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
476
495
  else invariant.events.forEach((event, eventIndex) => {
@@ -1108,6 +1127,96 @@ function shortestViolationForInvariant(model: LogicModelV1, options: NormalizedO
1108
1127
  return undefined
1109
1128
  }
1110
1129
 
1130
+ /**
1131
+ * Whether an invariant asserts a property of whole runs rather than of a single
1132
+ * runtime state. These kinds cannot be decided by a state predicate, so they are not
1133
+ * part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
1134
+ * states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
1135
+ * verdicts available to A7 and D2.
1136
+ */
1137
+ function isPathPropertyInvariant(invariant: InvariantSpec): boolean {
1138
+ return invariant.kind === 'leads-to'
1139
+ || invariant.kind === 'sequence'
1140
+ || invariant.kind === 'atomicity'
1141
+ || invariant.kind === 'budget'
1142
+ || invariant.kind === 'probability'
1143
+ }
1144
+
1145
+ /** Whether a probability invariant's bound fails for the computed hit probability. */
1146
+ function probabilityViolated(invariant: Extract<InvariantSpec, { kind: 'probability' }>, probability: number): boolean {
1147
+ const eps = 1e-9
1148
+ if (invariant.op === '>=') return probability < invariant.p - eps
1149
+ if (invariant.op === '>') return probability <= invariant.p + eps
1150
+ if (invariant.op === '<=') return probability > invariant.p + eps
1151
+ return probability >= invariant.p - eps
1152
+ }
1153
+
1154
+ /** Every runtime state reachable from init, capped like the other searches. */
1155
+ function reachableRuntimeStates(model: LogicModelV1, options: NormalizedOptions): RuntimeState[] {
1156
+ const init = initialState(model)
1157
+ const visited = new Set<string>([runtimeKey(init)])
1158
+ const reached: RuntimeState[] = [init]
1159
+ const queue: RuntimeState[] = [init]
1160
+ let steps = 0
1161
+ while (queue.length > 0) {
1162
+ if (++steps > options.maxStates) break
1163
+ const current = queue.shift()!
1164
+ for (const event of allEvents(model)) {
1165
+ for (const next of stepRuntime(model, current, event)) {
1166
+ const key = runtimeKey(next)
1167
+ if (visited.has(key)) continue
1168
+ visited.add(key)
1169
+ reached.push(next)
1170
+ queue.push(next)
1171
+ }
1172
+ }
1173
+ }
1174
+ return reached
1175
+ }
1176
+
1177
+ /**
1178
+ * Violation of a path-property invariant, or undefined when it holds. Each kind
1179
+ * delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
1180
+ * atomicity, A12 budget, A13 probability), so a verdict reported here can never
1181
+ * disagree with the probe that owns the kind.
1182
+ */
1183
+ function pathPropertyViolation(model: LogicModelV1, options: NormalizedOptions, invariant: InvariantSpec): InvariantViolation | undefined {
1184
+ if (invariant.kind === 'leads-to') {
1185
+ const targets = new Set(leadsToTargets(invariant))
1186
+ const label = leadsToTargetLabel(invariant)
1187
+ for (const runtime of reachableRuntimeStates(model, options)) {
1188
+ if (runtime.state !== invariant.from) continue
1189
+ const bad = findLeadsToBadPath(model, runtime, targets, label)
1190
+ if (bad !== undefined) return { invariant, path: bad.path, reason: bad.reason }
1191
+ }
1192
+ return undefined
1193
+ }
1194
+ if (invariant.kind === 'sequence') return findSequenceViolation(model, options, invariant)
1195
+ if (invariant.kind === 'atomicity') return findAtomicityViolation(model, options, invariant)
1196
+ if (invariant.kind === 'budget') {
1197
+ const violation = findBudgetViolation(model, options, invariant)
1198
+ if (violation === undefined) return undefined
1199
+ return {
1200
+ invariant,
1201
+ path: violation.path,
1202
+ reason: violation.unbounded
1203
+ ? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
1204
+ : 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
1205
+ }
1206
+ }
1207
+ if (invariant.kind === 'probability') {
1208
+ const { probability, converged } = computeHitProbability(model, options, invariant.target)
1209
+ if (!probabilityViolated(invariant, probability)) return undefined
1210
+ return {
1211
+ invariant,
1212
+ path: [],
1213
+ reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
1214
+ + (converged ? '' : ' (value iteration did not converge)'),
1215
+ }
1216
+ }
1217
+ return undefined
1218
+ }
1219
+
1111
1220
  function shortestEventBeforeStateViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'event-before-state' }>): InvariantViolation | undefined {
1112
1221
  const init = initialState(model)
1113
1222
  const seenInitially = false
@@ -1138,10 +1247,12 @@ function shortestEventBeforeStateViolation(model: LogicModelV1, options: Normali
1138
1247
  return undefined
1139
1248
  }
1140
1249
 
1141
- function runInvariants(model: LogicModelV1, options: NormalizedOptions): InvariantViolation[] {
1250
+ function runInvariants(model: LogicModelV1, options: NormalizedOptions, includePathProperties = false): InvariantViolation[] {
1142
1251
  const violations: InvariantViolation[] = []
1143
1252
  for (const invariant of model.invariants ?? []) {
1144
- const violation = shortestViolationForInvariant(model, options, invariant)
1253
+ const violation = includePathProperties && isPathPropertyInvariant(invariant)
1254
+ ? pathPropertyViolation(model, options, invariant)
1255
+ : shortestViolationForInvariant(model, options, invariant)
1145
1256
  if (violation !== undefined) violations.push(violation)
1146
1257
  }
1147
1258
  return violations
@@ -1507,14 +1618,27 @@ function A6_resourceInjection(model: LogicModelV1): CheckResult {
1507
1618
  }
1508
1619
 
1509
1620
  function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1510
- const violations = runInvariants(model, options)
1511
- const findings: Finding[] = violations.map((violation) => ({
1512
- code: 'A7_SHORTEST_COUNTEREXAMPLE',
1513
- severity: 'warning',
1514
- message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1515
- path: violation.path,
1516
- evidence: { invariant: violation.invariant.id },
1517
- }))
1621
+ // Path-property invariants are included: "for any invariant that fails" is this
1622
+ // check's contract, and a check that silently skips five of the eight kinds cannot
1623
+ // honour it. The searches for the state kinds, for sequence, atomicity and budget
1624
+ // are breadth-first, so their first witness is the shortest one; the leads-to walk
1625
+ // and the probability bound have no such guarantee, and their message says what it
1626
+ // found instead of claiming minimality.
1627
+ const violations = runInvariants(model, options, true)
1628
+ const findings: Finding[] = violations.map((violation) => {
1629
+ const pathLength = violation.path.length
1630
+ const message = isPathPropertyInvariant(violation.invariant)
1631
+ ? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
1632
+ + (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
1633
+ : 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '')
1634
+ return {
1635
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
1636
+ severity: 'warning' as const,
1637
+ message,
1638
+ path: violation.path,
1639
+ evidence: { invariant: violation.invariant.id },
1640
+ }
1641
+ })
1518
1642
  return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
1519
1643
  }
1520
1644
 
@@ -1595,6 +1719,25 @@ function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<str
1595
1719
  }
1596
1720
  return mapped
1597
1721
  }
1722
+ if (invariant.kind === 'leads-to') {
1723
+ // A target set maps member by member; a rename that drops a state is reported by
1724
+ // D2's own mapped-state check rather than silently changing the property.
1725
+ return {
1726
+ ...invariant,
1727
+ id: invariant.id + ':before',
1728
+ description: invariant.description + ' (from BEFORE)',
1729
+ from: mapStateId(mapping, invariant.from),
1730
+ to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
1731
+ }
1732
+ }
1733
+ if (invariant.kind === 'probability') {
1734
+ return {
1735
+ ...invariant,
1736
+ id: invariant.id + ':before',
1737
+ description: invariant.description + ' (from BEFORE)',
1738
+ target: mapStateId(mapping, invariant.target),
1739
+ }
1740
+ }
1598
1741
  return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
1599
1742
  }
1600
1743
 
@@ -1634,8 +1777,36 @@ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, optio
1634
1777
  })
1635
1778
  continue
1636
1779
  }
1780
+ } else if (mapped.kind === 'leads-to') {
1781
+ // The state references of a path-property invariant must follow stateMapping too,
1782
+ // otherwise a pure rename searches for a state AFTER does not declare.
1783
+ for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
1784
+ if (!afterStates.has(state)) {
1785
+ findings.push({
1786
+ code: 'D2_MAPPED_STATE_MISSING',
1787
+ severity: 'warning',
1788
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1789
+ evidence: { invariant: invariant.id, state },
1790
+ })
1791
+ }
1792
+ }
1793
+ } else if (mapped.kind === 'probability') {
1794
+ if (!afterStates.has(mapped.target)) {
1795
+ findings.push({
1796
+ code: 'D2_MAPPED_STATE_MISSING',
1797
+ severity: 'warning',
1798
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
1799
+ evidence: { invariant: invariant.id, state: mapped.target },
1800
+ })
1801
+ }
1637
1802
  }
1638
- const violation = shortestViolationForInvariant(after, options, mapped)
1803
+ // Every invariant the engine can decide is re-decided against AFTER. Path-property
1804
+ // kinds go through the same searches their dedicated probes use, so D2 can no
1805
+ // longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
1806
+ // very same model.
1807
+ const violation = isPathPropertyInvariant(mapped)
1808
+ ? pathPropertyViolation(after, options, mapped)
1809
+ : shortestViolationForInvariant(after, options, mapped)
1639
1810
  if (violation !== undefined) {
1640
1811
  findings.push({
1641
1812
  code: 'D2_INVARIANT_REGRESSION',
@@ -1825,15 +1996,29 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1825
1996
  }
1826
1997
 
1827
1998
  /**
1828
- * Find a run from `start` that never reaches `target`, or return undefined when
1829
- * every run does. The property is universal (see the A9 row in `SKILL.md`): one
1830
- * branch that loops forever, or that stops before the target, refutes it.
1999
+ * Target states of a `leads-to` invariant. The schema accepts one state id or a
2000
+ * non-empty array of them, so "reach any of these" needs no separate invariant kind.
2001
+ */
2002
+ function leadsToTargets(invariant: Extract<InvariantSpec, { kind: 'leads-to' }>): string[] {
2003
+ return typeof invariant.to === 'string' ? [invariant.to] : invariant.to
2004
+ }
2005
+
2006
+ /** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
2007
+ function leadsToTargetLabel(invariant: Extract<InvariantSpec, { kind: 'leads-to' }>): string {
2008
+ return leadsToTargets(invariant).join(', ')
2009
+ }
2010
+
2011
+ /**
2012
+ * Find a run from `start` that reaches none of `targets`, or return undefined when
2013
+ * every run reaches at least one of them. The property is universal (see the A9 row
2014
+ * in `SKILL.md`): one branch that loops forever, or that stops before any target,
2015
+ * refutes it.
1831
2016
  *
1832
- * The search is a depth-first walk of the run graph with three colours, and target
1833
- * runs are success leaves that are never expanded:
2017
+ * The search is a depth-first walk of the run graph with three colours, and runs at a
2018
+ * target are success leaves that are never expanded:
1834
2019
  *
1835
2020
  * - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
1836
- * the target, so some run never reaches it;
2021
+ * every target, so some run never reaches one;
1837
2022
  * - a node with no outgoing step at all stops the machine where it stands, which is
1838
2023
  * the same violation for a different reason;
1839
2024
  * - a node whose walk completed without a violation is BLACK, and reaching it again
@@ -1844,8 +2029,8 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
1844
2029
  * A run is identified by its state plus its variable values, so the same state id
1845
2030
  * with different values is a different node.
1846
2031
  */
1847
- function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: string): { path: PathStep[]; reason: string } | undefined {
1848
- if (start.state === target) return undefined
2032
+ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string): { path: PathStep[]; reason: string } | undefined {
2033
+ if (targets.has(start.state)) return undefined
1849
2034
  const GRAY = 1
1850
2035
  const BLACK = 2
1851
2036
  interface LeadFrame {
@@ -1866,7 +2051,7 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: st
1866
2051
  for (const next of stepRuntime(model, frame.runtime, event)) nexts.push({ next, event })
1867
2052
  }
1868
2053
  frame.nexts = nexts
1869
- if (nexts.length === 0) return { path: frame.path, reason: 'Dead end before target ' + target }
2054
+ if (nexts.length === 0) return { path: frame.path, reason: 'Dead end before target ' + label }
1870
2055
  }
1871
2056
  if (frame.index >= frame.nexts.length) {
1872
2057
  color.set(runtimeKey(frame.runtime), BLACK)
@@ -1876,15 +2061,15 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: st
1876
2061
  const { next, event } = frame.nexts[frame.index]
1877
2062
  frame.index += 1
1878
2063
  const step: PathStep = { from: frame.runtime.state, event, to: next.state }
1879
- if (next.state === target) continue
2064
+ if (targets.has(next.state)) continue
1880
2065
  const key = runtimeKey(next)
1881
2066
  const seen = color.get(key)
1882
- if (seen === GRAY) return { path: [...frame.path, step], reason: 'Cycle avoids target ' + target }
2067
+ if (seen === GRAY) return { path: [...frame.path, step], reason: 'Cycle avoids target ' + label }
1883
2068
  if (seen === BLACK) continue
1884
2069
  color.set(key, GRAY)
1885
2070
  stack.push({ runtime: next, path: [...frame.path, step], nexts: undefined, index: 0 })
1886
2071
  }
1887
- // Every branch either reached the target or rejoined a branch that did.
2072
+ // Every branch either reached a target or rejoined a branch that did.
1888
2073
  return undefined
1889
2074
  }
1890
2075
 
@@ -1892,9 +2077,11 @@ function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult
1892
2077
  const findings: Finding[] = []
1893
2078
  for (const invariant of model.invariants ?? []) {
1894
2079
  if (invariant.kind !== 'leads-to') continue
2080
+ const targets = new Set(leadsToTargets(invariant))
2081
+ const label = leadsToTargetLabel(invariant)
1895
2082
  for (const runtime of exploration.reachable) {
1896
2083
  if (runtime.state !== invariant.from) continue
1897
- const bad = findLeadsToBadPath(model, runtime, invariant.to)
2084
+ const bad = findLeadsToBadPath(model, runtime, targets, label)
1898
2085
  if (bad !== undefined) {
1899
2086
  findings.push({
1900
2087
  code: 'A9_LEADS_TO_VIOLATION',
@@ -2318,16 +2505,10 @@ function computeHitProbability(model: LogicModelV1, options: NormalizedOptions,
2318
2505
 
2319
2506
  function A13_probability(model: LogicModelV1, options: NormalizedOptions): CheckResult {
2320
2507
  const findings: Finding[] = []
2321
- const eps = 1e-9
2322
2508
  for (const invariant of model.invariants ?? []) {
2323
2509
  if (invariant.kind !== 'probability') continue
2324
2510
  const { probability, converged } = computeHitProbability(model, options, invariant.target)
2325
- let violated = false
2326
- if (invariant.op === '>=') violated = probability < invariant.p - eps
2327
- else if (invariant.op === '>') violated = probability <= invariant.p + eps
2328
- else if (invariant.op === '<=') violated = probability > invariant.p + eps
2329
- else violated = probability >= invariant.p - eps
2330
- if (violated) {
2511
+ if (probabilityViolated(invariant, probability)) {
2331
2512
  findings.push({
2332
2513
  code: 'A13_PROBABILITY_VIOLATION',
2333
2514
  severity: 'error',
package/src/index.ts CHANGED
@@ -75,7 +75,7 @@ export type InteractionMode = 'ask' | 'auto' | 'follow-approval'
75
75
  const DEFAULT_GATE_CONTENT = `<EXTREMELY_IMPORTANT>
76
76
  Plugin logicprobe is active. Documents are not truth — code is. Verify every verifiable claim before accepting or acting on any design.
77
77
 
78
- **1% Rule**: If there is even a 1% chance the logicprobe skill applies — reviewing design documents, architecture specs, technical proposals, or refactoring plans that make claims about API names, file locations, enum values, mechanism feasibility, state machines, protocol logic, data models, schema migrations, data invariants, or behavioral guarantees ("always"/"never"/"guaranteed") — load it with the skill tool before responding. The cost of loading is trivial compared to the cost of a false claim.
78
+ **1% Rule**: If there is even a 1% chance the logicprobe skill applies — reviewing design documents, architecture specs, technical proposals, or refactoring plans that make claims about API names, file locations, enum values, mechanism feasibility, state machines, protocol logic, data models, schema migrations, data invariants, behavioral guarantees ("always"/"never"/"guaranteed"), worst-case path cost or declared budgets, probabilistic reachability ("≥90% of runs reach SAFE"), deadlines ("must leave within 2 ticks"), cross-machine handshakes, or concurrency guarantees ("thread-safe"/"lock-free"/"ISR-safe") — load it with the skill tool before responding. The cost of loading is trivial compared to the cost of a false claim.
79
79
 
80
80
  **Red Flags** — if you think any of these, STOP. You are rationalizing:
81
81
 
@@ -86,7 +86,16 @@ Plugin logicprobe is active. Documents are not truth — code is. Verify every v
86
86
  | "I'll verify while implementing" | Verification happens before implementation, not during. |
87
87
  | "I can check this with reasoning alone" | Behavioral claims are verified with code/models, not intuition. One counter-example refutes a universal claim. |
88
88
 
89
- **Native verification path**: In dsh, prefer the \`logicprobe_verify\` tool for state-machine checks and \`logicprobe_datamodel_verify\` for data-model/schema migration checks. Both support before/after regression and common domain constraints (idempotency, monotonic, sequence, leads-to, atomicity). To model a code flow as UML, or to audit such a modelling, use \`logicprobe_uml\` (render | parse | review). Python harnesses remain the fallback for non-dsh hosts.
89
+ **Native verification path** (dsh) — six model-invocable tools:
90
+
91
+ - \`logicprobe_verify\` — state machines: S1-S8 structural + A1-A14 adversarial, including A12 worst-case path cost against a declared \`budget\`, A13 probabilistic reachability over a DTMC, and A14 discrete-tick deadlines. Pass \`beforeModel\` + \`stateMapping\` to add the D1-D4 before/after regression.
92
+ - \`logicprobe_datamodel_verify\` — data models and schema migrations: DS/DA checks, migration coverage, copy consistency, rollback symmetry, DD1-DD4 data regression.
93
+ - \`logicprobe_compose_verify\` — two or more machines checked together (rendezvous handshake semantics): C1 composition deadlock, C2 rendezvous that never fires.
94
+ - \`logicprobe_concurrency_scan\` — mine concurrency claims (thread-safe, lock-free, race condition, mutex, ISR-safe) and route them to dedicated verification; logicprobe does not prove concurrency safety.
95
+ - \`logicprobe_export\` — emit external-checker input from a verified model: UPPAAL, TLA+, PRISM, SPIN.
96
+ - \`logicprobe_uml\` — model a code flow as UML (render), read a diagram back into a model (parse), or audit the modelling (review: UML001-UML019 structural defects, documentation gaps, diagram-versus-model round-trip fidelity).
97
+
98
+ Python harnesses in the skill references remain the fallback for non-dsh hosts.
90
99
 
91
100
  **Proactive suggestion**: When a user asks code-level behavioral questions — "could this state machine deadlock", "is this retry limit safe", "check this timing sequence for bugs", "is this migration non-breaking", "does this copy cover all required fields" — suggest logicprobe as an optional verification pass (do not auto-escalate).
92
101
  </EXTREMELY_IMPORTANT>`
@@ -211,7 +220,7 @@ function resolveInteraction(config: Config, session: Session): 'ask' | 'auto' {
211
220
  function modeContextText(config: Config, session: Session): string {
212
221
  const interaction = resolveInteraction(config, session)
213
222
  const lines = [
214
- 'logicprobe: use `logicprobe_verify` for state machines and `logicprobe_datamodel_verify` for data models; both cover before/after regression and common domain constraints.',
223
+ 'logicprobe: native tools — `logicprobe_verify` (state machines: S1-S8 + A1-A14, incl. A12 budget/worst-case path cost, A13 probabilistic reachability, A14 deadlines; `beforeModel` adds D1-D4 regression), `logicprobe_datamodel_verify` (data models and schema migrations: DS/DA, migration coverage, copy consistency, rollback symmetry, DD1-DD4), `logicprobe_compose_verify` (two or more machines: C1 composition deadlock, C2 rendezvous never fires), `logicprobe_concurrency_scan` (mine concurrency claims — it does not prove concurrency safety), `logicprobe_export` (UPPAAL, TLA+, PRISM, SPIN input from a verified model).',
215
224
  'logicprobe: use `logicprobe_uml` to model a code flow as UML (render), to read a UML diagram back into a model (parse), or to audit the modelling (review: structural defects, documentation gaps, diagram-vs-model round-trip fidelity).',
216
225
  interaction === 'auto'
217
226
  ? 'logicprobe interaction=auto: do NOT call ask_user_question for model confirmation; run round-trip validation of the extracted transition table and mark the result UNCONFIRMED.'
package/src/tool.ts CHANGED
@@ -1,4 +1,4 @@
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 { runVerification } from './engine.js'
4
4
 
@@ -16,7 +16,7 @@ export const LOGICPROBE_VERIFY_TOOL_NAME = 'logicprobe_verify'
16
16
  export const logicProbeVerifyTool = defineTool({
17
17
  name: LOGICPROBE_VERIFY_TOOL_NAME,
18
18
  description:
19
- 'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?, cost?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?, idempotentEvents?, narrative?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range ({variable, min?, max?, when?} — when scopes the range to a state ({state}) or a guard node, is evaluated on the post-state of every transition, leaves the initial state checked unconditionally, and is rejected on any other invariant kind), event-before-state, leads-to, sequence, atomicity, budget (transition cost defaults to 1; A12 checks every reachable path stays within the declared budget and reports the shortest over-budget counterexample), and probability (transition weight, default 1, makes the model a DTMC; A13 checks P(hit target) against the bound by value iteration). The schema is closed: an undeclared key on any model part (model, state, transition, update, variable, invariant, boundaryCheck, resourcePair, narrative, scenario) is a validation error, never ignored. Variables support monotonic inc/dec. The optional narrative block carries natural-language descriptions of states (narrative.states), events (narrative.events), and (state, event) scenarios (narrative.scenarios: [{from, event, scenario}]); when present it must fully cover the model and is echoed in the report. Returns a report with S1-S8 structural checks and A1-A13 adversarial/probability probes including shortest counterexample paths. If beforeModel is provided, also runs D1-D4 before/after regression checks. See skills/logicprobe/references/dsh-model-schema.md.',
19
+ 'Run executable state-machine verification (logicprobe). Takes a LogicModelV1 object with schemaVersion=1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?, cost?}), variables?, invariants?, concurrentPairs?, boundaryChecks?, resourcePairs?, idempotentEvents?, narrative?. Guards are structured ({variable, op, value} | {all} | {any} | {not}); invariants support never-states, var-in-range ({variable, min?, max?, when?} — when scopes the range to a state ({state}) or a guard node, is evaluated on the post-state of every transition, leaves the initial state checked unconditionally, and is rejected on any other invariant kind), event-before-state, leads-to (`to` is one state id or a non-empty array of them: every path must reach at least one member), sequence, atomicity, budget (transition cost defaults to 1; A12 checks every reachable path stays within the declared budget and reports the shortest over-budget counterexample), and probability (transition weight, default 1, makes the model a DTMC; A13 checks P(hit target) against the bound by value iteration), and deadline (declaring maxTicks on a state is how many tick steps it may stay resident and the top-level tickEvents list names those steps; A14 reports the over-residency path, and A14_NO_TICK_EVENTS when maxTicks is declared without tickEvents). The schema is closed: an undeclared key on any model part (model, state, transition, update, variable, invariant, boundaryCheck, resourcePair, narrative, scenario) is a validation error, never ignored. Variables support monotonic inc/dec. The optional narrative block carries natural-language descriptions of states (narrative.states), events (narrative.events), and (state, event) scenarios (narrative.scenarios: [{from, event, scenario}]); when present it must fully cover the model and is echoed in the report. Returns a report with S1-S8 structural checks and A1-A14 adversarial, cost, probability and deadline probes, including counterexample paths (shortest where the search is breadth-first). If beforeModel is provided, also runs D1-D4 before/after regression checks. See skills/logicprobe/references/dsh-model-schema.md.',
20
20
  parameters: {
21
21
  model: {
22
22
  type: 'json',