dsh-doublecheck 0.5.0 → 0.7.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.
@@ -0,0 +1,518 @@
1
+ /**
2
+ * The delivery gate runner: orchestrates the four-phase quality checklist
3
+ * over one session's durable evidence and settles the deliverable/rework
4
+ * decision.
5
+ *
6
+ * - **requirements** + **tests** — deterministic folds of the session log
7
+ * (no model calls): the key-question checklist against the committed
8
+ * spec, and the test-run/coverage evidence.
9
+ * - **consistency** — a local forked reviewer audits the diff ↔ requirement
10
+ * mapping (every edit must serve a spec dimension).
11
+ * - **review** — the delivery conclusion. With `engine: auto`, the
12
+ * dsh-auto-review engine's durable verdict records (`autoReview/verdict` /
13
+ * `autoReview/rejection`) are the evidence; when the engine is not
14
+ * installed or has no records this session, the phase degrades to the
15
+ * local forked reviewer and says so. `engine: local` always uses the
16
+ * local reviewer. The gate never synthesizes approval requests: that
17
+ * chain may reach a human.
18
+ *
19
+ * Everything a run settles is audit-safe: counts, ids, verdicts, and
20
+ * redacted findings only. The settled state rides the durable
21
+ * `doublecheck/gate` session event (replay IS the state), the workspace
22
+ * `gate-report.md`, and the `/gate run` command result.
23
+ *
24
+ * @module dsh-doublecheck/guard/gate
25
+ */
26
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
27
+ import z from '@deepseek-ai/schemastery';
28
+ import { countRedChecks, DEFAULT_COVERAGE_PATTERN, DEFAULT_GATE_QUESTIONS, deriveGateVerdict, evaluateConsistency, evaluateRequirements, evaluateReview, evaluateTests, foldAutoReviewEvidence, foldRequirementsEvidence, foldTestEvidence, GATE_FINDINGS_SCHEMA, renderGateReportMarkdown, renderPhaseMarkdown, skippedPhase, } from "../domain/gate.js";
29
+ import { sortFindings } from "./review.js";
30
+ import { PROSE } from "./prose.js";
31
+ /** The default gate configuration: every phase on, the six key questions required. */
32
+ export const DEFAULT_GATE_CONFIG = {
33
+ enabled: true,
34
+ planSuggestion: true,
35
+ reportFile: 'gate-report.md',
36
+ requirements: {
37
+ enabled: true,
38
+ checklist: [...DEFAULT_GATE_QUESTIONS],
39
+ minConfirmed: 6,
40
+ interrogateTool: 'ask_user_question',
41
+ },
42
+ tests: {
43
+ enabled: true,
44
+ requirePassingRun: true,
45
+ allowFailingRuns: 0,
46
+ requireCoverage: false,
47
+ minCoveragePct: 80,
48
+ coveragePattern: DEFAULT_COVERAGE_PATTERN,
49
+ },
50
+ consistency: {
51
+ enabled: true,
52
+ provider: 'fork',
53
+ model: null,
54
+ tools: ['read', 'glob', 'grep'],
55
+ timeoutMs: 120000,
56
+ maxFindings: 5,
57
+ },
58
+ review: {
59
+ enabled: true,
60
+ engine: 'auto',
61
+ provider: 'fork',
62
+ model: null,
63
+ tools: ['read', 'glob', 'grep'],
64
+ timeoutMs: 120000,
65
+ maxFindings: 5,
66
+ },
67
+ };
68
+ /** The delivery-gate configuration schema (the pluggable checklist + knobs). */
69
+ export const GateConfigSchema = z.object({
70
+ enabled: z.boolean().default(true),
71
+ planSuggestion: z.boolean().default(true),
72
+ reportFile: z.string().min(1).default('gate-report.md'),
73
+ requirements: z.object({
74
+ enabled: z.boolean().default(true),
75
+ checklist: z.array(z.object({
76
+ id: z.string().min(1),
77
+ question: z.string().min(1),
78
+ specDimension: z.union([
79
+ z.union(['goal', 'scope', 'acceptanceCriteria', 'failureModes', 'priorities', 'nonGoals']),
80
+ z.const(null),
81
+ ]).default(null),
82
+ required: z.boolean().default(true),
83
+ })).default([...DEFAULT_GATE_QUESTIONS]),
84
+ minConfirmed: z.number().default(6),
85
+ interrogateTool: z.string().min(1).default('ask_user_question'),
86
+ }).default({ ...DEFAULT_GATE_CONFIG.requirements, checklist: [...DEFAULT_GATE_CONFIG.requirements.checklist] }),
87
+ tests: z.object({
88
+ enabled: z.boolean().default(true),
89
+ requirePassingRun: z.boolean().default(true),
90
+ allowFailingRuns: z.number().default(0),
91
+ requireCoverage: z.boolean().default(false),
92
+ minCoveragePct: z.number().default(80),
93
+ coveragePattern: z.string().min(1).default(DEFAULT_COVERAGE_PATTERN),
94
+ }).default({ ...DEFAULT_GATE_CONFIG.tests }),
95
+ consistency: z.object({
96
+ enabled: z.boolean().default(true),
97
+ provider: z.string().min(1).default('fork'),
98
+ model: z.union([z.string(), z.const(null)]).default(null),
99
+ tools: z.array(z.string()).default(['read', 'glob', 'grep']),
100
+ timeoutMs: z.number().default(120000),
101
+ maxFindings: z.number().default(5),
102
+ }).default({ ...DEFAULT_GATE_CONFIG.consistency }),
103
+ review: z.object({
104
+ enabled: z.boolean().default(true),
105
+ engine: z.union(['auto', 'local']).default('auto'),
106
+ provider: z.string().min(1).default('fork'),
107
+ model: z.union([z.string(), z.const(null)]).default(null),
108
+ tools: z.array(z.string()).default(['read', 'glob', 'grep']),
109
+ timeoutMs: z.number().default(120000),
110
+ maxFindings: z.number().default(5),
111
+ }).default({ ...DEFAULT_GATE_CONFIG.review }),
112
+ }).default({
113
+ ...DEFAULT_GATE_CONFIG,
114
+ requirements: { ...DEFAULT_GATE_CONFIG.requirements, checklist: [...DEFAULT_GATE_CONFIG.requirements.checklist] },
115
+ tests: { ...DEFAULT_GATE_CONFIG.tests },
116
+ consistency: { ...DEFAULT_GATE_CONFIG.consistency },
117
+ review: { ...DEFAULT_GATE_CONFIG.review },
118
+ });
119
+ /** The consistency reviewer's task (role statement first, kept short). */
120
+ const CONSISTENCY_TASK_EN = 'You are the implementation-consistency checker for this delivery gate. '
121
+ + 'Map the implementation edits of the session you inherited to the committed '
122
+ + 'doublecheck_spec dimensions: every changed file must serve a requirement, '
123
+ + 'and every spec dimension with edits must have evidence. Answer through the '
124
+ + 'required structured output: blocker/major objections for edits unmapped to '
125
+ + 'any requirement or covered dimensions without evidence; an empty findings '
126
+ + 'list when the mapping is complete. Do not invent objections.';
127
+ /** The consistency reviewer's task, zh. */
128
+ const CONSISTENCY_TASK_ZH = '你是本次交付门禁的实现一致性检查员。把你继承的会话中的实现改动映射到已提交的 '
129
+ + 'doublecheck_spec 维度:每个改动的文件都必须服务于某条需求,每个有改动的 spec 维度'
130
+ + '都必须有证据。通过要求的结构化输出作答:改动无法映射到任何需求、或有改动的维度缺乏'
131
+ + '证据时给出 blocker/major 反对意见;映射完整时返回空 findings 列表。不要编造反对意见。';
132
+ /**
133
+ * Validate the gate configuration fail-loud: checklist ids unique and
134
+ * non-empty, valid spec-dimension bindings, bounded thresholds, compilable
135
+ * coverage regex, and sound reviewer knobs. A bad gate config must not
136
+ * silently produce an uncheckable checklist.
137
+ * @param gate - the gate config block.
138
+ */
139
+ export function validateGateConfig(gate) {
140
+ if (gate.requirements.checklist.length === 0) {
141
+ throw new Error('dsh-doublecheck: gate.requirements.checklist must not be empty');
142
+ }
143
+ const seen = new Set();
144
+ for (const question of gate.requirements.checklist) {
145
+ if (question.id.trim() === '')
146
+ throw new Error('dsh-doublecheck: gate checklist ids must not be empty');
147
+ if (seen.has(question.id))
148
+ throw new Error(`dsh-doublecheck: duplicate gate checklist id "${question.id}"`);
149
+ seen.add(question.id);
150
+ if (question.specDimension !== null) {
151
+ const dimensions = ['goal', 'scope', 'acceptanceCriteria', 'failureModes', 'priorities', 'nonGoals'];
152
+ if (!dimensions.includes(question.specDimension)) {
153
+ throw new Error(`dsh-doublecheck: gate checklist id "${question.id}" binds unknown spec dimension "${String(question.specDimension)}"`);
154
+ }
155
+ }
156
+ }
157
+ const required = gate.requirements.checklist.filter(question => question.required).length;
158
+ if (!Number.isInteger(gate.requirements.minConfirmed) || gate.requirements.minConfirmed < 1) {
159
+ throw new Error('dsh-doublecheck: gate.requirements.minConfirmed must be an integer >= 1');
160
+ }
161
+ if (gate.requirements.minConfirmed > required) {
162
+ throw new Error(`dsh-doublecheck: gate.requirements.minConfirmed (${gate.requirements.minConfirmed}) exceeds the ${required} required questions`);
163
+ }
164
+ if (gate.requirements.interrogateTool.trim() === '') {
165
+ throw new Error('dsh-doublecheck: gate.requirements.interrogateTool must not be empty');
166
+ }
167
+ if (!Number.isInteger(gate.tests.allowFailingRuns) || gate.tests.allowFailingRuns < 0) {
168
+ throw new Error('dsh-doublecheck: gate.tests.allowFailingRuns must be an integer >= 0');
169
+ }
170
+ if (gate.tests.minCoveragePct < 0 || gate.tests.minCoveragePct > 100) {
171
+ throw new Error('dsh-doublecheck: gate.tests.minCoveragePct must be within 0..100');
172
+ }
173
+ try {
174
+ new RegExp(gate.tests.coveragePattern, 'i');
175
+ }
176
+ catch {
177
+ throw new Error(`dsh-doublecheck: invalid gate.tests.coveragePattern "${gate.tests.coveragePattern}"`);
178
+ }
179
+ for (const [label, reviewer] of [['consistency', gate.consistency], ['review', gate.review]]) {
180
+ if (!Number.isInteger(reviewer.maxFindings) || reviewer.maxFindings < 1 || reviewer.maxFindings > 20) {
181
+ throw new Error(`dsh-doublecheck: gate.${label}.maxFindings must be within 1..20`);
182
+ }
183
+ if (!Number.isInteger(reviewer.timeoutMs) || reviewer.timeoutMs < 1) {
184
+ throw new Error(`dsh-doublecheck: gate.${label}.timeoutMs must be an integer >= 1`);
185
+ }
186
+ assertNonEmptyNames(`gate.${label}.tools`, reviewer.tools);
187
+ if (reviewer.provider.trim() === '') {
188
+ throw new Error(`dsh-doublecheck: gate.${label}.provider must not be empty`);
189
+ }
190
+ }
191
+ }
192
+ /** Validate a name list fail-loud: non-empty, non-empty names, no duplicates. */
193
+ function assertNonEmptyNames(field, names) {
194
+ if (names.length === 0)
195
+ throw new Error(`dsh-doublecheck: ${field} must not be empty`);
196
+ for (const name of names) {
197
+ if (name.length === 0)
198
+ throw new Error(`dsh-doublecheck: ${field} must not contain empty names`);
199
+ }
200
+ if (new Set(names).size !== names.length)
201
+ throw new Error(`dsh-doublecheck: ${field} must not contain duplicates`);
202
+ }
203
+ /**
204
+ * Run one local gate reviewer through the subagents seam and settle it to
205
+ * structured findings. A missing seam, a rejected start, a non-completed
206
+ * run, or a model failure settles as `findings: null` with an honest reason
207
+ * — a broken reviewer never throws into the gate.
208
+ * @param ctx - plugin context carrying the subagents seam.
209
+ * @param reviewer - the reviewer knobs.
210
+ * @param agent - the reviewed agent; its session seeds the fork.
211
+ * @param task - the reviewer's task prompt.
212
+ * @param label - the subagent run label.
213
+ * @param signal - the caller's cancellation signal.
214
+ * @returns the settled outcome.
215
+ */
216
+ async function runLocalReviewer(ctx, reviewer, agent, task, label, signal) {
217
+ const subagents = ctx.get('subagents');
218
+ if (subagents === undefined) {
219
+ ctx.logger.warn(`dsh-doublecheck: ${label} skipped: the ctx.subagents seam is not mounted`);
220
+ return { findings: null, unavailable: 'the subagents seam is not mounted' };
221
+ }
222
+ const timeout = AbortSignal.timeout(reviewer.timeoutMs);
223
+ const runSignal = AbortSignal.any([signal, timeout]);
224
+ let run;
225
+ try {
226
+ run = await subagents.start(reviewer.provider, {
227
+ label,
228
+ prompt: [{ type: 'text', text: task }],
229
+ parent: agent,
230
+ signal: runSignal,
231
+ ...reviewer.model !== null ? { agentOptions: { model: reviewer.model } } : {},
232
+ outputSchema: GATE_FINDINGS_SCHEMA,
233
+ toolFilter: { allow: reviewer.tools },
234
+ });
235
+ }
236
+ catch (error) {
237
+ return { findings: null, unavailable: String(error) };
238
+ }
239
+ try {
240
+ let result;
241
+ try {
242
+ result = await run.result;
243
+ }
244
+ catch (error) {
245
+ if (runSignal.aborted) {
246
+ return { findings: null, unavailable: signal.aborted ? 'aborted' : 'timed out' };
247
+ }
248
+ return { findings: null, unavailable: String(error) };
249
+ }
250
+ if (result.stopReason !== 'completed') {
251
+ return { findings: null, unavailable: `stopped (${result.stopReason})` };
252
+ }
253
+ const structured = result.structured;
254
+ const raw = structured?.findings;
255
+ if (!Array.isArray(raw)) {
256
+ return { findings: null, unavailable: 'the reviewer returned no structured findings' };
257
+ }
258
+ return { findings: sortFindings(raw).slice(0, reviewer.maxFindings), unavailable: '' };
259
+ }
260
+ finally {
261
+ await run.dispose();
262
+ }
263
+ }
264
+ /**
265
+ * Whether the dsh-auto-review row appears to be installed. Two weak probes:
266
+ * its `/auto-review` command on the shared commands registry, and its
267
+ * durable `autoReview/*` session events (its own switch writes
268
+ * `autoReview/state` on first use). Best-effort — the durable verdict
269
+ * records remain the actual evidence, this only shapes the degrade note.
270
+ * @param ctx - plugin context carrying the commands service.
271
+ * @param agent - the receiving agent.
272
+ * @returns true when an `auto-review` command or an `autoReview/*` event exists.
273
+ */
274
+ function autoReviewInstalled(ctx, agent) {
275
+ const commands = ctx.get('commands');
276
+ if (commands?.list !== undefined) {
277
+ try {
278
+ if (commands.list(agent).some(descriptor => descriptor.name === 'auto-review'))
279
+ return true;
280
+ }
281
+ catch {
282
+ // Fall through to the durable-event probe.
283
+ }
284
+ }
285
+ return agent.session.events.some(event => event.type.startsWith('autoReview/'));
286
+ }
287
+ /**
288
+ * Run the full delivery gate over the agent's session and settle the state.
289
+ * The deterministic phases fold first; the two reviewer phases run in
290
+ * parallel. Failures degrade to honest skip notices, never to throws.
291
+ * @param ctx - plugin context (subagents, commands, fs seams).
292
+ * @param config - the gate configuration.
293
+ * @param detection - the compiled test-run detection knobs.
294
+ * @param agent - the gated agent.
295
+ * @param signal - the caller's cancellation signal.
296
+ * @param language - prose language for the reviewer tasks.
297
+ * @returns the settled gate state.
298
+ */
299
+ export async function runGate(ctx, config, detection, agent, signal, language) {
300
+ const events = agent.session.events;
301
+ const coverageRegex = new RegExp(config.tests.coveragePattern, 'i');
302
+ const requirements = config.requirements.enabled
303
+ ? evaluateRequirements(foldRequirementsEvidence(events, config.requirements.interrogateTool), { checklist: config.requirements.checklist, minConfirmed: config.requirements.minConfirmed })
304
+ : skippedPhase('requirements');
305
+ const tests = config.tests.enabled
306
+ ? evaluateTests(foldTestEvidence(events, detection, coverageRegex), config.tests)
307
+ : skippedPhase('tests');
308
+ const prose = PROSE[language];
309
+ const consistencyTask = language === 'zh' ? CONSISTENCY_TASK_ZH : CONSISTENCY_TASK_EN;
310
+ const consistencyPromise = config.consistency.enabled
311
+ ? runLocalReviewer(ctx, config.consistency, agent, consistencyTask, 'doublecheck-gate-consistency', signal)
312
+ : Promise.resolve({ findings: null, unavailable: 'disabled' });
313
+ const localReviewPromise = config.review.enabled && config.review.engine === 'local'
314
+ ? runLocalReviewer(ctx, config.review, agent, prose.criticTask, 'doublecheck-gate-review', signal)
315
+ : Promise.resolve({ findings: null, unavailable: 'disabled' });
316
+ const [consistencyOutcome, localReviewOutcome] = await Promise.all([consistencyPromise, localReviewPromise]);
317
+ const consistency = config.consistency.enabled
318
+ ? evaluateConsistency(consistencyOutcome.findings, consistencyOutcome.unavailable)
319
+ : skippedPhase('consistency');
320
+ let reviewResult;
321
+ if (!config.review.enabled) {
322
+ reviewResult = { result: skippedPhase('review'), engine: null };
323
+ }
324
+ else if (config.review.engine === 'auto') {
325
+ // Weak dependency: the engine's durable verdict records are the evidence.
326
+ // No records (not installed, or nothing triggered it this session) → the
327
+ // local reviewer takes over, and an honest warn check names the degrade.
328
+ const engine = foldAutoReviewEvidence(events);
329
+ if (engine !== null) {
330
+ reviewResult = evaluateReview(engine, null, '', 'auto');
331
+ }
332
+ else {
333
+ const degradeNote = autoReviewInstalled(ctx, agent)
334
+ ? 'dsh-auto-review is installed but has no verdict records in this session'
335
+ : 'dsh-auto-review is not installed';
336
+ const local = await runLocalReviewer(ctx, config.review, agent, prose.criticTask, 'doublecheck-gate-review', signal);
337
+ const unavailable = local.findings === null
338
+ ? `${degradeNote}; ${local.unavailable}`
339
+ : local.unavailable;
340
+ reviewResult = evaluateReview(engine, local.findings, unavailable, 'auto', degradeNote);
341
+ }
342
+ }
343
+ else {
344
+ reviewResult = evaluateReview(null, localReviewOutcome.findings, localReviewOutcome.unavailable, 'local');
345
+ }
346
+ const phases = { requirements, tests, consistency, review: reviewResult.result };
347
+ return {
348
+ verdict: deriveGateVerdict(Object.values(phases)),
349
+ phases,
350
+ reviewEngine: reviewResult.engine,
351
+ at: new Date().toISOString(),
352
+ };
353
+ }
354
+ /**
355
+ * Settle one gate run's side effects: the durable `doublecheck/gate` session
356
+ * event (when the host stamps `ignorable`), the observability announcement,
357
+ * and the workspace `gate-report.md` copy. Failures of the file write never
358
+ * fail the run — the command result and the durable event remain.
359
+ * @param ctx - plugin context.
360
+ * @param config - the gate configuration.
361
+ * @param state - the settled gate state.
362
+ * @param agent - the gated agent.
363
+ * @param signal - the caller's cancellation signal.
364
+ * @param stampsIgnorable - whether the host stamps the `ignorable` marker.
365
+ */
366
+ export async function settleGate(ctx, config, state, agent, signal, stampsIgnorable) {
367
+ if (stampsIgnorable) {
368
+ ;
369
+ agent.session.append('doublecheck/gate', state, { ignorable: true });
370
+ }
371
+ ctx.emit('doublecheck/gate', { session: agent.session, state });
372
+ await writeGateFile(ctx, config.reportFile, state, agent, signal);
373
+ }
374
+ /** Write the gate report markdown through the filesystem seam, when one exists. */
375
+ async function writeGateFile(ctx, filePath, state, agent, signal) {
376
+ const fs = ctx.get('fs');
377
+ if (fs === undefined)
378
+ return;
379
+ let target;
380
+ try {
381
+ target = await fs.resolve(filePath, {
382
+ ...agent.session.header.cwd !== undefined ? { cwd: agent.session.header.cwd } : {},
383
+ signal,
384
+ });
385
+ await fs.writeText(target, renderGateReportMarkdown(state, true), undefined, signal);
386
+ }
387
+ catch (error) {
388
+ signal.throwIfAborted();
389
+ ctx.logger.debug(`dsh-doublecheck: gate report file write skipped: ${String(error)}`);
390
+ }
391
+ }
392
+ /** The latest `doublecheck/gate` record of a session, or null before any run. */
393
+ function latestGateRecord(events) {
394
+ for (let index = events.length - 1; index >= 0; index -= 1) {
395
+ const event = events[index];
396
+ if (event?.type === 'doublecheck/gate')
397
+ return event.data;
398
+ }
399
+ return null;
400
+ }
401
+ /** A pending placeholder phase for the status panel (no gate run on record yet). */
402
+ function pendingPhase(phase) {
403
+ return {
404
+ phase,
405
+ enabled: true,
406
+ status: 'pending',
407
+ checks: [{
408
+ id: 'pending',
409
+ phase,
410
+ label: 'not run yet',
411
+ status: 'pending',
412
+ summary: 'run /gate run to settle the reviewer phases',
413
+ suggestion: '',
414
+ }],
415
+ };
416
+ }
417
+ /**
418
+ * The `/gate` command handler: `status` renders the live checklist progress
419
+ * plus the latest settled run; `run` settles the full gate and returns the
420
+ * PR-ready markdown report; `config` renders the effective checklist.
421
+ * @param deps - the guard closures the handler reads.
422
+ * @returns the command handler for `ctx.commands.register`.
423
+ */
424
+ export function gateHandler(deps) {
425
+ const { config, detection, prose, runGate, settleGate } = deps;
426
+ return (invocation) => {
427
+ const agent = invocation.agent;
428
+ if (agent === undefined)
429
+ return { kind: 'error', text: prose.gateCommandNoAgent };
430
+ const session = agent.session;
431
+ const input = invocation.rawInput.trim().toLowerCase();
432
+ if (input === 'run') {
433
+ return (async () => {
434
+ try {
435
+ const state = await runGate(agent, invocation.signal);
436
+ await settleGate(state, agent, invocation.signal);
437
+ return { kind: 'success', text: renderGateReportMarkdown(state, config.planSuggestion) };
438
+ }
439
+ catch (error) {
440
+ return { kind: 'error', text: `gate run failed: ${String(error)}` };
441
+ }
442
+ })();
443
+ }
444
+ if (input === 'config') {
445
+ return { kind: 'success', text: `${prose.gateConfigHeader}\n\n${renderGateConfigMarkdown(config)}` };
446
+ }
447
+ if (input !== 'status' && input !== '') {
448
+ return { kind: 'error', text: prose.gateCommandUnknown(invocation.rawInput.trim()) };
449
+ }
450
+ const events = session.events;
451
+ const coverageRegex = new RegExp(config.tests.coveragePattern, 'i');
452
+ const requirements = config.requirements.enabled
453
+ ? evaluateRequirements(foldRequirementsEvidence(events, config.requirements.interrogateTool), { checklist: config.requirements.checklist, minConfirmed: config.requirements.minConfirmed })
454
+ : skippedPhase('requirements');
455
+ const tests = config.tests.enabled
456
+ ? evaluateTests(foldTestEvidence(events, detection, coverageRegex), config.tests)
457
+ : skippedPhase('tests');
458
+ const record = latestGateRecord(events);
459
+ const planActive = deps.planMode === undefined ? undefined : deps.planMode.get(agent)?.active;
460
+ const lines = [
461
+ prose.gateStatusHeader,
462
+ '',
463
+ `- plan mode: ${planActive === undefined ? 'unknown' : planActive ? 'active' : 'inactive'}${record !== null && record.verdict === 'rework' && planActive === false ? ' — a red gate suggests re-opening in plan mode' : ''}`,
464
+ '',
465
+ '## Live checklist',
466
+ ];
467
+ lines.push(...renderPhaseMarkdown(requirements), '', ...renderPhaseMarkdown(tests), '');
468
+ if (record === null) {
469
+ lines.push('## Latest gate run — none on record', '', ...renderPhaseMarkdown(pendingPhase('consistency')), '', ...renderPhaseMarkdown(pendingPhase('review')), '', 'No full gate run on record yet; `/gate run` settles the verdict.', '');
470
+ }
471
+ else {
472
+ const redCount = countRedChecks(Object.values(record.phases));
473
+ lines.push(`## Latest gate run — ${record.at} (verdict: ${record.verdict}, ${redCount} red item(s), engine: ${record.reviewEngine ?? 'not run'})`, '', ...renderPhaseMarkdown(record.phases.consistency), '', ...renderPhaseMarkdown(record.phases.review), '');
474
+ }
475
+ lines.push(prose.gateStatusHint, '');
476
+ return { kind: 'success', text: lines.join('\n') };
477
+ };
478
+ }
479
+ /**
480
+ * The injected gate-red notice message: short model-facing prose (role
481
+ * statement first) with the structured verdict and red count riding the
482
+ * durable `doublecheck-gate` message source, so the durable once-semantics
483
+ * and the report folds read the record without re-parsing the prose.
484
+ * @param verdict - the settled verdict.
485
+ * @param redCount - the red-item count.
486
+ * @param text - the model-facing notice text.
487
+ * @returns the injection-ready user message.
488
+ */
489
+ export function gateInjection(verdict, redCount, text) {
490
+ const source = { kind: 'doublecheck-gate', verdict, redCount };
491
+ return createUserMessage({ content: [{ type: 'text', text }], source });
492
+ }
493
+ /**
494
+ * Render the effective gate configuration as markdown — the `/gate config`
495
+ * output: the pluggable checklist, the thresholds, and the reviewer knobs.
496
+ * Configuration contains no secrets by construction; nothing is redacted.
497
+ * @param config - the gate configuration.
498
+ * @returns the configuration document.
499
+ */
500
+ export function renderGateConfigMarkdown(config) {
501
+ const lines = [
502
+ '# Delivery gate configuration',
503
+ '',
504
+ `- enabled: ${config.enabled}`,
505
+ `- plan mode suggestion on red: ${config.planSuggestion}`,
506
+ `- report file: ${config.reportFile}`,
507
+ '',
508
+ '## Requirements interrogation',
509
+ '',
510
+ '| # | id | key question | spec dimension | required |',
511
+ '|---|---|---|---|---|',
512
+ ];
513
+ config.requirements.checklist.forEach((question, index) => {
514
+ lines.push(`| ${index + 1} | ${question.id} | ${question.question} | ${question.specDimension ?? '—'} | ${question.required ? 'yes' : 'no'} |`);
515
+ });
516
+ lines.push('', `- minimum confirmed: ${config.requirements.minConfirmed}`, `- interrogation tool: ${config.requirements.interrogateTool}`, '', '## Test evidence', '', `- require passing run: ${config.tests.requirePassingRun}`, `- allowed failing runs after green: ${config.tests.allowFailingRuns}`, `- require coverage: ${config.tests.requireCoverage}`, `- minimum coverage: ${config.tests.minCoveragePct}%`, `- coverage pattern: \`${config.tests.coveragePattern}\``, '', '## Implementation consistency', '', `- enabled: ${config.consistency.enabled}`, `- reviewer provider: ${config.consistency.provider}`, `- max findings: ${config.consistency.maxFindings}`, `- tools: ${config.consistency.tools.join(', ')}`, '', '## Review conclusion', '', `- enabled: ${config.review.enabled}`, `- engine: ${config.review.engine} (auto = dsh-auto-review verdict records when present, else the local reviewer)`, `- reviewer provider: ${config.review.provider}`, `- max findings: ${config.review.maxFindings}`, `- tools: ${config.review.tools.join(', ')}`, '');
517
+ return lines.join('\n');
518
+ }
@@ -32,6 +32,8 @@ import type Schema from '@deepseek-ai/schemastery';
32
32
  import { type DisciplineState } from '../domain/stages.ts';
