/** * logicprobe — DeepSeek Harness native plugin for the Logic Probe toolbox. * Injects the session-start gate text (what this plugin checks, the routing to * the skills, the tool list, how to read a report) * into the first model step of every agent session, mirroring the SessionStart * hook the Claude Code plugin installs. * The skill ships in this package's `skills/` directory and is registered at * apply time into dsh's `ctx.skills` registry through the standard filesystem * provider, so it appears in every session catalog without a manual copy step. * * Injection listens on agent/pre-step and appends the gate to the FIRST * model step that runs, once per session (guarded by the session's durable * history). Session-start inbox injection was dropped: a blank-session preset * switch (agentPreset.select -> recompose) can clear the inbox before the * first step, losing the gate for the whole session. The pre-step decision is * the durable path - anchored/bootstrap presets that strip first-step injected * reminders (skill catalog, AGENTS.md, gate plugins) simply defer this message * to the first step after their promotion, and the history guard re-injects it * there. The default gate text is the dsh-native adaptation of * `hooks/session-start-content.md`. Both are a guide to calling the tools: what * this plugin checks, the routing to the sibling skills, the tool list, and how to * read a report. The Red Flags table is enforcement scaffolding and lives in the * skill body instead, where a session that needs it can load it. * They stay in sync. Method, doctrine and per-domain detail live in the skills * and their references, not here. Deployments override via Config. * * @module logicprobe-dsh */ import { fileURLToPath } from 'node:url' import type { Context, Volatile } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner' import type { AssembleContext, PromptContext } from '@deepseek-ai/dsh-system-prompt' import { FileSystemSkillProvider } from '@deepseek-ai/dsh-skill-filesystem' import { logicProbeVerifyTool } from './tool.js' import { logicProbeDataModelVerifyTool, DATA_ENGINE_SCHEMA_VERSION } from './data-tool.js' import { logicProbeConcurrencyScanTool } from './concurrency-tool.js' import { logicProbeComposeTool } from './compose-tool.js' import { logicProbeExportTool } from './export-tool.js' import { logicProbeUmlTool } from './uml-tool.js' import { logicProbeStructureTool } from './structure-tool.js' import { logicProbeReportDiffTool } from './report-diff-tool.js' import { ENGINE_SCHEMA_VERSION } from './engine.js' // DSH 0.1.7-alpha.1 (session format v4) retires the shared // `{ kind: 'plugin', plugin }` wrapper: native admission rejects it in every // declared durable message slot, and the official v3-to-v4 migration rewrites // those historical rows to `plugin:`. Declaring the producer-owned kind // here keeps the write path and the history guard on one identity, and still // compiles against the earlier releases that only declare `plugin`. declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { 'plugin:logicprobe': { kind: 'plugin:logicprobe' } & ContextFormed } } export const name = 'logicprobe' // Skills are contributed through the registry service, which dsh-base always // mounts before bundle rows such as this one apply. export const inject = ['skills'] // Absolute path of the package's shipped skills directory. `lib/index.js` // lives one level below the package root, so `../skills` from the module URL // lands on `/skills` regardless of where the package was installed. const SKILLS_DIR = fileURLToPath(new URL('../skills', import.meta.url)) const GATE_PLUGIN_ID = 'logicprobe' /** Producer-owned message source kind declared in `MessageSourceMap` above. */ const GATE_SOURCE_KIND: 'plugin:logicprobe' = 'plugin:logicprobe' export type InteractionMode = 'ask' | 'auto' | 'follow-approval' const DEFAULT_GATE_CONTENT = ` Plugin logicprobe is active. It checks claims about designs and code: facts against the source, behaviour as an executable model. **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. **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). **Tools** (dsh): - \`logicprobe_verify\` — state machines: structural checks, adversarial probes, budgets, deadlines, before/after regression. - \`logicprobe_datamodel_verify\` — data models and migrations: structure, migration coverage, copy consistency, rollback, before/after data. - \`logicprobe_compose_verify\` — two or more machines: deadlock, and a rendezvous that never fires. - \`logicprobe_concurrency_scan\` — finds concurrency claims and routes them. It proves nothing about concurrency. - \`logicprobe_export\` — UPPAAL, TLA+, PRISM and SPIN input from a verified model. - \`logicprobe_uml\` — render, parse or review a UML diagram. - \`logicprobe_structure_verify\` — dependency review of a structure diagram, at one level or several. - \`logicprobe_report_diff\` — baseline against current: what a change added, removed or changed. **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. ` /** A schemastery field that may or may not carry `.volatile()`. */ interface LiveField { volatile?: () => unknown } /** * Declare a field as live where this host's schemastery can — `.volatile()` * arrived in 3.18.3 — and leave it an ordinary field where it cannot. * * The fallback is load-bearing, not defensive padding. `Config` below is built * while this module is still being evaluated, so an unconditional `.volatile()` * on a host shipping schemastery 3.18.2 (measured: dsh 0.1.5-rc.2 and * 0.1.5-rc.3) throws during import; the loader entry then fails and takes the * WHOLE plugin tree — and the host's boot — down with it. Degrading costs only * the Plugins-page switch, because the settings service projects nothing but * fields under a `.volatile()` node; skills, tools and the gate injection are * untouched. The returned schema keeps the plain field's static type; the * `Config` interface below carries the union the host actually hands over. */ function live(field: T): T { const probe = field as T & LiveField return typeof probe.volatile === 'function' ? (probe.volatile() as T) : field } export interface Config { /** * The injection switch the Web client's Plugins page edits live: a `Volatile` * reference on a host whose schemastery supports one, an ordinary boolean on a * host that predates `.volatile()`. Read it through {@link injectionEnabled}, * which accepts both shapes. */ enabled: Volatile | boolean gateContent: string interaction: InteractionMode } export const Config = z.object({ // Live so the Web Plugins page can flip the gate injection inside a running // session: dsh's settings service projects ONLY fields under a `.volatile()` // node and rejects writes to every other path. The price is that the injection // reads the reference per step instead of deciding once at mount, which is also // what lets a toggle take effect without remounting the row. enabled: live(z.boolean().default(true)), gateContent: z.string().default(DEFAULT_GATE_CONTENT), interaction: z.union(['ask', 'auto', 'follow-approval']).default('follow-approval'), }) /** * Read the injection switch as a boolean, whichever shape this host produced. * @param config - the resolved plugin configuration. * @returns whether the gate may be injected. */ function injectionEnabled(config: Config): boolean { const value = config.enabled return typeof value === 'boolean' ? value : value.get() } function gateMessage(text: string): UserMessage { return createUserMessage({ content: [{ type: 'text', text }], // `form` omitted — an undeclared context is the documented default. source: { kind: GATE_SOURCE_KIND }, }) } interface SessionEventLike { type: string data?: Record } /** * Minimum session-store surface this plugin reads. DSH 0.1.2-alpha.4 replaced * the `Session.events` getter with on-demand reads (`seq`, `eventAt()`, * `snapshotEvents()`); releases up to 0.1.2-alpha.3 expose `events` as the * full log snapshot. Read through a structural union so one build serves every * declared DSH release. */ interface SessionEventSource { events?: readonly SessionEventLike[] snapshotEvents?: () => readonly SessionEventLike[] } function readSessionEvents(session: Session): readonly SessionEventLike[] { const source = session as unknown as SessionEventSource if (typeof source.snapshotEvents === 'function') { const snapshot = source.snapshotEvents() if (Array.isArray(snapshot)) return snapshot } if (Array.isArray(source.events)) return source.events return [] } function lastApprovalPolicy(session: Session): 'ask' | 'never' | undefined { const events = readSessionEvents(session) for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] if (event.type === 'approval/policy') { return event.data?.policy === 'never' ? 'never' : 'ask' } } return undefined } function planModeActive(session: Session): boolean { const events = readSessionEvents(session) for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] if (event.type === 'plan/mode') return event.data?.active === true } return false } function resolveInteraction(config: Config, session: Session): 'ask' | 'auto' { if (config.interaction === 'ask' || config.interaction === 'auto') return config.interaction return lastApprovalPolicy(session) === 'never' ? 'auto' : 'ask' } function modeContextText(config: Config, session: Session): string { const interaction = resolveInteraction(config, session) const lines = [ 'logicprobe: tools — `logicprobe_verify` (state machines), `logicprobe_datamodel_verify` (data models, migrations), `logicprobe_compose_verify` (two or more machines), `logicprobe_concurrency_scan` (finds concurrency claims, proves nothing), `logicprobe_export` (UPPAAL, TLA+, PRISM, SPIN), `logicprobe_uml` (render, parse, review UML), `logicprobe_structure_verify` (dependency diagrams, one or several levels), `logicprobe_report_diff` (baseline vs current).', 'logicprobe: gate on `verdict`, never on `ok`. Reports carry `verdictReason`, `hashes` and `nextSteps`.', interaction === 'auto' ? '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.' : 'logicprobe interaction=ask: show the extracted transition table and get user confirmation before running verification.', ] if (planModeActive(session)) { lines.push('Plan mode active: before exit_plan_mode, run logicprobe Phase 0 and append the "## Plan Verification" block to the plan file.') } return lines.join(' ') } interface ToolRegistryLike { register(definition: unknown): () => void } interface SystemPromptLike { context(contribution: PromptContext): () => void } /** * Model-visible catalog entry (cordis_inspect_list / cordis_inspect_query): * lets the model read this plugin's runtime status without guessing. Mirrors * the registration pattern of the official dsh-tool-cordis host providers. */ function inspectProvider(config: Config, isToolRegistered: () => boolean, isDataToolRegistered: () => boolean, isConcurrencyToolRegistered: () => boolean, isComposeToolRegistered: () => boolean, isExportToolRegistered: () => boolean, isUmlToolRegistered: () => boolean, isStructureToolRegistered: () => boolean, isReportDiffToolRegistered: () => boolean): HostCordisInspectProviderRegistration { return { manifest: { id: 'logicprobe', 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.', methods: [ { name: 'status', description: 'Read gate injection status, interaction mode, tool registration state, and engine schema version.', inputSchema: { type: 'object', properties: {}, additionalProperties: false, }, outputSchema: { type: 'object', description: 'Gate-injection plugin status.', properties: { enabled: { type: 'boolean', description: 'Whether the gate folds into the first model step.' }, gateContentLength: { type: 'integer', description: 'Length in characters of the injected gate text.' }, interaction: { type: 'string', enum: ['ask', 'auto', 'follow-approval'], description: 'Configured interaction mode. follow-approval resolves per session from approval/policy.' }, toolRegistered: { type: 'boolean', description: 'Whether the logicprobe_verify tool is registered on ctx.tools.' }, dataToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_datamodel_verify tool is registered on ctx.tools.' }, concurrencyToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_concurrency_scan tool is registered on ctx.tools.' }, composeToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_compose_verify tool is registered on ctx.tools.' }, exportToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_export tool is registered on ctx.tools.' }, umlToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_uml tool (UML modelling + modelling review) is registered on ctx.tools.' }, structureToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_structure_verify tool (structure-diagram / dependency review) is registered on ctx.tools.' }, reportDiffToolRegistered: { type: 'boolean', description: 'Whether the logicprobe_report_diff tool (baseline report comparison) is registered on ctx.tools.' }, engineSchemaVersion: { type: 'integer', description: 'Model schema version the bundled state-machine verification engine accepts.' }, dataEngineSchemaVersion: { type: 'integer', description: 'Model schema version the bundled data-model verification engine accepts.' }, }, required: ['enabled', 'gateContentLength', 'interaction', 'toolRegistered', 'dataToolRegistered', 'concurrencyToolRegistered', 'composeToolRegistered', 'exportToolRegistered', 'umlToolRegistered', 'structureToolRegistered', 'reportDiffToolRegistered', 'engineSchemaVersion', 'dataEngineSchemaVersion'], additionalProperties: false, }, }, ], }, query: async (method) => { if (method === 'status') { return { enabled: injectionEnabled(config), gateContentLength: config.gateContent.length, interaction: config.interaction, toolRegistered: isToolRegistered(), dataToolRegistered: isDataToolRegistered(), concurrencyToolRegistered: isConcurrencyToolRegistered(), composeToolRegistered: isComposeToolRegistered(), exportToolRegistered: isExportToolRegistered(), umlToolRegistered: isUmlToolRegistered(), structureToolRegistered: isStructureToolRegistered(), reportDiffToolRegistered: isReportDiffToolRegistered(), engineSchemaVersion: ENGINE_SCHEMA_VERSION, dataEngineSchemaVersion: DATA_ENGINE_SCHEMA_VERSION, } } return null }, } } export function apply(ctx: Context, config: Config): void { // Optional services are registered opportunistically; base-bundle rows can // mount after this row applies, so registration is retried on the first // agent/pre-step — by then the app is fully booted. let providerRegistered = false let toolRegistered = false let dataToolRegistered = false let concurrencyToolRegistered = false let composeToolRegistered = false let exportToolRegistered = false let umlToolRegistered = false let structureToolRegistered = false let reportDiffToolRegistered = false let modeContextRegistered = false const registerProvider = (): void => { if (providerRegistered) return const inspect = ctx.get('cordisInspect') if (inspect === undefined) return try { ctx.effect(() => inspect.register(inspectProvider(config, () => toolRegistered, () => dataToolRegistered, () => concurrencyToolRegistered, () => composeToolRegistered, () => exportToolRegistered, () => umlToolRegistered, () => structureToolRegistered, () => reportDiffToolRegistered)), 'logicprobe: inspect provider') providerRegistered = true } catch (err) { console.warn('[logicprobe] inspect provider registration failed', err) } } const registerTool = (): void => { if (toolRegistered) return const tools = ctx.get('tools') as ToolRegistryLike | undefined if (tools === undefined) return try { ctx.effect(() => tools.register(logicProbeVerifyTool), 'logicprobe: verify tool') ctx.effect(() => tools.register(logicProbeDataModelVerifyTool), 'logicprobe: data verify tool') ctx.effect(() => tools.register(logicProbeConcurrencyScanTool), 'logicprobe: concurrency scan tool') ctx.effect(() => tools.register(logicProbeComposeTool), 'logicprobe: compose tool') ctx.effect(() => tools.register(logicProbeExportTool), 'logicprobe: export tool') ctx.effect(() => tools.register(logicProbeUmlTool), 'logicprobe: uml tool') ctx.effect(() => tools.register(logicProbeStructureTool), 'logicprobe: structure tool') ctx.effect(() => tools.register(logicProbeReportDiffTool), 'logicprobe: report diff tool') toolRegistered = true dataToolRegistered = true concurrencyToolRegistered = true composeToolRegistered = true exportToolRegistered = true umlToolRegistered = true structureToolRegistered = true reportDiffToolRegistered = true } catch (err) { console.warn('[logicprobe] logicprobe_verify/logicprobe_datamodel_verify tool registration failed', err) } } const registerModeContext = (): void => { if (modeContextRegistered) return const systemPrompt = ctx.get('systemPrompt') as SystemPromptLike | undefined if (systemPrompt === undefined) return try { ctx.effect(() => systemPrompt.context({ name: 'logicprobe:mode', order: 118, text: (context: AssembleContext) => { const agent = (context as AssembleContext & { agent?: { session: Session } }).agent if (agent === undefined) return '' return modeContextText(config, agent.session) }, }), 'logicprobe: system prompt context') modeContextRegistered = true } catch (err) { console.warn('[logicprobe] system prompt context registration failed', err) } } const registerIntegrations = (): void => { registerProvider() registerTool() registerModeContext() } registerIntegrations() // Ship the bundled skill through the registry: reuse the standard // filesystem provider over this package's own `skills/` directory, so // catalog discovery, frontmatter parsing, and SKILL.md loading behave // exactly like project/user skills while the plugin stays self-contained. // Registration lands in the global registry layer (this row mounts at the // profile root), so every agent preset sees the skill. `registerProvider` // returns the effect disposer; its teardown unregisters and invalidates. ctx.skills.registerProvider((control) => { return new FileSystemSkillProvider(ctx, control, { providerName: 'logicprobe', includeDefaultRoots: false, customSkillDirs: [SKILLS_DIR], }) }) // Injection listens unconditionally, including while the switch is off: the // switch is volatile, so `apply` runs once and the value behind it can turn on // later from the Web Plugins page. Returning early on a false value here would // freeze that decision for the lifetime of the mount, and turning the switch // back on could never take effect without a profile restart. // // Inject the gate once per session on the FIRST model step that runs, // instead of at session-start: session-start injection lands in the agent's // inbox, which a blank-session preset switch (agentPreset.select -> // recompose) can clear before the first step - the gate would then be lost // for the whole session. The pre-step decision is the durable path a // first-step injection takes: the gate is appended to the first step's // decision and enters session history there, so every later step (and a // resume) skips it. Anchored/bootstrap presets that strip first-step // injected reminders (skill catalog, AGENTS.md, gate plugins) simply defer // this message to the first step after their promotion - the history guard // re-injects it there, so the gate still lands exactly once per session. ctx.on('agent/pre-step', async ({ agent }, next) => { const decision = await next() if (decision.kind === 'reject') return decision registerIntegrations() if (!injectionEnabled(config)) return decision if (gateInHistory(agent.session)) return decision return { kind: 'enter', messages: [...decision.messages, gateMessage(config.gateContent)], } }) } /** * Whether the gate already entered this session's durable history. The * pre-step listener re-appends the gate until it does; once a step committed * it, every later step (and a resume of a session that kept it) skips the * injection. A session whose gate was dropped before any step ran (e.g. an * inbox cleared by a blank-session preset switch) simply re-injects on the * first step that runs. */ function gateInHistory(session: Session): boolean { return readSessionEvents(session).some((event) => { if (event.type !== 'user/message') return false const source = event.data?.source as { kind?: string; plugin?: string } | undefined if (source === undefined) return false // The v4 producer-owned kind, plus the pre-v4 wrapper this bundle wrote // before DSH 0.1.7-alpha.1 retired it. return source.kind === GATE_SOURCE_KIND || (source.kind === 'plugin' && source.plugin === GATE_PLUGIN_ID) }) }