dsh-doublecheck 0.6.0 → 0.7.1

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,658 @@
1
+ /**
2
+ * The delivery gate domain: the configurable quality-gate checklist that
3
+ * turns one session's durable evidence into a deliverable/rework decision.
4
+ *
5
+ * Four phases compose the checklist:
6
+ *
7
+ * - **requirements** — a key-question checklist confirmed item by item
8
+ * against the committed spec (deterministic fold, no model calls).
9
+ * - **tests** — test evidence from the session log: the latest run color,
10
+ * failing runs after green, and (optionally) a coverage percentage parsed
11
+ * from the test output.
12
+ * - **consistency** — diff ↔ requirement mapping, audited by a local forked
13
+ * reviewer (orchestrated by the guard row; this module owns the result
14
+ * shape and the status fold).
15
+ * - **review** — the delivery conclusion: the dsh-auto-review engine's
16
+ * durable verdict records when they exist (`engine: auto`), degraded to
17
+ * the local reviewer when the engine is not installed or has not run.
18
+ *
19
+ * Everything here is pure: the durable session log in, structured gate facts
20
+ * out. Check summaries and suggestions are stable English audit ids (the
21
+ * workspace documents keep English headings by package policy); the guard
22
+ * row localizes the surrounding command chrome. Reports are audit-safe by
23
+ * construction — counts, ids, and verdicts only — and {@link redactSecrets}
24
+ * scrubs the model-produced finding texts before they are stored or shown.
25
+ *
26
+ * @module dsh-doublecheck/domain/gate
27
+ */
28
+ import { isTestCommand, joinTextBlocks, parseRawArguments, shellCommand, testOutcome, } from "./evidence.js";
29
+ import { SPEC_TOOL_NAME } from "./stages.js";
30
+ import { SPEC_FIELD_NAMES } from "./vocabulary.js";
31
+ /** All gate phases in checklist order. */
32
+ export const GATE_PHASES = ['requirements', 'tests', 'consistency', 'review'];
33
+ /** The default key-question checklist: one question per spec dimension. */
34
+ export const DEFAULT_GATE_QUESTIONS = [
35
+ { id: 'goal', question: 'What outcome must the delivery produce?', specDimension: 'goal', required: true },
36
+ { id: 'scope', question: 'What is in scope, and what is out of scope?', specDimension: 'scope', required: true },
37
+ { id: 'acceptance', question: 'Which observable checks prove the work is done?', specDimension: 'acceptanceCriteria', required: true },
38
+ { id: 'failureModes', question: 'What can go wrong, and what is the correct behavior in each case?', specDimension: 'failureModes', required: true },
39
+ { id: 'priorities', question: 'What is traded when goals conflict; what is optional?', specDimension: 'priorities', required: true },
40
+ { id: 'nonGoals', question: 'What does the user explicitly not want?', specDimension: 'nonGoals', required: true },
41
+ ];
42
+ /** Default regex parsing a coverage percentage from test output (one capture group, case-insensitive). */
43
+ export const DEFAULT_COVERAGE_PATTERN = 'coverage[^\\d]{0,40}(\\d+(?:\\.\\d+)?)\\s*%';
44
+ /**
45
+ * Fold the requirements evidence: the latest committed spec (a successful
46
+ * `doublecheck_spec` pair, from the call's own arguments) and the count of
47
+ * interrogation tool calls on record.
48
+ * @param events - the session's append-only event log.
49
+ * @param interrogateTool - the tool name that counts as user interrogation.
50
+ * @returns the folded evidence.
51
+ */
52
+ export function foldRequirementsEvidence(events, interrogateTool) {
53
+ let spec = null;
54
+ let interrogations = 0;
55
+ const pendingSpecs = new Map();
56
+ for (const event of events) {
57
+ switch (event.type) {
58
+ case 'tool/call': {
59
+ if (event.data.name === interrogateTool)
60
+ interrogations += 1;
61
+ if (event.data.name !== SPEC_TOOL_NAME)
62
+ break;
63
+ const args = parseRawArguments(event.data.arguments);
64
+ const candidate = readSpecFromArgs(args);
65
+ if (candidate !== null)
66
+ pendingSpecs.set(event.data.callId, candidate);
67
+ break;
68
+ }
69
+ case 'tool/result': {
70
+ const callId = event.data.message.source.callId;
71
+ const candidate = pendingSpecs.get(callId);
72
+ if (candidate === undefined)
73
+ break;
74
+ pendingSpecs.delete(callId);
75
+ if (event.data.error === undefined)
76
+ spec = candidate;
77
+ break;
78
+ }
79
+ }
80
+ }
81
+ return { spec, interrogations };
82
+ }
83
+ /** Read the six spec fields from a spec tool call's arguments, or null when malformed. */
84
+ function readSpecFromArgs(args) {
85
+ if (args === undefined)
86
+ return null;
87
+ const fields = args;
88
+ for (const field of SPEC_FIELD_NAMES) {
89
+ if (typeof fields[field] !== 'string')
90
+ return null;
91
+ }
92
+ return {
93
+ goal: fields.goal,
94
+ scope: fields.scope,
95
+ acceptanceCriteria: fields.acceptanceCriteria,
96
+ failureModes: fields.failureModes,
97
+ priorities: fields.priorities,
98
+ nonGoals: fields.nonGoals,
99
+ };
100
+ }
101
+ /**
102
+ * Fold the test evidence: run counts, the latest outcome, the failing runs
103
+ * since the latest green, and the best parsed coverage percentage.
104
+ * @param events - the session's append-only event log.
105
+ * @param detection - the compiled test-run detection knobs.
106
+ * @param coverageRegex - regex with one capture group matching a percentage.
107
+ * @returns the folded evidence.
108
+ */
109
+ export function foldTestEvidence(events, detection, coverageRegex) {
110
+ const evidence = { failed: 0, passed: 0, lastOutcome: 'none', failingAfterGreen: 0, coveragePct: null };
111
+ const pendingTests = new Map();
112
+ for (const event of events) {
113
+ switch (event.type) {
114
+ case 'tool/call': {
115
+ const args = parseRawArguments(event.data.arguments);
116
+ const command = shellCommand(event.data.name, args, detection);
117
+ if (command !== undefined && isTestCommand(command, detection)) {
118
+ pendingTests.set(event.data.callId, undefined);
119
+ }
120
+ break;
121
+ }
122
+ case 'tool/result': {
123
+ const callId = event.data.message.source.callId;
124
+ if (!pendingTests.delete(callId))
125
+ break;
126
+ const text = joinTextBlocks(event.data.message.content);
127
+ foldTestEvidenceOutcome(evidence, testOutcome(text, event.data.error !== undefined), text, coverageRegex);
128
+ break;
129
+ }
130
+ case 'tool/code-dispatch': {
131
+ const args = parseRawArguments(event.data.arguments);
132
+ const command = shellCommand(event.data.name, args, detection);
133
+ if (command !== undefined && isTestCommand(command, detection)) {
134
+ const text = joinTextBlocks(event.data.content);
135
+ foldTestEvidenceOutcome(evidence, testOutcome(text, event.data.isError), text, coverageRegex);
136
+ }
137
+ break;
138
+ }
139
+ }
140
+ }
141
+ return evidence;
142
+ }
143
+ /** Fold one settled test-run outcome into the counts, the red window, and the coverage. */
144
+ function foldTestEvidenceOutcome(evidence, outcome, text, coverageRegex) {
145
+ const pct = readCoverage(text, coverageRegex);
146
+ if (pct !== null && (evidence.coveragePct === null || pct > evidence.coveragePct)) {
147
+ evidence.coveragePct = pct;
148
+ }
149
+ if (outcome === 'fail') {
150
+ evidence.failed += 1;
151
+ evidence.lastOutcome = 'fail';
152
+ evidence.failingAfterGreen += 1;
153
+ }
154
+ else if (outcome === 'pass') {
155
+ evidence.passed += 1;
156
+ evidence.lastOutcome = 'pass';
157
+ evidence.failingAfterGreen = 0;
158
+ }
159
+ }
160
+ /** Parse the first coverage percentage from test output, or null when absent. */
161
+ function readCoverage(text, coverageRegex) {
162
+ const match = coverageRegex.exec(text);
163
+ if (match === null || match[1] === undefined)
164
+ return null;
165
+ const parsed = Number.parseFloat(match[1]);
166
+ return Number.isFinite(parsed) ? parsed : null;
167
+ }
168
+ /**
169
+ * Evaluate the requirements phase: each key question is confirmed item by
170
+ * item against the committed spec. A failed required question is a red
171
+ * light; a failed optional question is a warning; a free-form question
172
+ * without a spec-dimension binding is a manual-confirm warning.
173
+ * @param evidence - the folded requirements evidence.
174
+ * @param config - the requirements-phase tuning.
175
+ * @returns the settled phase result.
176
+ */
177
+ export function evaluateRequirements(evidence, config) {
178
+ const checks = [];
179
+ let confirmed = 0;
180
+ for (const question of config.checklist) {
181
+ let status;
182
+ let summary;
183
+ let suggestion;
184
+ if (evidence.spec === null) {
185
+ status = 'fail';
186
+ summary = 'no requirements spec on record';
187
+ suggestion = 'run the grill-requirements skill (or plan mode) and commit doublecheck_spec';
188
+ if (evidence.interrogations > 0) {
189
+ summary = `no spec committed; ${evidence.interrogations} interrogation(s) on record`;
190
+ status = 'warn';
191
+ }
192
+ }
193
+ else if (question.specDimension === null) {
194
+ status = 'warn';
195
+ summary = 'no automatic evidence binding; confirm manually';
196
+ suggestion = `answer "${question.question}" with the user and record the outcome`;
197
+ }
198
+ else if (!SPEC_FIELD_NAMES.includes(question.specDimension)) {
199
+ status = 'warn';
200
+ summary = `unknown spec dimension binding "${question.specDimension}"; confirm manually`;
201
+ suggestion = `answer "${question.question}" with the user and record the outcome`;
202
+ }
203
+ else if (evidence.spec[question.specDimension].trim() === '') {
204
+ status = 'fail';
205
+ summary = `spec dimension "${question.specDimension}" is empty`;
206
+ suggestion = 'settle the dimension with the user and re-commit doublecheck_spec';
207
+ }
208
+ else {
209
+ status = 'pass';
210
+ summary = `spec dimension "${question.specDimension}" committed`;
211
+ suggestion = '';
212
+ }
213
+ if (status === 'pass') {
214
+ if (question.required)
215
+ confirmed += 1;
216
+ }
217
+ else if (status === 'fail' && !question.required) {
218
+ status = 'warn';
219
+ summary += ' (optional question)';
220
+ }
221
+ checks.push({
222
+ id: question.id,
223
+ phase: 'requirements',
224
+ label: question.question,
225
+ status,
226
+ summary,
227
+ suggestion,
228
+ });
229
+ }
230
+ const required = config.checklist.filter(question => question.required).length;
231
+ const target = Math.min(config.minConfirmed, required);
232
+ if (confirmed < target) {
233
+ checks.push({
234
+ id: 'confirmed',
235
+ phase: 'requirements',
236
+ label: `key questions confirmed (${confirmed}/${target})`,
237
+ status: 'fail',
238
+ summary: `${confirmed} of ${target} required key questions confirmed`,
239
+ suggestion: 'answer the open key questions with the user, then commit doublecheck_spec',
240
+ });
241
+ }
242
+ return { phase: 'requirements', enabled: true, status: worstStatus(checks), checks };
243
+ }
244
+ /**
245
+ * Evaluate the tests phase: the latest run color, the open red window, and
246
+ * (optionally) the coverage percentage. Deterministic — no model calls.
247
+ * @param evidence - the folded test evidence.
248
+ * @param config - the tests-phase tuning.
249
+ * @returns the settled phase result.
250
+ */
251
+ export function evaluateTests(evidence, config) {
252
+ const checks = [];
253
+ if (config.requirePassingRun) {
254
+ let status;
255
+ let summary;
256
+ let suggestion = '';
257
+ if (evidence.lastOutcome === 'pass') {
258
+ status = 'pass';
259
+ summary = 'latest test run passed';
260
+ }
261
+ else if (evidence.lastOutcome === 'fail') {
262
+ status = 'fail';
263
+ summary = 'latest test run failing';
264
+ suggestion = 'fix the failing tests and re-run the suite to green';
265
+ }
266
+ else {
267
+ status = 'fail';
268
+ summary = 'no test run on record';
269
+ suggestion = 'run the test suite to green before delivering';
270
+ }
271
+ checks.push({ id: 'passing-run', phase: 'tests', label: 'passing test run', status, summary, suggestion });
272
+ }
273
+ if (evidence.failed > 0) {
274
+ const within = evidence.failingAfterGreen <= config.allowFailingRuns;
275
+ checks.push({
276
+ id: 'failing-cases',
277
+ phase: 'tests',
278
+ label: 'failing cases after green',
279
+ status: within ? 'pass' : 'fail',
280
+ summary: `${evidence.failingAfterGreen} failing run(s) after green (allowed: ${config.allowFailingRuns})`,
281
+ suggestion: within ? '' : 'make the failing tests pass and re-run the suite',
282
+ });
283
+ }
284
+ if (config.requireCoverage) {
285
+ let status;
286
+ let summary;
287
+ let suggestion;
288
+ if (evidence.coveragePct === null) {
289
+ status = 'fail';
290
+ summary = 'no coverage evidence in the test output';
291
+ suggestion = 'run coverage and include its summary in the test output';
292
+ }
293
+ else if (evidence.coveragePct < config.minCoveragePct) {
294
+ status = 'fail';
295
+ summary = `${evidence.coveragePct}% coverage below the ${config.minCoveragePct}% minimum`;
296
+ suggestion = 'raise coverage above the configured minimum';
297
+ }
298
+ else {
299
+ status = 'pass';
300
+ summary = `${evidence.coveragePct}% coverage (minimum ${config.minCoveragePct}%)`;
301
+ suggestion = '';
302
+ }
303
+ checks.push({ id: 'coverage', phase: 'tests', label: 'coverage evidence', status, summary, suggestion });
304
+ }
305
+ return { phase: 'tests', enabled: true, status: worstStatus(checks), checks };
306
+ }
307
+ /** The structured output the consistency and local review subagents must satisfy. */
308
+ export const GATE_FINDINGS_SCHEMA = {
309
+ type: 'object',
310
+ additionalProperties: false,
311
+ required: ['findings'],
312
+ properties: {
313
+ findings: {
314
+ type: 'array',
315
+ items: {
316
+ type: 'object',
317
+ additionalProperties: false,
318
+ required: ['severity', 'title', 'detail'],
319
+ properties: {
320
+ severity: { type: 'string', enum: ['blocker', 'major', 'minor', 'info'] },
321
+ title: { type: 'string' },
322
+ detail: { type: 'string' },
323
+ },
324
+ },
325
+ },
326
+ },
327
+ };
328
+ /**
329
+ * Evaluate the consistency phase from the local reviewer's findings: blocker
330
+ * and major findings are red lights, minor/info findings are warnings, and
331
+ * an unavailable reviewer is an honest skip (never a red light — a broken
332
+ * critic is not evidence of a defect, and a skipped phase keeps the verdict
333
+ * honest instead of silent).
334
+ * @param findings - the structured findings, or null when the reviewer could not run.
335
+ * @param unavailableReason - why the reviewer could not run (for the skip notice).
336
+ * @returns the settled phase result.
337
+ */
338
+ export function evaluateConsistency(findings, unavailableReason) {
339
+ if (findings === null) {
340
+ return {
341
+ phase: 'consistency',
342
+ enabled: true,
343
+ status: 'skip',
344
+ checks: [{
345
+ id: 'consistency-review',
346
+ phase: 'consistency',
347
+ label: 'diff ↔ requirement mapping',
348
+ status: 'skip',
349
+ summary: unavailableReason,
350
+ suggestion: 'run /gate run again once the reviewer seam is available',
351
+ }],
352
+ };
353
+ }
354
+ if (findings.length === 0) {
355
+ return {
356
+ phase: 'consistency',
357
+ enabled: true,
358
+ status: 'pass',
359
+ checks: [{
360
+ id: 'consistency-review',
361
+ phase: 'consistency',
362
+ label: 'diff ↔ requirement mapping',
363
+ status: 'pass',
364
+ summary: 'edits map to the committed spec; no unmapped changes found',
365
+ suggestion: '',
366
+ }],
367
+ };
368
+ }
369
+ const checks = findings.map((finding, index) => ({
370
+ id: `finding-${index + 1}`,
371
+ phase: 'consistency',
372
+ label: finding.title,
373
+ status: finding.severity === 'blocker' || finding.severity === 'major' ? 'fail' : 'warn',
374
+ summary: `[${finding.severity}] ${redactSecrets(finding.detail)}`,
375
+ suggestion: redactSecrets(finding.title),
376
+ }));
377
+ return { phase: 'consistency', enabled: true, status: worstStatus(checks), checks };
378
+ }
379
+ /**
380
+ * Fold the dsh-auto-review engine's durable verdict records
381
+ * (`autoReview/verdict` / `autoReview/rejection`) from the session log. The
382
+ * engine row provides no service, so its verdict events ARE the weak
383
+ * dependency: records present means the engine is installed and has judged
384
+ * this session's calls; no records means it did not run, and the gate
385
+ * degrades to the local reviewer.
386
+ * @param events - the session's append-only event log.
387
+ * @returns the engine evidence, or null when the engine has no records here.
388
+ */
389
+ export function foldAutoReviewEvidence(events) {
390
+ let approvals = 0;
391
+ let rejections = 0;
392
+ let latestRisk = null;
393
+ for (const event of events) {
394
+ // The engine's vocabulary is foreign (dsh-auto-review is not a dependency
395
+ // of this package), so the fold reads it structurally by type name.
396
+ const type = event.type;
397
+ if (type !== 'autoReview/verdict' && type !== 'autoReview/rejection')
398
+ continue;
399
+ const data = event.data;
400
+ if (type === 'autoReview/verdict') {
401
+ if (data.decision === 'allow')
402
+ approvals += 1;
403
+ else if (data.decision === 'deny')
404
+ rejections += 1;
405
+ if (typeof data.riskLevel === 'string' && data.riskLevel.length > 0)
406
+ latestRisk = data.riskLevel;
407
+ }
408
+ else {
409
+ rejections += 1;
410
+ }
411
+ }
412
+ if (approvals === 0 && rejections === 0)
413
+ return null;
414
+ return { engine: 'dsh-auto-review', approvals, rejections, latestRisk };
415
+ }
416
+ /**
417
+ * Evaluate the review conclusion phase. With `engine: auto`, the engine's
418
+ * durable verdict records are the evidence; when the engine has no records
419
+ * (not installed, or nothing triggered it this session), the phase degrades
420
+ * to the local reviewer's findings with a warn check naming the degrade
421
+ * reason. With `engine: local`, the local reviewer is always the evidence.
422
+ * A rejected engine call is a red light.
423
+ * @param engine - the engine evidence, or null.
424
+ * @param localFindings - the local reviewer's findings, or null when it could not run.
425
+ * @param localUnavailable - why the local reviewer could not run.
426
+ * @param engineConfigured - the configured engine preference.
427
+ * @param degradeNote - why the engine was not used (for the warn check).
428
+ * @returns the settled phase result and the engine that produced it.
429
+ */
430
+ export function evaluateReview(engine, localFindings, localUnavailable, engineConfigured, degradeNote = null) {
431
+ if (engineConfigured === 'auto' && engine !== null) {
432
+ let status = 'pass';
433
+ let summary;
434
+ let suggestion = '';
435
+ if (engine.rejections > 0) {
436
+ status = 'fail';
437
+ summary = `${engine.rejections} call(s) rejected by dsh-auto-review`;
438
+ suggestion = 'address the rejected calls, then re-run /gate run';
439
+ }
440
+ else {
441
+ summary = `${engine.approvals} call(s) approved by dsh-auto-review`;
442
+ if (engine.latestRisk !== null)
443
+ summary += ` (latest risk: ${engine.latestRisk})`;
444
+ }
445
+ return {
446
+ engine: 'dsh-auto-review',
447
+ result: {
448
+ phase: 'review',
449
+ enabled: true,
450
+ status,
451
+ checks: [{ id: 'engine-review', phase: 'review', label: 'dsh-auto-review conclusion', status, summary, suggestion }],
452
+ },
453
+ };
454
+ }
455
+ const degraded = engineConfigured === 'auto' && engine === null;
456
+ return {
457
+ engine: 'local',
458
+ result: evaluateLocalReview(localFindings, localUnavailable, degraded, degradeNote),
459
+ };
460
+ }
461
+ /** The local reviewer's settled phase result (shared by both engine modes). */
462
+ function evaluateLocalReview(findings, unavailableReason, degraded, degradeNote) {
463
+ const checks = [];
464
+ if (degraded && degradeNote !== null) {
465
+ checks.push({
466
+ id: 'engine-degraded',
467
+ phase: 'review',
468
+ label: 'review engine',
469
+ status: 'warn',
470
+ summary: degradeNote,
471
+ suggestion: 'install dsh-auto-review, or re-run /gate run after it has judged this session\'s calls',
472
+ });
473
+ }
474
+ if (findings === null) {
475
+ checks.push({
476
+ id: 'local-review',
477
+ phase: 'review',
478
+ label: 'local review',
479
+ status: 'skip',
480
+ summary: unavailableReason,
481
+ suggestion: 'run /gate run again once the reviewer seam is available',
482
+ });
483
+ return { phase: 'review', enabled: true, status: worstStatus(checks), checks };
484
+ }
485
+ if (findings.length === 0) {
486
+ checks.push({
487
+ id: 'local-review',
488
+ phase: 'review',
489
+ label: 'local review',
490
+ status: 'pass',
491
+ summary: 'the reviewer found no objections the evidence can support',
492
+ suggestion: '',
493
+ });
494
+ return { phase: 'review', enabled: true, status: worstStatus(checks), checks };
495
+ }
496
+ for (const [index, finding] of findings.entries()) {
497
+ checks.push({
498
+ id: `finding-${index + 1}`,
499
+ phase: 'review',
500
+ label: finding.title,
501
+ status: finding.severity === 'blocker' || finding.severity === 'major' ? 'fail' : 'warn',
502
+ summary: `[${finding.severity}] ${redactSecrets(finding.detail)}`,
503
+ suggestion: redactSecrets(finding.title),
504
+ });
505
+ }
506
+ return { phase: 'review', enabled: true, status: worstStatus(checks), checks };
507
+ }
508
+ /** A disabled phase: present in the report as skipped, contributing nothing. */
509
+ export function skippedPhase(phase) {
510
+ return { phase, enabled: false, status: 'skip', checks: [] };
511
+ }
512
+ /** The worst status among the checks (fail > warn > skip > pending > pass). */
513
+ function worstStatus(checks) {
514
+ let worst = 'pass';
515
+ for (const check of checks) {
516
+ if (check.status === 'fail')
517
+ return 'fail';
518
+ if (check.status === 'warn')
519
+ worst = 'warn';
520
+ else if (check.status === 'skip' && worst === 'pass')
521
+ worst = 'skip';
522
+ else if (check.status === 'pending' && worst === 'pass')
523
+ worst = 'pending';
524
+ }
525
+ return worst;
526
+ }
527
+ /**
528
+ * Derive the gate's binary decision: any enabled phase with a failing check
529
+ * means rework. Warnings and skipped phases never flip the decision — a
530
+ * skipped review keeps the report honest ("not reviewed") without inventing
531
+ * a red light.
532
+ * @param phases - the settled phase results.
533
+ * @returns the gate verdict.
534
+ */
535
+ export function deriveGateVerdict(phases) {
536
+ return phases.some(phase => phase.enabled && phase.status === 'fail') ? 'rework' : 'deliverable';
537
+ }
538
+ /**
539
+ * The count of red-light (failing) checks across the phases.
540
+ * @param phases - the settled phase results.
541
+ * @returns the red-item count.
542
+ */
543
+ export function countRedChecks(phases) {
544
+ let count = 0;
545
+ for (const phase of phases) {
546
+ for (const check of phase.checks) {
547
+ if (check.status === 'fail')
548
+ count += 1;
549
+ }
550
+ }
551
+ return count;
552
+ }
553
+ /**
554
+ * Redact secrets from a model-produced text. The gate report is auditable
555
+ * by policy: counts, ids, and verdicts only. This scrubber catches the
556
+ * common credential shapes (cloud keys, GitHub tokens, OpenAI-style keys,
557
+ * Slack tokens, bearer tokens, private-key blocks, password assignments,
558
+ * and long hex/base64 runs) before a finding title or detail is stored or
559
+ * shown, so a reviewer quoting evidence cannot leak a credential.
560
+ * @param text - the text to scrub.
561
+ * @returns the text with recognized secrets replaced by `[redacted]`.
562
+ */
563
+ export function redactSecrets(text) {
564
+ return text
565
+ .replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/gi, '[redacted]')
566
+ .replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[redacted]')
567
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '[redacted]')
568
+ .replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, '[redacted]')
569
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, '[redacted]')
570
+ .replace(/\bBearer\s+[A-Za-z0-9._-]{16,}\b/gi, 'Bearer [redacted]')
571
+ .replace(/\b(?:password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, '$1: [redacted]')
572
+ .replace(/\b[0-9a-f]{32,}\b/gi, '[redacted]')
573
+ .replace(/\b[A-Za-z0-9+/_-]{40,}\b/g, '[redacted]');
574
+ }
575
+ /** The status glyph of one checklist item for the markdown renderers. */
576
+ function statusGlyph(status) {
577
+ switch (status) {
578
+ case 'pass': return '✔';
579
+ case 'fail': return '✖';
580
+ case 'warn': return '⚠';
581
+ case 'skip': return '–';
582
+ case 'pending': return '…';
583
+ }
584
+ }
585
+ /** The phase-order index (for deterministic rendering). */
586
+ function phaseOrder(phase) {
587
+ return GATE_PHASES.indexOf(phase);
588
+ }
589
+ /**
590
+ * Render the settled gate state as the model/human-facing markdown report —
591
+ * the `/gate run` output, ready to paste into a PR description.
592
+ * @param state - the settled gate state.
593
+ * @param planSuggestion - whether to append the plan-mode re-check suggestion.
594
+ * @returns the report document.
595
+ */
596
+ export function renderGateReportMarkdown(state, planSuggestion) {
597
+ const redCount = countRedChecks(Object.values(state.phases));
598
+ const lines = [
599
+ '# Delivery gate report',
600
+ '',
601
+ `> **Verdict: ${state.verdict === 'rework' ? 'rework required' : 'deliverable'}**${redCount > 0 ? ` — ${redCount} red item(s)` : ''}`,
602
+ '',
603
+ ];
604
+ if (state.verdict === 'rework' && planSuggestion) {
605
+ lines.push('> The gate is red. Re-open the work in plan mode to re-check the open items before delivering.', '');
606
+ }
607
+ for (const phase of GATE_PHASES) {
608
+ const result = state.phases[phase];
609
+ if (result === undefined)
610
+ continue;
611
+ lines.push(...renderPhaseMarkdown(result), '');
612
+ }
613
+ const reds = [];
614
+ for (const phase of GATE_PHASES) {
615
+ const result = state.phases[phase];
616
+ if (result === undefined)
617
+ continue;
618
+ for (const check of result.checks) {
619
+ if (check.status === 'fail')
620
+ reds.push(`1. **${phase}/${check.id}** — ${check.summary} — *rework: ${check.suggestion}*`);
621
+ }
622
+ }
623
+ if (reds.length > 0) {
624
+ lines.push('## Red items', '', ...reds, '');
625
+ }
626
+ lines.push('## Audit', `- review engine: ${state.reviewEngine ?? 'not run'}`, `- generated at: ${state.at}`, '- counts, ids, and verdicts only: no file contents or session text are embedded, and recognized secrets are redacted.', '');
627
+ return lines.join('\n');
628
+ }
629
+ /**
630
+ * Render one phase's checklist as markdown lines (shared by the full report
631
+ * and the `/gate status` panel).
632
+ * @param result - the phase result to render.
633
+ * @returns the markdown lines, without the trailing blank line.
634
+ */
635
+ export function renderPhaseMarkdown(result) {
636
+ const lines = [`## ${phaseOrder(result.phase) + 1}. ${phaseLabel(result.phase)} — ${result.status.toUpperCase()}`];
637
+ if (!result.enabled) {
638
+ lines.push('- disabled in the gate configuration');
639
+ }
640
+ else if (result.checks.length === 0) {
641
+ lines.push('- no evidence folded');
642
+ }
643
+ else {
644
+ for (const check of result.checks) {
645
+ lines.push(`- [${statusGlyph(check.status)}] **${check.label}** — ${check.summary}${check.suggestion !== '' ? ` — rework: ${check.suggestion}` : ''}`);
646
+ }
647
+ }
648
+ return lines;
649
+ }
650
+ /** Human-readable phase headings (stable English audit ids). */
651
+ export function phaseLabel(phase) {
652
+ switch (phase) {
653
+ case 'requirements': return 'Requirements interrogation';
654
+ case 'tests': return 'Test evidence';
655
+ case 'consistency': return 'Implementation consistency';
656
+ case 'review': return 'Review conclusion';
657
+ }
658
+ }
@@ -11,9 +11,10 @@
11
11
  * @module dsh-doublecheck/domain/projection