33
33
  import type { GuardIntensity } from '../events.ts';
34
34
  import { type ProseLanguage } from './prose.ts';
35
+ import { type GateConfig } from './gate.ts';
36
+ import { type GateVerdict } from '../domain/gate.ts';
35
37
  export declare const name = "doublecheck-guard";
36
38
  export declare const inject: string[];
37
39
  /**
@@ -74,6 +76,8 @@ export interface Config {
74
76
  testCommandPatterns: string[];
75
77
  /** Regexes identifying test-file paths, exempt from the red gate. */
76
78
  testFilePatterns: string[];
79
+ /** The delivery quality gate: the configurable checklist and the panel. */
80
+ gate: GateConfig;
77
81
  }
78
82
  export declare const Config: Schema<Config>;
79
83
  /** Cached per-session guard facts, folded incrementally from the append-only log. */
@@ -102,6 +106,16 @@ export interface Snapshot {
102
106
  lastReviewSeq: number;
103
107
  /** Implementation edits folded after {@link lastReviewSeq}. */
104
108
  editsAfterReview: number;
109
+ /** The latest durable `doublecheck/gate` run, or null before any. */
110
+ lastGate: {
111
+ seq: number;
112
+ verdict: GateVerdict;
113
+ redCount: number;
114
+ } | null;
115
+ /** A gate-red notice is already on record in the log (durable `remindOnce`). */
116
+ gateRedReminded: boolean;
117
+ /** The last durable `doublecheck/state` switch on record, when one exists. */
118
+ stateEnabled?: boolean;
105
119
  }
106
120
  /**
107
121
  * Install the guard listeners.