dsh-doublecheck 0.9.24 → 0.9.27

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.
@@ -8,6 +8,12 @@
8
8
  * bump the edit count, and a `doublecheck-review` source record marks the
9
9
  * delivery reviewed. Resumed and forked sessions fold to the same view.
10
10
  *
11
+ * The WIRE value is not this state: it is one bounded single-line string this
12
+ * module renders (`renderDoublecheckLine`), because a JSON object of these
13
+ * eight fields is at best 126 characters and every generic one-line renderer
14
+ * truncates it — the old object view lost `gateVerdict`/`gateRedCount`
15
+ * outright. See {@link DOUBLECHECK_LINE_MAX}.
16
+ *
11
17
  * @module dsh-doublecheck/domain/projection
12
18
  */
13
19
  import type { SessionEvent } from '@deepseek-ai/dsh-session';
@@ -15,6 +21,28 @@ import { type TestRunDetection } from './evidence.js';
15
21
  import { type DisciplineStage, type TestColor } from './stages.js';
16
22
  import { type GateVerdict } from './gate.js';
17
23
  import type { DoublecheckView } from '../types.js';
24
+ /**
25
+ * The hard budget of the wire line, in UTF-16 code units — the same unit
26
+ * `String#length` and every one-line renderer measure.
27
+ *
28
+ * 120 is not arbitrary: the WeChat bridge's generic projection renderer
29
+ * (`dsh-wechat` `renderProjectionValue`) keeps a value only while
30
+ * `value.length <= 120` and otherwise cuts it to 117 units plus `…`. The
31
+ * object-shaped view this projection used to emit was 131 units when EMPTY
32
+ * and 139 with a short goal, so it was truncated for every possible value and
33
+ * the two fields a user reads on a phone (`gateVerdict`, `gateRedCount`) were
34
+ * exactly the ones cut off. A single bounded line keeps the whole payload
35
+ * inside every renderer's first line.
36
+ */
37
+ export declare const DOUBLECHECK_LINE_MAX = 120;
38
+ /**
39
+ * Longest `goal:` excerpt kept on the line, in UTF-16 code units. A goal is
40
+ * free text and is the one lossy field: it is sanitized to one line and
41
+ * clipped (never dropped silently — a clipped excerpt ends with `…`). The
42
+ * three fields a user reads first (`gate:`, `stage:`, `r<red>`) are never
43
+ * clipped.
44
+ */
45
+ export declare const DOUBLECHECK_GOAL_MAX = 40;
18
46
  /** The projection unit's plain-JSON fold state. */