12
12
  */
13
13
  import type { SessionEvent } from '@deepseek-ai/dsh-session';
14
- import { type TestRunDetection } from './evidence.ts';
15
- import { type DisciplineStage, type TestColor } from './stages.ts';
16
- import type { DoublecheckView } from '../types.ts';
14
+ import { type TestRunDetection } from './evidence.js';
15
+ import { type DisciplineStage, type TestColor } from './stages.js';
16
+ import { type GateVerdict } from './gate.js';
17
+ import type { DoublecheckView } from '../types.js';
17
18
  /** The projection unit's plain-JSON fold state. */
18
19
  export interface DoublecheckProjectionState {
19
20
  /** The last discipline stage a successful call or test run reached. */
@@ -28,6 +29,10 @@ export interface DoublecheckProjectionState {
28
29
  reviewed: boolean;
29
30
  /** Total implementation edits folded so far. */
30
31
  editCount: number;
32
+ /** The latest delivery-gate verdict, or 'none' before any gate run. */
33
+ gateVerdict: 'none' | GateVerdict;
34
+ /** Red-light (failing) checks of the latest gate run. */
35
+ gateRedCount: number;
31
36
  /** Spec-tool call ids (with their goals) whose results have not been folded yet. */
32
37
  pendingSpecCalls: Record<string, string>;
33
38
  /** Test-run call ids (with their commands) whose results have not been folded yet. */