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.
@@ -33,10 +33,12 @@ import { emptyDisciplineState, foldDisciplineRange } from "../domain/stages.js";
33
33
  import { isVagueTask } from "../domain/vagueness.js";
34
34
  import { runAdversaryReview, reviewInjection } from "./review.js";
35
35
  import { PROSE } from "./prose.js";
36
+ import { gateHandler, gateInjection, GateConfigSchema, runGate, settleGate, validateGateConfig, } from "./gate.js";
37
+ import { countRedChecks } from "../domain/gate.js";
36
38
  import { applyDoublecheckEvent, emptyDoublecheckState, viewDoublecheck, } from "../domain/projection.js";
37
39
  import { doublecheckViewSchema } from "../types.js";
38
40
  import { installInvariant, PACKAGE_NAME } from "../invariant.js";
39
- import { doublecheckHandler, effectiveDoublecheckEnabled, hostStampsIgnorable } from "./command.js";
41
+ import { doublecheckHandler, hostStampsIgnorable } from "./command.js";
40
42
  export const name = 'doublecheck-guard';
41
43
  export const inject = ['commands'];
42
44
  export const Config = z.object({
@@ -59,6 +61,7 @@ export const Config = z.object({
59
61
  testToolNames: z.array(z.string()).default([...DEFAULT_TEST_TOOL_NAMES]),
60
62
  testCommandPatterns: z.array(z.string()).default([...DEFAULT_TEST_COMMAND_PATTERNS]),
61
63
  testFilePatterns: z.array(z.string()).default([...DEFAULT_TEST_FILE_PATTERNS]),
64
+ gate: GateConfigSchema,
62
65
  });
63
66
  /**
64
67
  * Install the guard listeners.
@@ -68,6 +71,7 @@ export const Config = z.object({
68
71
  export function apply(ctx, config) {
69
72
  assertPositiveInteger('vagueTaskMaxChars', config.vagueTaskMaxChars);
70
73
  assertGuardTools(config.guardTools);
74
+ validateGateConfig(config.gate);
71
75
  if (config.modules.adversary) {
72
76
  assertBoundedInteger('adversaryMaxFindings', config.adversaryMaxFindings, 20);
73
77
  assertPositiveInteger('adversaryTimeoutMs', config.adversaryTimeoutMs);
@@ -91,9 +95,14 @@ export function apply(ctx, config) {
91
95
  const pendingReminders = new WeakMap();
92
96
  /** Process-local switch overrides written by `/doublecheck on|off` on hosts that cannot store the durable event. */
93
97
  const localOverrides = new WeakMap();
94
- /** The effective switch for a session: the local override first, then the durable log fold. */
98
+ /**
99
+ * The effective switch for a session: the local override first, then the
100
+ * durable `doublecheck/state` fold, then the configured default. The fold
101
+ * rides the same incremental snapshot as the discipline facts, so the read
102
+ * costs O(new events) instead of rescanning the log per tool call.
103
+ */
95
104
  function doublecheckEnabled(session) {
96
- return localOverrides.get(session) ?? effectiveDoublecheckEnabled(session.events, config.enableByDefault);
105
+ return localOverrides.get(session) ?? snapshotOf(session).stateEnabled ?? config.enableByDefault;
97
106
  }
98
107
  const prose = PROSE[config.language];
99
108
  /**
@@ -125,6 +134,9 @@ export function apply(ctx, config) {
125
134
  snapshot.lastReviewSeq = event.seq;
126
135
  snapshot.editsAfterReview = 0;
127
136
  }
137
+ else if (source.kind === 'doublecheck-gate') {
138
+ snapshot.gateRedReminded = true;
139
+ }
128
140
  else if (source.kind === 'user') {
129
141
  const parts = [];
130
142
  for (const block of event.data.content) {
@@ -138,6 +150,17 @@ export function apply(ctx, config) {
138
150
  }
139
151
  }
140
152
  }
153
+ else if (event.type === 'doublecheck/state') {
154
+ snapshot.stateEnabled = event.data.enabled;
155
+ }
156
+ else if (event.type === 'doublecheck/gate') {
157
+ const gate = event.data;
158
+ snapshot.lastGate = {
159
+ seq: event.seq,
160
+ verdict: gate.verdict,
161
+ redCount: gate.phases === undefined ? 0 : countRedChecks(Object.values(gate.phases)),
162
+ };
163
+ }
141
164
  else if (event.type === 'tool/call' && event.seq > snapshot.lastReviewSeq) {
142
165
  // Implementation edits after the latest review re-arm the adversary
143
166
  // trigger for a second review round.
@@ -165,6 +188,8 @@ export function apply(ctx, config) {
165
188
  reportReminded: false,
166
189
  lastReviewSeq: -1,
167
190
  editsAfterReview: 0,
191
+ lastGate: null,
192
+ gateRedReminded: false,
168
193
  };
169
194
  foldEvents(snapshot, events, 0, events.length);
170
195
  snapshots.set(session, snapshot);
@@ -224,12 +249,71 @@ export function apply(ctx, config) {
224
249
  config,
225
250
  snapshotOf,
226
251
  detection,
252
+ effectiveEnabled: doublecheckEnabled,
227
253
  stampsIgnorable: hostStampsIgnorable,
228
254
  setLocalOverride: (session, enabled) => localOverrides.set(session, enabled),
229
255
  }),
230
256
  });
257
+ // The delivery gate panel: `/gate` stays available even when the gate or
258
+ // every discipline module is off — it is the user's inspection surface.
259
+ ctx.commands.register({
260
+ name: 'gate',
261
+ description: 'run the delivery quality gate and report the deliverable/rework decision',
262
+ input: { hint: 'status|run|config' },
263
+ handler: gateHandler({
264
+ config: config.gate,
265
+ detection,
266
+ prose,
267
+ runGate: (agent, signal) => runGate(ctx, config.gate, detection, agent, signal, config.language),
268
+ settleGate: (state, agent, signal) => settleGate(ctx, config.gate, state, agent, signal, hostStampsIgnorable()),
269
+ stampsIgnorable: hostStampsIgnorable,
270
+ planMode: ctx.get('planMode'),
271
+ }),
272
+ });
273
+ // The gate settings namespace: the pluggable checklist becomes editable
274
+ // through the harness settings surface when one is mounted (weak seam —
275
+ // a profile without the settings service simply has no settings page).
276
+ const settings = ctx.get('settings');
277
+ if (settings !== undefined) {
278
+ try {
279
+ settings.register('doublecheck.gate', GateConfigSchema, {
280
+ base: config.gate,
281
+ applies: 'restart',
282
+ expose: true,
283
+ });
284
+ }
285
+ catch (error) {
286
+ ctx.logger.warn(`dsh-doublecheck: gate settings namespace skipped: ${String(error)}`);
287
+ }
288
+ }
289
+ // The gate-red turn notice: once per session, a settled rework verdict
290
+ // suggests re-opening the work in plan mode. Installed even when every
291
+ // discipline module is off — the gate panel is advisory, not a gate.
292
+ if (config.gate.enabled) {
293
+ ctx.on('agent/turn-stopping', async ({ agent }) => {
294
+ if (!doublecheckEnabled(agent.session))
295
+ return;
296
+ const snapshot = snapshotOf(agent.session);
297
+ if (snapshot.gateRedReminded)
298
+ return;
299
+ const gate = snapshot.lastGate;
300
+ if (gate === null || gate.verdict !== 'rework')
301
+ return;
302
+ snapshot.gateRedReminded = true;
303
+ const text = prose.gateRedNotice(gate.redCount);
304
+ agent.inject(gateInjection(gate.verdict, gate.redCount, text));
305
+ ctx.emit('doublecheck/reminder', {
306
+ agent,
307
+ session: agent.session,
308
+ intensity: config.intensity,
309
+ gate: 'gate',
310
+ verdict: 'gate-red',
311
+ reminder: text,
312
+ });
313
+ });
314
+ }
231
315
  if (!config.modules.grill && !config.modules.tdd && !config.modules.adversary) {
232
- ctx.logger.info('dsh-doublecheck: all discipline gates disabled; only the /doublecheck command remains active');
316
+ ctx.logger.info('dsh-doublecheck: all discipline gates disabled; the /doublecheck and /gate commands remain active');
233
317
  return;
234
318
  }
235
319
  // The policy gate. Observe-and-decide: the grill gate owns vague,
@@ -293,11 +377,13 @@ export function apply(ctx, config) {
293
377
  }
294
378
  }
295
379
  // Red gate: implementation edits need a failing test on record. Writing
296
- // test files is the red step itself and always delegates.
380
+ // test files is the red step itself and always delegates, and a call that
381
+ // names no file at all (a custom guard tool with another argument shape)
382
+ // is not an implementation edit either.
297
383
  if (config.modules.tdd && snapshot.discipline.color !== 'red') {
298
384
  const args = exec.arguments;
299
385
  const path = mutationTargetPath(exec.name, args, detection);
300
- if (path !== undefined && isTestFilePath(path, detection))
386
+ if (path === undefined || isTestFilePath(path, detection))
301
387
  return next();
302
388
  const reminder = nextTddRedReminder(snapshot);
303
389
  if (reminder !== undefined)
@@ -445,7 +531,7 @@ export function apply(ctx, config) {
445
531
  init: emptyDoublecheckState,
446
532
  apply: (state, event) => applyDoublecheckEvent(state, event, detection),
447
533
  view: viewDoublecheck,
448
- stateVersion: 1,
534
+ stateVersion: 2,
449
535
  });
450
536
  });
451
537
  // The invariant companion, registered from the main plugin so its checks
@@ -5,6 +5,12 @@
5
5
  * `green gate`) stay English: they are stable session-log ids the durable
6
6
  * once-semantics fold matches, and translating them would silently break
7
7
  * `remindOnce` after a language switch. Only the model-facing text localizes.
8
+ *
9
+ * Scope of localization: every model-visible string the package injects or
10
+ * answers with (gate prose, the critic task, `/doublecheck` replies, the
11
+ * switch notice) is localized. The workspace documents (`doublecheck-spec.md`
12
+ * / `doublecheck-report.md`) keep their English section headings: they are
13
+ * stable artifacts whose structure other tooling may match.
8
14
  * @module dsh-doublecheck/guard/prose
9
15
  */
10
16
  /** Supported prose languages for guard injections. */
@@ -29,6 +35,64 @@ export interface GuardProse {
29
35
  reviewUnavailableNoFindings: string;
30
36
  reviewFindingsHeader: (count: number) => string;
31
37
  reviewFindingsFooter: string;
38
+ reviewHeldBack: (held: number) => string;
39
+ /** The critic's task prompt (model-facing behavior of the review run). */
40
+ criticTask: string;
41
+ /** The gate-red notice injected at the turn boundary (role statement first). */
42
+ gateRedNotice: (redCount: number) => string;
43
+ /** The localized `/gate status` panel header. */
44
+ gateStatusHeader: string;
45
+ /** The localized `/gate status` usage hint. */
46
+ gateStatusHint: string;
47
+ /** The localized `/gate config` header. */
48
+ gateConfigHeader: string;
49
+ /** The localized `/gate` usage error. */
50
+ gateCommandUnknown: (input: string) => string;
51
+ /** The localized `/gate` no-agent error. */
52
+ gateCommandNoAgent: string;
53
+ switchOnDurable: string;
54
+ switchOnLocal: string;
55
+ switchOffDurable: string;
56
+ switchOffLocal: string;
57
+ commandNoAgent: string;
58
+ commandUnknown: (input: string) => string;
59
+ commandAlreadyOn: string;
60
+ commandAlreadyOff: string;
61
+ commandOnDurable: string;
62
+ commandOnLocal: string;
63
+ commandOffDurable: string;
64
+ commandOffLocal: string;
65
+ commandStatus: (facts: CommandStatusFacts) => string;
66
+ }
67
+ /** The folded facts the localized `/doublecheck status` reply renders. */
68
+ export interface CommandStatusFacts {
69
+ /** The effective session switch. */
70
+ enabled: boolean;
71
+ /** The configured default for sessions without a state record. */
72
+ defaultEnabled: boolean;
73
+ /** Configured module switches. */
74
+ modules: {
75
+ grill: boolean;
76
+ tdd: boolean;
77
+ adversary: boolean;
78
+ };
79
+ /** Configured enforcement strength. */
80
+ intensity: string;
81
+ /** Whether reminder repetition is capped per session. */
82
+ remindOnce: boolean;
83
+ /** Whether a committed spec exists in the log. */
84
+ hasSpec: boolean;
85
+ /** The latest test-run color (`none` / `red` / `green`). */
86
+ color: string;
87
+ /** Whether an adversary review record exists in the log. */
88
+ reviewed: boolean;
89
+ /** Total implementation edits folded so far. */
90
+ editCount: number;
91
+ /** The latest durable gate run, or null before any. */
92
+ gate: {
93
+ verdict: string;
94
+ redCount: number;
95
+ } | null;
32
96
  }
33
97
  /** The localized prose tables by language. */
34
98
  export declare const PROSE: Readonly<Record<ProseLanguage, GuardProse>>;
@@ -5,6 +5,12 @@
5
5
  * `green gate`) stay English: they are stable session-log ids the durable
6
6
  * once-semantics fold matches, and translating them would silently break
7
7
  * `remindOnce` after a language switch. Only the model-facing text localizes.
8
+ *
9
+ * Scope of localization: every model-visible string the package injects or
10
+ * answers with (gate prose, the critic task, `/doublecheck` replies, the
11
+ * switch notice) is localized. The workspace documents (`doublecheck-spec.md`
12
+ * / `doublecheck-report.md`) keep their English section headings: they are
13
+ * stable artifacts whose structure other tooling may match.
8
14
  * @module dsh-doublecheck/guard/prose
9
15
  */
10
16
  const EN = {
@@ -55,6 +61,45 @@ const EN = {
55
61
  reviewUnavailableNoFindings: 'Adversary review returned no structured findings.',
56
62
  reviewFindingsHeader: count => `Adversary review found ${count} objection(s) the delivery must answer:`,
57
63
  reviewFindingsFooter: 'Answer each: fix what is real, and state plainly what is false.',
64
+ reviewHeldBack: held => `… ${held} further objection(s) held back by adversaryMaxFindings`,
65
+ criticTask: 'You are the delivery reviewer for this software-engineering session. The '
66
+ + 'conversation you inherited contains a requirements spec recorded with the '
67
+ + 'doublecheck_spec tool (six dimensions: goal, scope, acceptance criteria, '
68
+ + 'failure modes, priorities, non-goals), followed by the implementation '
69
+ + 'work and its test evidence. Assume the delivery FAILS its own spec. Hunt '
70
+ + 'for the strongest objections you can actually support from this session: '
71
+ + 'dimensions the work did not meet, acceptance criteria with no evidence, '
72
+ + 'scope or non-goal violations, failure modes left unhandled. Answer '
73
+ + 'through the required structured output, one entry per objection, citing '
74
+ + 'what in the session supports it. If — and only if — the evidence '
75
+ + 'genuinely satisfies every dimension, return an empty findings list. Do '
76
+ + 'not invent objections; the empty answer is correct when nothing is wrong.',
77
+ gateRedNotice: redCount => `You are the delivery gate panel for this session. The latest gate run is RED with ${redCount} red item(s). Re-open the work in plan mode to re-check, or run /gate run for the full checklist.`,
78
+ gateStatusHeader: 'Delivery gate panel — checklist progress for this session:',
79
+ gateStatusHint: 'Deterministic phases fold live; the reviewer phases show the latest /gate run. Run /gate run to settle everything; /gate config shows the checklist.',
80
+ gateConfigHeader: 'Delivery gate configuration for this session:',
81
+ gateCommandUnknown: input => `Unknown /gate argument "${input}". Usage: /gate status|run|config`,
82
+ gateCommandNoAgent: '/gate needs an agent session to inspect.',
83
+ switchOnDurable: 'Double-check discipline was switched ON for this session (changed by the user): the requirements grill, the red/green gates, and the delivery review are active again.',
84
+ switchOnLocal: 'Double-check discipline was switched ON for this process only (changed by the user; this harness build predates the ignorable append surface, so the switch is not durable): the requirements grill, the red/green gates, and the delivery review are active again.',
85
+ switchOffDurable: 'Double-check discipline was switched OFF for this session (changed by the user): the discipline gates defer to the human approval chain until switched back on.',
86
+ switchOffLocal: 'Double-check discipline was switched OFF for this process only (changed by the user; this harness build predates the ignorable append surface, so the switch is not durable): the discipline gates defer to the human approval chain until switched back on.',
87
+ commandNoAgent: '/doublecheck needs an agent session to inspect.',
88
+ commandUnknown: input => `Unknown /doublecheck argument "${input}". Usage: /doublecheck status|report|on|off`,
89
+ commandAlreadyOn: 'Double-check discipline is already ON for this session.',
90
+ commandAlreadyOff: 'Double-check discipline is already OFF for this session.',
91
+ commandOnDurable: 'Double-check discipline ON for this session.',
92
+ commandOnLocal: 'Double-check discipline ON for this process only (not durable on this harness).',
93
+ commandOffDurable: 'Double-check discipline OFF for this session.',
94
+ commandOffLocal: 'Double-check discipline OFF for this process only (not durable on this harness).',
95
+ commandStatus: facts => [
96
+ `Double-check discipline is ${facts.enabled ? 'ON' : 'OFF'} for this session. (/doublecheck on|off)`,
97
+ `Modules: grill=${facts.modules.grill ? 'on' : 'off'}, tdd=${facts.modules.tdd ? 'on' : 'off'}, adversary=${facts.modules.adversary ? 'on' : 'off'} (cordis.yml).`,
98
+ `Intensity: ${facts.intensity}. Default switch: ${facts.defaultEnabled ? 'on' : 'off'}. remindOnce: ${facts.remindOnce ? 'on' : 'off'}.`,
99
+ `Stage: spec=${facts.hasSpec ? 'committed' : 'missing'}, tests=${facts.color}, review=${facts.reviewed ? 'on record' : 'not run'}, edits=${facts.editCount}.`,
100
+ `Gate: ${facts.gate === null ? 'not run' : `${facts.gate.verdict} (${facts.gate.redCount} red item(s))`} (/gate status|run|config).`,
101
+ `Usage: /doublecheck status|report|on|off`,
102
+ ].join('\n'),
58
103
  };
59
104
  const ZH = {
60
105
  grillReminder: '交付前三查:这个任务描述很简短,本会话还没有记录 doublecheck spec。先停下编辑,把六个需求维度确认清楚——目标、范围、验收标准、失败模式、优先级、非目标。按 grill-requirements 技能执行:用 ask_user_question 向用户提问直到达成共识,用 doublecheck_spec 记录结果,然后再继续编辑。',
@@ -75,6 +120,40 @@ const ZH = {
75
120
  reviewUnavailableNoFindings: '对抗式审查没有返回结构化发现。',
76
121
  reviewFindingsHeader: count => `对抗式审查发现 ${count} 条反对意见,交付必须回应:`,
77
122
  reviewFindingsFooter: '逐条回应:属实的修掉,不属实的明确说明。',
123
+ reviewHeldBack: held => `… 另有 ${held} 条反对意见因 adversaryMaxFindings 上限被保留`,
124
+ criticTask: '你是本次软件工程会话的交付审查者。你继承的会话里包含一份通过 '
125
+ + 'doublecheck_spec 工具记录的需求 spec(六个维度:目标、范围、验收标准、'
126
+ + '失败模式、优先级、非目标),其后是实现工作和测试证据。假设交付不满足自己的 '
127
+ + 'spec。寻找你能从本会话证据中真正支撑的最强反对意见:未达到的维度、没有证据的'
128
+ + '验收标准、范围或非目标被违反、未处理的失败模式。通过要求的结构化输出作答,'
129
+ + '每条反对意见占一项,并引用会话中支撑它的内容。当且仅当证据确实满足每一个维度时,'
130
+ + '才返回空的 findings 列表。不要编造反对意见;没有问题时空答案才是正确的。',
131
+ gateRedNotice: redCount => `你是本会话的交付门禁面板。最近一次门禁结论为红灯,共 ${redCount} 项红灯。建议转 plan mode 复查,或运行 /gate run 查看完整检查单。`,
132
+ gateStatusHeader: '交付门禁面板 — 本会话的检查单进度:',
133
+ gateStatusHint: '确定性阶段实时折叠;评审类阶段显示最近一次 /gate run 的结果。运行 /gate run 结算全部检查;/gate config 查看检查单配置。',
134
+ gateConfigHeader: '本会话的交付门禁配置:',
135
+ gateCommandUnknown: input => `未知的 /gate 参数 "${input}"。用法:/gate status|run|config`,
136
+ gateCommandNoAgent: '/gate 需要代理会话才能查看。',
137
+ switchOnDurable: 'Double-check 纪律已对本会话开启(由用户切换):需求盘问、红绿门和交付审查恢复生效。',
138
+ switchOnLocal: 'Double-check 纪律已对本进程开启(由用户切换;此 harness 版本早于 ignorable 追加接口,切换不持久):需求盘问、红绿门和交付审查恢复生效。',
139
+ switchOffDurable: 'Double-check 纪律已对本会话关闭(由用户切换):在重新开启之前,纪律门让位于人工审批链。',
140
+ switchOffLocal: 'Double-check 纪律已对本进程关闭(由用户切换;此 harness 版本早于 ignorable 追加接口,切换不持久):在重新开启之前,纪律门让位于人工审批链。',
141
+ commandNoAgent: '/doublecheck 需要代理会话才能查看。',
142
+ commandUnknown: input => `未知的 /doublecheck 参数 "${input}"。用法:/doublecheck status|report|on|off`,
143
+ commandAlreadyOn: '本会话的 Double-check 纪律已经是开启状态。',
144
+ commandAlreadyOff: '本会话的 Double-check 纪律已经是关闭状态。',
145
+ commandOnDurable: 'Double-check 纪律已对本会话开启。',
146
+ commandOnLocal: 'Double-check 纪律已对本进程开启(此 harness 上不持久)。',
147
+ commandOffDurable: 'Double-check 纪律已对本会话关闭。',
148
+ commandOffLocal: 'Double-check 纪律已对本进程关闭(此 harness 上不持久)。',
149
+ commandStatus: facts => [
150
+ `本会话的 Double-check 纪律:${facts.enabled ? '已开启' : '已关闭'}。(/doublecheck on|off)`,
151
+ `模块:grill=${facts.modules.grill ? '开' : '关'}, tdd=${facts.modules.tdd ? '开' : '关'}, adversary=${facts.modules.adversary ? '开' : '关'}(cordis.yml)。`,
152
+ `强度:${facts.intensity}。默认开关:${facts.defaultEnabled ? '开' : '关'}。remindOnce:${facts.remindOnce ? '开' : '关'}。`,
153
+ `阶段:spec=${facts.hasSpec ? '已提交' : '缺失'}, tests=${facts.color}, review=${facts.reviewed ? '有记录' : '未运行'}, edits=${facts.editCount}。`,
154
+ `门禁:${facts.gate === null ? '未运行' : `${facts.gate.verdict}(${facts.gate.redCount} 项红灯)`}(/gate status|run|config)。`,
155
+ '用法:/doublecheck status|report|on|off',
156
+ ].join('\n'),
78
157
  };
79
158
  /** The localized prose tables by language. */
80
159
  export const PROSE = { en: EN, zh: ZH };
@@ -42,6 +42,14 @@ export interface ReviewOutcome {
42
42
  export declare const REVIEW_OUTPUT_SCHEMA: ObjectJsonSchema;
43
43
  /** Injected when the critic found nothing supportable (English contract text). */
44
44
  export declare const CLEAN_TEXT: string;
45
+ /**
46
+ * Sort findings blocker-first, keeping the critic's original order within a
47
+ * severity. A deterministic order means the most threatening objection leads
48
+ * both the injected prose and the durable record.
49
+ * @param findings - the structured findings as produced by the critic.
50
+ * @returns the same findings, severity-ordered.
51
+ */
52
+ export declare function sortFindings(findings: readonly ReviewFinding[]): ReviewFinding[];
45
53
  /** Render structured findings as the model-facing review text. */
46
54
  export declare function renderFindings(findings: readonly ReviewFinding[], prose?: GuardProse, totalCount?: number): string;
47
55
  /**
@@ -41,25 +41,25 @@ export const REVIEW_OUTPUT_SCHEMA = {
41
41
  },
42
42
  },
43
43
  };
44
- /**
45
- * The critic's task, delivered as the forked child's user message. The child
46
- * already holds the parent history; this prompt only sets the adversarial
47
- * stance and the answer discipline.
48
- */
49
- const CRITIC_TASK = 'You are the delivery reviewer for this software-engineering session. The '
50
- + 'conversation you inherited contains a requirements spec recorded with the '
51
- + 'doublecheck_spec tool (six dimensions: goal, scope, acceptance criteria, '
52
- + 'failure modes, priorities, non-goals), followed by the implementation '
53
- + 'work and its test evidence. Assume the delivery FAILS its own spec. Hunt '
54
- + 'for the strongest objections you can actually support from this session: '
55
- + 'dimensions the work did not meet, acceptance criteria with no evidence, '
56
- + 'scope or non-goal violations, failure modes left unhandled. Answer '
57
- + 'through the required structured output, one entry per objection, citing '
58
- + 'what in the session supports it. If — and only if — the evidence '
59
- + 'genuinely satisfies every dimension, return an empty findings list. Do '
60
- + 'not invent objections; the empty answer is correct when nothing is wrong.';
61
44
  /** Injected when the critic found nothing supportable (English contract text). */
62
45
  export const CLEAN_TEXT = PROSE.en.reviewClean;
46
+ /** Severity ranks for a deterministic, blocker-first findings order. */
47
+ const SEVERITY_RANK = {
48
+ blocker: 0,
49
+ major: 1,
50
+ minor: 2,
51
+ info: 3,
52
+ };
53
+ /**
54
+ * Sort findings blocker-first, keeping the critic's original order within a
55
+ * severity. A deterministic order means the most threatening objection leads
56
+ * both the injected prose and the durable record.
57
+ * @param findings - the structured findings as produced by the critic.
58
+ * @returns the same findings, severity-ordered.
59
+ */
60
+ export function sortFindings(findings) {
61
+ return [...findings].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
62
+ }
63
63
  /** Render structured findings as the model-facing review text. */
64
64
  export function renderFindings(findings, prose = PROSE.en, totalCount = findings.length) {
65
65
  const lines = [
@@ -71,7 +71,7 @@ export function renderFindings(findings, prose = PROSE.en, totalCount = findings
71
71
  lines.push(` ${finding.detail}`);
72
72
  }
73
73
  if (totalCount > findings.length) {
74
- lines.push(`… ${totalCount - findings.length} further objection(s) held back by adversaryMaxFindings`);
74
+ lines.push(prose.reviewHeldBack(totalCount - findings.length));
75
75
  }
76
76
  lines.push('', prose.reviewFindingsFooter);
77
77
  return lines.join('\n');
@@ -101,7 +101,7 @@ export async function runAdversaryReview(ctx, config, agent, turnSignal) {
101
101
  try {
102
102
  run = await subagents.start(config.adversaryProvider, {
103
103
  label: 'doublecheck-adversary',
104
- prompt: [{ type: 'text', text: CRITIC_TASK }],
104
+ prompt: [{ type: 'text', text: prose.criticTask }],
105
105
  parent: agent,
106
106
  signal,
107
107
  ...config.adversaryModel !== null ? { agentOptions: { model: config.adversaryModel } } : {},
@@ -149,7 +149,7 @@ async function settleResult(run, signal, prose, maxFindings) {
149
149
  if (!Array.isArray(raw)) {
150
150
  return { verdict: 'unavailable', findings: [], text: outputText(result.output, prose.reviewUnavailableNoFindings) };
151
151
  }
152
- const findings = raw.slice(0, maxFindings);
152
+ const findings = sortFindings(raw).slice(0, maxFindings);
153
153
  if (findings.length === 0)
154
154
  return { verdict: 'clean', findings: [], text: prose.reviewClean };
155
155
  return { verdict: 'findings', findings, text: renderFindings(findings, prose, raw.length) };
package/lib/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './domain/evidence.ts';
12
12
  export * from './domain/vagueness.ts';
13
13
  export * from './domain/vocabulary.ts';
14
14
  export * from './domain/report.ts';
15
+ export * from './domain/gate.ts';
15
16
  export { renderSpecMarkdown } from './grill/index.ts';
16
17
  export { BundledSkillProvider, parseSkillAsset, PROVIDER_NAME } from './grill/provider.ts';
17
18
  export type * from './types.ts';
package/lib/index.js CHANGED
@@ -11,5 +11,6 @@ export * from "./domain/evidence.js";
11
11
  export * from "./domain/vagueness.js";
12
12
  export * from "./domain/vocabulary.js";
13
13
  export * from "./domain/report.js";
14
+ export * from "./domain/gate.js";
14
15
  export { renderSpecMarkdown } from "./grill/index.js";
15
16
  export { BundledSkillProvider, parseSkillAsset, PROVIDER_NAME } from "./grill/provider.js";
@@ -14,6 +14,8 @@
14
14
  * the tool result, not this event, so equality is not checkable there).
15
15
  * 3. Every `doublecheck/review` announcement's findings satisfy the
16
16
  * structured finding shape (severity enum, non-empty title and detail).
17
+ * 4. Every `doublecheck/gate` announcement's verdict matches the re-derivation
18
+ * from its own phase results, and all four phases are present.
17
19
  *
18
20
  * The guard registers a facts-bearing installer from its own context; the
19
21
  * `./invariant` export is the standalone companion usable through a separate
package/lib/invariant.js CHANGED
@@ -14,13 +14,17 @@
14
14
  * the tool result, not this event, so equality is not checkable there).
15
15
  * 3. Every `doublecheck/review` announcement's findings satisfy the
16
16
  * structured finding shape (severity enum, non-empty title and detail).
17
+ * 4. Every `doublecheck/gate` announcement's verdict matches the re-derivation
18
+ * from its own phase results, and all four phases are present.
17
19
  *
18
20
  * The guard registers a facts-bearing installer from its own context; the
19
21
  * `./invariant` export is the standalone companion usable through a separate
20
22
  * profile row.
21
23
  * @module dsh-doublecheck/invariant
22
24
  */
25
+ import { SPEC_FIELD_NAMES } from "./domain/vocabulary.js";
23
26
  import { deriveReportVerdict, foldReportFacts } from "./domain/report.js";
27
+ import { deriveGateVerdict, GATE_PHASES } from "./domain/gate.js";
24
28
  /** Full npm package name owning the reported failures. */
25
29
  export const PACKAGE_NAME = 'dsh-doublecheck';
26
30
  /** Cordis companion plugin name. */
@@ -28,9 +32,7 @@ export const name = 'dsh-doublecheck-invariant';
28
32
  /** Service required before the companion can reserve package ownership. */
29
33
  export const inject = ['invariants'];
30
34
  /** All six spec fields, in declaration order. */
31
- const SPEC_FIELDS = [
32
- 'goal', 'scope', 'acceptanceCriteria', 'failureModes', 'priorities', 'nonGoals',
33
- ];
35
+ const SPEC_FIELDS = SPEC_FIELD_NAMES;
34
36
  /**
35
37
  * Build the installer over a facts source. The standalone companion and the
36
38
  * main plugin share this body; only the facts differ.
@@ -81,6 +83,18 @@ export function installInvariant(facts) {
81
83
  }
82
84
  }
83
85
  });
86
+ ctx.on('doublecheck/gate', (payload) => {
87
+ const state = payload.state;
88
+ const derived = deriveGateVerdict(GATE_PHASES.map(phase => state.phases[phase]).filter(phase => phase !== undefined));
89
+ if (derived !== state.verdict) {
90
+ fail(`doublecheck/gate announces verdict "${state.verdict}" but its phases derive "${derived}"`);
91
+ }
92
+ for (const phase of GATE_PHASES) {
93
+ if (state.phases[phase] === undefined) {
94
+ fail(`doublecheck/gate carries no "${phase}" phase result`);
95
+ }
96
+ }
97
+ });
84
98
  };
85
99
  }
86
100
  /**
package/lib/types.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { z as zod } from 'zod';
10
10
  import type { DisciplineStage, TestColor } from './domain/stages.ts';
11
+ import type { GateVerdict } from './domain/gate.ts';
11
12
  /** The whole wire value of the `doublecheck` session projection. */
12
13
  export interface DoublecheckView {
13
14
  /** The last discipline stage reached on durable evidence. */
@@ -22,6 +23,10 @@ export interface DoublecheckView {
22
23
  reviewed: boolean;
23
24
  /** Total implementation edits folded so far. */
24
25
  editCount: number;
26
+ /** The latest delivery-gate verdict, or 'none' before any gate run. */
27
+ gateVerdict: 'none' | GateVerdict;
28
+ /** Red-light (failing) checks of the latest gate run. */
29
+ gateRedCount: number;
25
30
  }
26
31
  /** Validates the `doublecheck` projection's wire payload before it leaves the host. */
27
32
  export declare const doublecheckViewSchema: zod.ZodObject<{
@@ -31,6 +36,8 @@ export declare const doublecheckViewSchema: zod.ZodObject<{
31
36
  specGoal: zod.ZodString;
32
37
  reviewed: zod.ZodBoolean;
33
38
  editCount: zod.ZodNumber;
39
+ gateVerdict: zod.ZodUnion<readonly [zod.ZodLiteral<"none">, zod.ZodLiteral<"deliverable">, zod.ZodLiteral<"rework">]>;
40
+ gateRedCount: zod.ZodNumber;
34
41
  }, zod.core.$strip>;
35
42
  declare module '@deepseek-ai/dsh-session-projection/types' {
36
43
  interface SessionProjectionMap {
package/lib/types.js CHANGED
@@ -15,4 +15,6 @@ export const doublecheckViewSchema = zod.object({
15
15
  specGoal: zod.string(),
16
16
  reviewed: zod.boolean(),
17
17
  editCount: zod.number(),
18
+ gateVerdict: zod.union([zod.literal('none'), zod.literal('deliverable'), zod.literal('rework')]),
19
+ gateRedCount: zod.number(),
18
20
  });