19
47
  export interface DoublecheckProjectionState {
20
48
  /** The last discipline stage a successful call or test run reached. */
@@ -58,8 +86,33 @@ export declare function applyDoublecheckEvent(state: DoublecheckProjectionState,
58
86
  */
59
87
  export declare function foldDoublecheckState(events: readonly SessionEvent[], detection: TestRunDetection): DoublecheckProjectionState;
60
88
  /**
61
- * Project the folded state to the wire payload.
89
+ * Project the folded state to the wire payload: one status line.
90
+ *
91
+ * The line is built in priority order and every part is bounded, so the total
92
+ * can never exceed {@link DOUBLECHECK_LINE_MAX}:
93
+ *
94
+ * 1. `gate:<verdict> r<red>` — the latest gate verdict and its red-light count.
95
+ * 2. `stage:<stage>` — the discipline stage reached on durable evidence.
96
+ * 3. `test:<color> edits:<n> spec:<yes|no> review:<yes|no>` — the supporting facts.
97
+ * 4. `goal:<excerpt>` — only when a spec exists and the excerpt is non-empty;
98
+ * clipped to whatever budget parts 1–3 left, at most
99
+ * {@link DOUBLECHECK_GOAL_MAX} units.
100
+ *
101
+ * The worst case of parts 1–3 is 84 units (`gate:deliverable r9999+`,
102
+ * `stage:design`, `test:green edits:99999+ spec:yes review:yes`), leaving at
103
+ * least 28 units for the goal excerpt — so no part is ever dropped to make
104
+ * room, and a final hard clamp backstops the invariant.
62
105
  * @param state - the folded state.
63
106
  * @returns the whole current value for the `doublecheck` projection key.
64
107
  */
65
108
  export declare function viewDoublecheck(state: DoublecheckProjectionState): DoublecheckView;
109
+ /**
110
+ * Render the fold state as the bounded single-line wire value.
111
+ *
112
+ * Pure and total: any state (including a hand-built or version-mismatched one
113
+ * with absurd counts) yields a one-line string of at most
114
+ * {@link DOUBLECHECK_LINE_MAX} code units, never a throw.
115
+ * @param state - the folded state.
116
+ * @returns one line, `<= DOUBLECHECK_LINE_MAX` code units, with no newline.
117
+ */
118
+ export declare function renderDoublecheckLine(state: DoublecheckProjectionState): string;
@@ -8,11 +8,46 @@
8
8
  * bump the edit count, and a `doublecheck-review` source record marks the
9
9
  * delivery reviewed. Resumed and forked sessions fold to the same view.
10
10
  *
11
+ * The WIRE value is not this state: it is one bounded single-line string this
12
+ * module renders (`renderDoublecheckLine`), because a JSON object of these
13
+ * eight fields is at best 126 characters and every generic one-line renderer
14
+ * truncates it — the old object view lost `gateVerdict`/`gateRedCount`
15
+ * outright. See {@link DOUBLECHECK_LINE_MAX}.
16
+ *
11
17
  * @module dsh-doublecheck/domain/projection
12
18
  */
13
19
  import { isTestCommand, isTestFilePath, joinTextBlocks, mutationTargetPath, parseRawArguments, ptcSettle, shellCommand, testOutcome, } from "./evidence.js";
14
20
  import { SPEC_TOOL_NAME } from "./stages.js";
15
21
  import { countRedChecks } from "./gate.js";
22
+ /**
23
+ * The hard budget of the wire line, in UTF-16 code units — the same unit
24
+ * `String#length` and every one-line renderer measure.
25
+ *
26
+ * 120 is not arbitrary: the WeChat bridge's generic projection renderer
27
+ * (`dsh-wechat` `renderProjectionValue`) keeps a value only while
28
+ * `value.length <= 120` and otherwise cuts it to 117 units plus `…`. The
29
+ * object-shaped view this projection used to emit was 131 units when EMPTY
30
+ * and 139 with a short goal, so it was truncated for every possible value and
31
+ * the two fields a user reads on a phone (`gateVerdict`, `gateRedCount`) were
32
+ * exactly the ones cut off. A single bounded line keeps the whole payload
33
+ * inside every renderer's first line.
34
+ */
35
+ export const DOUBLECHECK_LINE_MAX = 120;
36
+ /**
37
+ * Longest `goal:` excerpt kept on the line, in UTF-16 code units. A goal is
38
+ * free text and is the one lossy field: it is sanitized to one line and
39
+ * clipped (never dropped silently — a clipped excerpt ends with `…`). The
40
+ * three fields a user reads first (`gate:`, `stage:`, `r<red>`) are never
41
+ * clipped.
42
+ */
43
+ export const DOUBLECHECK_GOAL_MAX = 40;
44
+ /** Segment separator: visually distinct on a phone and outside the goal word charset. */
45
+ const SEGMENT = ' | ';
46
+ /** Marks a value this line deliberately shortened. */
47
+ const ELLIPSIS = '…';
48
+ /** Above these counts the line prints `<max>+` instead of the exact number. */
49
+ const RED_COUNT_MAX = 9999;
50
+ const EDIT_COUNT_MAX = 99999;
16
51
  /** State for the empty log. */
17
52
  export function emptyDoublecheckState() {
18
53
  return {
@@ -130,19 +165,122 @@ export function foldDoublecheckState(events, detection) {
130
165
  return state;
131
166
  }
132
167
  /**
133
- * Project the folded state to the wire payload.
168
+ * Project the folded state to the wire payload: one status line.
169
+ *
170
+ * The line is built in priority order and every part is bounded, so the total
171
+ * can never exceed {@link DOUBLECHECK_LINE_MAX}:
172
+ *
173
+ * 1. `gate:<verdict> r<red>` — the latest gate verdict and its red-light count.
174
+ * 2. `stage:<stage>` — the discipline stage reached on durable evidence.
175
+ * 3. `test:<color> edits:<n> spec:<yes|no> review:<yes|no>` — the supporting facts.
176
+ * 4. `goal:<excerpt>` — only when a spec exists and the excerpt is non-empty;
177
+ * clipped to whatever budget parts 1–3 left, at most
178
+ * {@link DOUBLECHECK_GOAL_MAX} units.
179
+ *
180
+ * The worst case of parts 1–3 is 84 units (`gate:deliverable r9999+`,
181
+ * `stage:design`, `test:green edits:99999+ spec:yes review:yes`), leaving at
182
+ * least 28 units for the goal excerpt — so no part is ever dropped to make
183
+ * room, and a final hard clamp backstops the invariant.
134
184
  * @param state - the folded state.
135
185
  * @returns the whole current value for the `doublecheck` projection key.
136
186
  */
137
187
  export function viewDoublecheck(state) {
138
- return {
139
- stage: state.stage,
140
- color: state.color,
141
- hasSpec: state.hasSpec,
142
- specGoal: state.specGoal,
143
- reviewed: state.reviewed,
144
- editCount: state.editCount,
145
- gateVerdict: state.gateVerdict,
146
- gateRedCount: state.gateRedCount,
147
- };
188
+ return renderDoublecheckLine(state);
189
+ }
190
+ /**
191
+ * Render the fold state as the bounded single-line wire value.
192
+ *
193
+ * Pure and total: any state (including a hand-built or version-mismatched one
194
+ * with absurd counts) yields a one-line string of at most
195
+ * {@link DOUBLECHECK_LINE_MAX} code units, never a throw.
196
+ * @param state - the folded state.
197
+ * @returns one line, `<= DOUBLECHECK_LINE_MAX` code units, with no newline.
198
+ */
199
+ export function renderDoublecheckLine(state) {
200
+ const head = [
201
+ `gate:${state.gateVerdict} r${boundedCount(state.gateRedCount, RED_COUNT_MAX)}`,
202
+ `stage:${state.stage}`,
203
+ `test:${state.color} edits:${boundedCount(state.editCount, EDIT_COUNT_MAX)}`
204
+ + ` spec:${state.hasSpec ? 'yes' : 'no'} review:${state.reviewed ? 'yes' : 'no'}`,
205
+ ].join(SEGMENT);
206
+ // A goal without a committed spec would contradict `spec:no`.
207
+ const goal = state.hasSpec ? sanitizeGoal(state.specGoal) : '';
208
+ if (goal === '')
209
+ return clampLine(head);
210
+ const budget = DOUBLECHECK_LINE_MAX - head.length - SEGMENT.length - 'goal:'.length;
211
+ if (budget <= 0)
212
+ return clampLine(head);
213
+ const excerpt = capText(goal, Math.min(budget, DOUBLECHECK_GOAL_MAX));
214
+ if (excerpt === '')
215
+ return clampLine(head);
216
+ return clampLine(`${head}${SEGMENT}goal:${excerpt}`);
217
+ }
218
+ /**
219
+ * Render a count for the line: non-finite and negative inputs fold to `0`, and
220
+ * a count above `maximum` prints `<maximum>+` so the field stays bounded.
221
+ * @param value - the folded count.
222
+ * @param maximum - the largest exact count the line prints.
223
+ * @returns a bounded decimal string.
224
+ */
225
+ function boundedCount(value, maximum) {
226
+ if (!Number.isFinite(value))
227
+ return '0';
228
+ const count = Math.max(0, Math.trunc(value));
229
+ return count > maximum ? `${maximum}+` : String(count);
230
+ }
231
+ /**
232
+ * Flatten a free-text goal to one safe line: control characters (including
233
+ * newlines and the C1 range) become spaces, the segment separator becomes `/`,
234
+ * whitespace runs collapse, and the result is trimmed. The wire value must be
235
+ * ONE line — the WeChat renderer keeps only the first line of a multi-line
236
+ * value. `\p{Cc}` is the Unicode control category (U+0000–U+001F and
237
+ * U+007F–U+009F), so no literal control character appears in this pattern.
238
+ * @param goal - the raw committed spec goal.
239
+ * @returns the sanitized single-line goal, possibly empty.
240
+ */
241
+ function sanitizeGoal(goal) {
242
+ return goal
243
+ .replace(/\p{Cc}/gu, ' ')
244
+ .replace(/\|/gu, '/')
245
+ .replace(/\s+/gu, ' ')
246
+ .trim();
247
+ }
248
+ /**
249
+ * Cut `text` to at most `budget` code units, marking a real cut with `…` and
250
+ * never splitting a surrogate pair.
251
+ * @param text - the text to bound.
252
+ * @param budget - the maximum length of the result.
253
+ * @returns the text, or a `…`-terminated prefix of it.
254
+ */
255
+ function capText(text, budget) {
256
+ if (budget <= 0)
257
+ return '';
258
+ if (text.length <= budget)
259
+ return text;
260
+ if (budget <= ELLIPSIS.length)
261
+ return ELLIPSIS;
262
+ return `${text.slice(0, safeEnd(text, budget - ELLIPSIS.length))}${ELLIPSIS}`;
263
+ }
264
+ /**
265
+ * Backstop for {@link DOUBLECHECK_LINE_MAX}: the line is bounded by
266
+ * construction, so this is unreachable today and exists so a future field
267
+ * cannot escape the budget silently.
268
+ * @param line - the assembled line.
269
+ * @returns the line, or a `…`-terminated prefix within budget.
270
+ */
271
+ function clampLine(line) {
272
+ return capText(line, DOUBLECHECK_LINE_MAX);
273
+ }
274
+ /**
275
+ * The largest cut index that does not split a UTF-16 surrogate pair: a lone
276
+ * surrogate is rejected by the WeChat transport (`ret=-1 invalid request`).
277
+ * @param text - the text being cut.
278
+ * @param end - the proposed cut index.
279
+ * @returns `end`, or `end - 1` when it would split a pair.
280
+ */
281
+ function safeEnd(text, end) {
282
+ if (end <= 0)
283
+ return 0;
284
+ const previous = text.charCodeAt(end - 1);
285
+ return previous >= 0xD800 && previous <= 0xDBFF ? end - 1 : end;
148
286
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Minimal shape of the 0.2.1+ `subagents.startActivation` surface. The alpha
3
+ * host line only exposes `start(provider, request)`; keeping this structural
4
+ * lets the same guard code compile against both.
5
+ */
6
+ import type { Agent } from '@deepseek-ai/dsh-agent';
7
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
8
+ import type { SessionId } from '@deepseek-ai/dsh-session';
9
+ export interface GuardActivationService {
10
+ startActivation?: (spec: {
11
+ provider: string;
12
+ label: string;
13
+ request: {
14
+ prompt: ContentBlock[];
15
+ parent: Agent;
16
+ outputSchema: unknown;
17
+ maxDepth?: number;
18
+ agentOptions?: Record<string, unknown>;
19
+ agentPreset: string;
20
+ };
21
+ signal: AbortSignal;
22
+ delivery: 'caller';
23
+ }) => Promise<{
24
+ childId: SessionId;
25
+ result: Promise<{
26
+ stopReason: string;
27
+ structured?: unknown;
28
+ }>;
29
+ dispose(): Promise<void>;
30
+ }>;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -38,6 +38,8 @@ export interface GateReviewerConfig {
38
38
  provider: string;
39
39
  /** Model route for the local reviewer; null means the main model reviews. */
40
40
  model: string | null;
41
+ /** Read-only Agent Preset for the startActivation path. */
42
+ preset?: string;
41
43
  /** Tools the local reviewer may call (read-only by default). */
42
44
  tools: string[];
43
45
  /** Hard time budget for one local reviewer run. */
package/lib/guard/gate.js CHANGED
@@ -114,6 +114,7 @@ export const GateConfigSchema = z.object({
114
114
  consistency: z.object({
115
115
  enabled: z.boolean().default(true),
116
116
  provider: z.string().min(1).default('fork'),
117
+ preset: z.string(),
117
118
  model: z.union([z.string(), z.const(null)]).default(null),
118
119
  tools: z.array(z.string()).default(['read', 'glob', 'grep']),
119
120
  timeoutMs: z.number().default(120000),
@@ -123,6 +124,7 @@ export const GateConfigSchema = z.object({
123
124
  enabled: z.boolean().default(true),
124
125
  engine: z.union(['auto', 'local']).default('auto'),
125
126
  provider: z.string().min(1).default('fork'),
127
+ preset: z.string(),
126
128
  model: z.union([z.string(), z.const(null)]).default(null),
127
129
  tools: z.array(z.string()).default(['read', 'glob', 'grep']),
128
130
  timeoutMs: z.number().default(120000),
@@ -248,15 +250,37 @@ async function runLocalReviewer(ctx, reviewer, agent, task, label, signal) {
248
250
  const runSignal = AbortSignal.any([signal, timeout]);
249
251
  let run;
250
252
  try {
251
- run = await subagents.start(reviewer.provider, {
252
- label,
253
- prompt: [{ type: 'text', text: task }],
254
- parent: agent,
255
- signal: runSignal,
256
- ...reviewer.model !== null ? { agentOptions: { model: reviewer.model } } : {},
257
- outputSchema: GATE_FINDINGS_SCHEMA,
258
- toolFilter: { allow: reviewer.tools },
259
- });
253
+ const activation = subagents;
254
+ if (typeof activation.startActivation === 'function') {
255
+ if (reviewer.preset === undefined) {
256
+ return { findings: null, unavailable: 'this host uses subagents.startActivation; set the reviewer preset to a read-only Agent Preset' };
257
+ }
258
+ const started = await activation.startActivation({
259
+ provider: reviewer.provider,
260
+ label,
261
+ request: {
262
+ prompt: [{ type: 'text', text: task }],
263
+ parent: agent,
264
+ outputSchema: GATE_FINDINGS_SCHEMA,
265
+ agentPreset: reviewer.preset,
266
+ ...reviewer.model !== null ? { agentOptions: { model: reviewer.model } } : {},
267
+ },
268
+ signal: runSignal,
269
+ delivery: 'caller',
270
+ });
271
+ run = started;
272
+ }
273
+ else {
274
+ run = await subagents.start(reviewer.provider, {
275
+ label,
276
+ prompt: [{ type: 'text', text: task }],
277
+ parent: agent,
278
+ signal: runSignal,
279
+ ...reviewer.model !== null ? { agentOptions: { model: reviewer.model } } : {},
280
+ outputSchema: GATE_FINDINGS_SCHEMA,
281
+ toolFilter: { allow: reviewer.tools },
282
+ });
283
+ }
260
284
  }
261
285
  catch (error) {
262
286
  return { findings: null, unavailable: String(error) };
@@ -58,6 +58,8 @@ export interface Config {
58
58
  adversaryModel: string | null;
59
59
  /** Subagent provider name the critic runs on (default `fork`). */
60
60
  adversaryProvider: string;
61
+ /** Read-only Agent Preset for hosts that use startActivation. */
62
+ adversaryPreset?: string;
61
63
  /** Maximum structured findings injected into the session. */
62
64
  adversaryMaxFindings: number;
63
65
  /** Tools the critic may call (read-only by default; never mutation tools). */
@@ -125,6 +127,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
125
127
  }>>, "defined">;
126
128
  adversaryModel: z<string | null, string | null, "defined">;
127
129
  adversaryProvider: z<string, string, "defined">;
130
+ adversaryPreset: z<string, string, "plain">;
128
131
  adversaryMaxFindings: z<number, number, "defined">;
129
132
  adversaryTools: z<string[], string[], "defined">;
130
133
  adversaryTimeoutMs: z<number, number, "defined">;
@@ -150,6 +153,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
150
153
  }>>, "defined">;
151
154
  adversaryModel: z<string | null, string | null, "defined">;
152
155
  adversaryProvider: z<string, string, "defined">;
156
+ adversaryPreset: z<string, string, "plain">;
153
157
  adversaryMaxFindings: z<number, number, "defined">;
154
158
  adversaryTools: z<string[], string[], "defined">;
155
159
  adversaryTimeoutMs: z<number, number, "defined">;
@@ -94,6 +94,7 @@ export const Config = z.object({
94
94
  }).default({ grill: true, tdd: true, adversary: false }),
95
95
  adversaryModel: z.union([z.string(), z.const(null)]).default(null),
96
96
  adversaryProvider: z.string().min(1).default('fork'),
97
+ adversaryPreset: z.string(),
97
98
  adversaryMaxFindings: z.number().default(5),
98
99
  adversaryTools: z.array(z.string()).default(['read', 'glob', 'grep']),
99
100
  adversaryTimeoutMs: z.number().default(120000),
@@ -577,7 +578,8 @@ export function apply(ctx, config) {
577
578
  }
578
579
  });
579
580
  // The `doublecheck` session projection: folds the same durable discipline
580
- // facts the guard reads into a plain-JSON wire value a client renders.
581
+ // facts the guard reads into the wire value a client renders — one bounded
582
+ // status line (`renderDoublecheckLine`), not a JSON object.
581
583
  // Activates only when a projection registry is composed.
582
584
  ctx.inject(['sessionProjections'], (projectionCtx) => {
583
585
  const registry = projectionCtx.get('sessionProjections');
@@ -597,7 +599,16 @@ export function apply(ctx, config) {
597
599
  viewSchema: doublecheckViewSchema,
598
600
  view: viewDoublecheck,
599
601
  },
600
- stateVersion: 2,
602
+ // v3: the wire value changed type (object -> one bounded line). The
603
+ // bump is the persisted-cache invalidation the registry documents, and
604
+ // it IS the backward-compatibility story: the state fields themselves
605
+ // are unchanged and still pass `doublecheckStateSchema`, but a row
606
+ // written at `ver: 2` is discarded on a `ver: 3` unit and the key
607
+ // refolds from `init` over the full log (the host's `restoreFloor`
608
+ // drops to seq 0 on any version mismatch). No migration code, and a
609
+ // pre-upgrade session degrades to a recomputed line instead of a
610
+ // stale one or a crash.
611
+ stateVersion: 3,
601
612
  }), 'dsh-doublecheck: projection');
602
613
  });
603
614
  // The invariant companion, registered from the main plugin so its checks
@@ -616,6 +627,7 @@ export function apply(ctx, config) {
616
627
  function adversaryConfig(config) {
617
628
  return {
618
629
  adversaryProvider: config.adversaryProvider,
630
+ adversaryPreset: config.adversaryPreset,
619
631
  adversaryModel: config.adversaryModel ?? null,
620
632
  adversaryMaxFindings: config.adversaryMaxFindings,
621
633
  adversaryTools: config.adversaryTools,
@@ -21,6 +21,8 @@ import { type GuardProse, type ProseLanguage } from './prose.js';
21
21
  /** The adversary knobs the review runner reads. */
22
22
  export interface AdversaryConfig {
23
23
  adversaryProvider: string;
24
+ /** Read-only Agent Preset for the startActivation path. */
25
+ adversaryPreset?: string;
24
26
  adversaryModel: string | null;
25
27
  adversaryMaxFindings: number;
26
28
  adversaryTools: string[];
@@ -99,15 +99,37 @@ export async function runAdversaryReview(ctx, config, agent, turnSignal) {
99
99
  const signal = turnSignal === undefined ? timeout : AbortSignal.any([turnSignal, timeout]);
100
100
  let run;
101
101
  try {
102
- run = await subagents.start(config.adversaryProvider, {
103
- label: 'doublecheck-adversary',
104
- prompt: [{ type: 'text', text: prose.criticTask }],
105
- parent: agent,
106
- signal,
107
- ...config.adversaryModel !== null ? { agentOptions: { model: config.adversaryModel } } : {},
108
- outputSchema: REVIEW_OUTPUT_SCHEMA,
109
- toolFilter: { allow: config.adversaryTools },
110
- });
102
+ const activation = subagents;
103
+ if (typeof activation.startActivation === 'function') {
104
+ if (config.adversaryPreset === undefined) {
105
+ return { verdict: 'unavailable', findings: [], text: prose.reviewUnavailableFailed('this host uses subagents.startActivation; set adversaryPreset to a read-only Agent Preset') };
106
+ }
107
+ const started = await activation.startActivation({
108
+ provider: config.adversaryProvider,
109
+ label: 'doublecheck-adversary',
110
+ request: {
111
+ prompt: [{ type: 'text', text: prose.criticTask }],
112
+ parent: agent,
113
+ outputSchema: REVIEW_OUTPUT_SCHEMA,
114
+ agentPreset: config.adversaryPreset,
115
+ ...config.adversaryModel !== null ? { agentOptions: { model: config.adversaryModel } } : {},
116
+ },
117
+ signal,
118
+ delivery: 'caller',
119
+ });
120
+ run = started;
121
+ }
122
+ else {
123
+ run = await subagents.start(config.adversaryProvider, {
124
+ label: 'doublecheck-adversary',
125
+ prompt: [{ type: 'text', text: prose.criticTask }],
126
+ parent: agent,
127
+ signal,
128
+ ...config.adversaryModel !== null ? { agentOptions: { model: config.adversaryModel } } : {},
129
+ outputSchema: REVIEW_OUTPUT_SCHEMA,
130
+ toolFilter: { allow: config.adversaryTools },
131
+ });
132
+ }
111
133
  }
112
134
  catch (error) {
113
135
  // Provider absence, capability rejection, or a child that could not be
package/lib/types.d.ts CHANGED
@@ -7,39 +7,25 @@
7
7
  * @module dsh-doublecheck/types
8
8
  */
9
9
  import { z as zod } from 'zod';
10
- import type { DisciplineStage, TestColor } from './domain/stages.js';
11
- import type { GateVerdict } from './domain/gate.js';
12
10
  import type { DoublecheckProjectionState } from './domain/projection.js';
13
- /** The whole wire value of the `doublecheck` session projection. */
14
- export interface DoublecheckView {
15
- /** The last discipline stage reached on durable evidence. */
16
- stage: DisciplineStage;
17
- /** The latest test-run evidence color. */
18
- color: TestColor;
19
- /** Whether a committed requirements spec exists in the log. */
20
- hasSpec: boolean;
21
- /** The goal of the latest committed spec, or '' before any spec. */
22
- specGoal: string;
23
- /** Whether an adversary review record exists in the log. */
24
- reviewed: boolean;
25
- /** Total implementation edits folded so far. */
26
- editCount: number;
27
- /** The latest delivery-gate verdict, or 'none' before any gate run. */
28
- gateVerdict: 'none' | GateVerdict;
29
- /** Red-light (failing) checks of the latest gate run. */
30
- gateRedCount: number;
31
- }
11
+ /**
12
+ * The whole wire value of the `doublecheck` session projection: ONE bounded
13
+ * status line, e.g.
14
+ *
15
+ * ```text
16
+ * gate:rework r2 | stage:green | test:green edits:12 spec:yes review:yes | goal:ship parser fix
17
+ * ```
18
+ *
19
+ * It is a string rather than an object on purpose. The eight state fields cost
20
+ * 126 characters as compact JSON even at their shortest possible values, so no
21
+ * object-shaped payload can survive a renderer that keeps one line under 121
22
+ * characters — which is exactly how `gateVerdict` and `gateRedCount` used to
23
+ * disappear. `renderDoublecheckLine` (`./domain/projection.ts`) owns the
24
+ * format and the {@link DOUBLECHECK_LINE_MAX} budget.
25
+ */
26
+ export type DoublecheckView = string;
32
27
  /** Validates the `doublecheck` projection's wire payload before it leaves the host. */
33
- export declare const doublecheckViewSchema: zod.ZodObject<{
34
- stage: zod.ZodUnion<readonly [zod.ZodLiteral<"grill">, zod.ZodLiteral<"design">, zod.ZodLiteral<"red">, zod.ZodLiteral<"green">, zod.ZodLiteral<"review">, zod.ZodLiteral<"verify">]>;
35
- color: zod.ZodUnion<readonly [zod.ZodLiteral<"none">, zod.ZodLiteral<"red">, zod.ZodLiteral<"green">]>;
36
- hasSpec: zod.ZodBoolean;
37
- specGoal: zod.ZodString;
38
- reviewed: zod.ZodBoolean;
39
- editCount: zod.ZodNumber;
40
- gateVerdict: zod.ZodUnion<readonly [zod.ZodLiteral<"none">, zod.ZodLiteral<"deliverable">, zod.ZodLiteral<"rework">]>;
41
- gateRedCount: zod.ZodNumber;
42
- }, zod.core.$strip>;
28
+ export declare const doublecheckViewSchema: zod.ZodString;
43
29
  /** Validates the persisted `doublecheck` projection state before it seeds a fold. */
44
30
  export declare const doublecheckStateSchema: zod.ZodObject<{
45
31
  stage: zod.ZodUnion<readonly [zod.ZodLiteral<"grill">, zod.ZodLiteral<"design">, zod.ZodLiteral<"red">, zod.ZodLiteral<"green">, zod.ZodLiteral<"review">, zod.ZodLiteral<"verify">]>;
package/lib/types.js CHANGED
@@ -7,17 +7,11 @@
7
7
  * @module dsh-doublecheck/types
8
8
  */
9
9
  import { z as zod } from 'zod';
10
+ // Value import (the one runtime edge in this type table): the wire line's
11
+ // budget is declared once, next to the renderer that must honor it.
12
+ import { DOUBLECHECK_LINE_MAX } from "./domain/projection.js";
10
13
  /** Validates the `doublecheck` projection's wire payload before it leaves the host. */
11
- export const doublecheckViewSchema = zod.object({
12
- stage: zod.union([zod.literal('grill'), zod.literal('design'), zod.literal('red'), zod.literal('green'), zod.literal('review'), zod.literal('verify')]),
13
- color: zod.union([zod.literal('none'), zod.literal('red'), zod.literal('green')]),
14
- hasSpec: zod.boolean(),
15
- specGoal: zod.string(),
16
- reviewed: zod.boolean(),
17
- editCount: zod.number(),
18
- gateVerdict: zod.union([zod.literal('none'), zod.literal('deliverable'), zod.literal('rework')]),
19
- gateRedCount: zod.number(),
20
- });
14
+ export const doublecheckViewSchema = zod.string().max(DOUBLECHECK_LINE_MAX);
21
15
  /** Validates the persisted `doublecheck` projection state before it seeds a fold. */
22
16
  export const doublecheckStateSchema = zod.object({
23
17
  stage: zod.union([zod.literal('grill'), zod.literal('design'), zod.literal('red'), zod.literal('green'), zod.literal('review'), zod.literal('verify')]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-doublecheck",
3
- "version": "0.9.24",
3
+ "version": "0.9.27",
4
4
  "description": "Delivery quality gate for DeepSeek Harness: grill the requirements, test the implementation, prove the delivery, and gate the handoff with a deliverable/rework decision.",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "repository": {