dsh-doublecheck 0.5.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +252 -0
  4. package/README.hi.md +252 -0
  5. package/README.md +268 -0
  6. package/README.pt.md +252 -0
  7. package/README.zh.md +266 -0
  8. package/cordis.patch.yml +19 -0
  9. package/lib/domain/evidence.d.ts +100 -0
  10. package/lib/domain/evidence.js +180 -0
  11. package/lib/domain/projection.d.ts +60 -0
  12. package/lib/domain/projection.js +132 -0
  13. package/lib/domain/report.d.ts +93 -0
  14. package/lib/domain/report.js +266 -0
  15. package/lib/domain/stages.d.ts +98 -0
  16. package/lib/domain/stages.js +192 -0
  17. package/lib/domain/vagueness.d.ts +25 -0
  18. package/lib/domain/vagueness.js +56 -0
  19. package/lib/domain/vocabulary.d.ts +50 -0
  20. package/lib/domain/vocabulary.js +16 -0
  21. package/lib/events.d.ts +155 -0
  22. package/lib/events.js +15 -0
  23. package/lib/grill/index.d.ts +49 -0
  24. package/lib/grill/index.js +495 -0
  25. package/lib/grill/provider.d.ts +56 -0
  26. package/lib/grill/provider.js +189 -0
  27. package/lib/guard/command.d.ts +47 -0
  28. package/lib/guard/command.js +132 -0
  29. package/lib/guard/index.d.ts +111 -0
  30. package/lib/guard/index.js +516 -0
  31. package/lib/guard/prose.d.ts +34 -0
  32. package/lib/guard/prose.js +80 -0
  33. package/lib/guard/review.d.ts +67 -0
  34. package/lib/guard/review.js +184 -0
  35. package/lib/index.d.ts +17 -0
  36. package/lib/index.js +15 -0
  37. package/lib/invariant.d.ts +56 -0
  38. package/lib/invariant.js +112 -0
  39. package/lib/types.d.ts +39 -0
  40. package/lib/types.js +18 -0
  41. package/package.json +109 -0
  42. package/skills/delivery-proof/SKILL.md +52 -0
  43. package/skills/delivery-review/SKILL.md +49 -0
  44. package/skills/grill-requirements/SKILL.md +98 -0
  45. package/skills/red-green-tdd/SKILL.md +57 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * The discipline guard: dsh-doublecheck's soft enforcement plugin.
3
+ *
4
+ * Two gates compose on the documented `tools/pre-execute` /
5
+ * `tools/post-execute` / `agent/turn-stopping` extension points, both reading
6
+ * their facts from the durable session log alone:
7
+ *
8
+ * - **Grill gate** (`modules.grill`): a vague task with no committed
9
+ * `doublecheck_spec` may not mutate implementation files.
10
+ * - **Red/green gate** (`modules.tdd`, v0.2): an implementation edit requires
11
+ * a failing test run on record since the last passing run (the red step);
12
+ * at turn end, edits without a passing run re-arm the green reminder.
13
+ * Writing test files is always allowed — that is how the red step happens.
14
+ *
15
+ * The configured `intensity` picks the consequence for both gates:
16
+ *
17
+ * - `remind`: the call proceeds; a reminder rides the call's
18
+ * `additionalContexts`, so the agent loop records it as a `user/message`
19
+ * session event (model-visible ⟺ logged).
20
+ * - `warn`: the call is held for human approval through the approval seam
21
+ * (`ask`); without an approval channel it denies.
22
+ * - `block`: the call is denied with corrective feedback.
23
+ *
24
+ * Resumed and forked sessions enforce identically. The package-internal
25
+ * `doublecheck/reminder` event announces each reaction for observers.
26
+ *
27
+ * @module dsh-doublecheck/guard
28
+ */
29
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
30
+ import z from '@deepseek-ai/schemastery';
31
+ import { DEFAULT_MUTATION_TOOLS, DEFAULT_TEST_COMMAND_PATTERNS, DEFAULT_TEST_FILE_PATTERNS, DEFAULT_TEST_TOOL_NAMES, compileDetection, isTestFilePath, mutationTargetPath, parseRawArguments, } from "../domain/evidence.js";
32
+ import { emptyDisciplineState, foldDisciplineRange } from "../domain/stages.js";
33
+ import { isVagueTask } from "../domain/vagueness.js";
34
+ import { runAdversaryReview, reviewInjection } from "./review.js";
35
+ import { PROSE } from "./prose.js";
36
+ import { applyDoublecheckEvent, emptyDoublecheckState, viewDoublecheck, } from "../domain/projection.js";
37
+ import { doublecheckViewSchema } from "../types.js";
38
+ import { installInvariant, PACKAGE_NAME } from "../invariant.js";
39
+ import { doublecheckHandler, effectiveDoublecheckEnabled, hostStampsIgnorable } from "./command.js";
40
+ export const name = 'doublecheck-guard';
41
+ export const inject = ['commands'];
42
+ export const Config = z.object({
43
+ intensity: z.union(['remind', 'warn', 'block']).default('remind'),
44
+ modules: z.object({
45
+ grill: z.boolean().default(true),
46
+ tdd: z.boolean().default(true),
47
+ adversary: z.boolean().default(false),
48
+ }).default({ grill: true, tdd: true, adversary: false }),
49
+ adversaryModel: z.union([z.string(), z.const(null)]).default(null),
50
+ adversaryProvider: z.string().min(1).default('fork'),
51
+ adversaryMaxFindings: z.number().default(5),
52
+ adversaryTools: z.array(z.string()).default(['read', 'glob', 'grep']),
53
+ adversaryTimeoutMs: z.number().default(120000),
54
+ guardTools: z.array(z.string()).default([...DEFAULT_MUTATION_TOOLS]),
55
+ vagueTaskMaxChars: z.number().default(200),
56
+ remindOnce: z.boolean().default(true),
57
+ language: z.union(['en', 'zh']).default('en'),
58
+ enableByDefault: z.boolean().default(true),
59
+ testToolNames: z.array(z.string()).default([...DEFAULT_TEST_TOOL_NAMES]),
60
+ testCommandPatterns: z.array(z.string()).default([...DEFAULT_TEST_COMMAND_PATTERNS]),
61
+ testFilePatterns: z.array(z.string()).default([...DEFAULT_TEST_FILE_PATTERNS]),
62
+ });
63
+ /**
64
+ * Install the guard listeners.
65
+ * @param ctx - plugin context; listeners unwind with it.
66
+ * @param config - validated {@link Config}; reserved-module misuse fails loud here.
67
+ */
68
+ export function apply(ctx, config) {
69
+ assertPositiveInteger('vagueTaskMaxChars', config.vagueTaskMaxChars);
70
+ assertGuardTools(config.guardTools);
71
+ if (config.modules.adversary) {
72
+ assertBoundedInteger('adversaryMaxFindings', config.adversaryMaxFindings, 20);
73
+ assertPositiveInteger('adversaryTimeoutMs', config.adversaryTimeoutMs);
74
+ assertNonEmptyNames('adversaryTools', config.adversaryTools);
75
+ // The subagents seam itself is checked at review time, not here: row load
76
+ // order means the provider may simply not have activated yet, and a
77
+ // missing seam then settles as an honest "unavailable" notice.
78
+ }
79
+ const guardToolSet = new Set(config.guardTools);
80
+ // The detection knobs always compile, so a bad regex or an empty name list
81
+ // fails loud at load even when no module consumes them yet (a later
82
+ // `/doublecheck report` and the delivery gate read the same compiled table).
83
+ const detection = compileDetection({
84
+ testToolNames: config.testToolNames,
85
+ testCommandPatterns: config.testCommandPatterns,
86
+ guardTools: config.guardTools,
87
+ testFilePatterns: config.testFilePatterns,
88
+ });
89
+ const snapshots = new WeakMap();
90
+ /** Reminder queued at pre-execute, attached to the same execution at post-execute. */
91
+ const pendingReminders = new WeakMap();
92
+ /** Process-local switch overrides written by `/doublecheck on|off` on hosts that cannot store the durable event. */
93
+ const localOverrides = new WeakMap();
94
+ /** The effective switch for a session: the local override first, then the durable log fold. */
95
+ function doublecheckEnabled(session) {
96
+ return localOverrides.get(session) ?? effectiveDoublecheckEnabled(session.events, config.enableByDefault);
97
+ }
98
+ const prose = PROSE[config.language];
99
+ /**
100
+ * Fold `events[start..end)` into the snapshot; each event is folded once
101
+ * per session. Beyond the discipline fold, this tracks the durable
102
+ * once-semantics facts: reminder notices and review records ride the log as
103
+ * `user/message` sources, so `remindOnce` and the once-per-green review
104
+ * survive restarts, resumes, and forks without process memory.
105
+ */
106
+ function foldEvents(snapshot, events, start, end) {
107
+ foldDisciplineRange(snapshot.discipline, events, start, detection);
108
+ for (let index = start; index < end; index += 1) {
109
+ const event = events[index];
110
+ if (event === undefined)
111
+ continue;
112
+ if (event.type === 'user/message') {
113
+ const source = event.data.source;
114
+ if (source.kind === 'plugin' && source.plugin === 'dsh-doublecheck' && source.form === 'notice') {
115
+ if (source.summary === 'requirements check')
116
+ snapshot.grillReminded = true;
117
+ else if (source.summary === 'red/green check')
118
+ snapshot.redReminded = true;
119
+ else if (source.summary === 'green gate')
120
+ snapshot.greenReminded = true;
121
+ else if (source.summary === 'delivery report')
122
+ snapshot.reportReminded = true;
123
+ }
124
+ else if (source.kind === 'doublecheck-review') {
125
+ snapshot.lastReviewSeq = event.seq;
126
+ snapshot.editsAfterReview = 0;
127
+ }
128
+ else if (source.kind === 'user') {
129
+ const parts = [];
130
+ for (const block of event.data.content) {
131
+ if (block.type === 'text')
132
+ parts.push(block.text);
133
+ }
134
+ if (parts.length > 0) {
135
+ snapshot.latestUserText = parts.join('\n');
136
+ snapshot.vague = isVagueTask(snapshot.latestUserText, config);
137
+ snapshot.lastTaskSeq = event.seq;
138
+ }
139
+ }
140
+ }
141
+ else if (event.type === 'tool/call' && event.seq > snapshot.lastReviewSeq) {
142
+ // Implementation edits after the latest review re-arm the adversary
143
+ // trigger for a second review round.
144
+ const args = parseRawArguments(event.data.arguments);
145
+ const path = mutationTargetPath(event.data.name, args, detection);
146
+ if (path !== undefined && !isTestFilePath(path, detection)) {
147
+ snapshot.editsAfterReview += 1;
148
+ }
149
+ }
150
+ }
151
+ snapshot.scanned = end;
152
+ }
153
+ /** Start a fresh fold over the whole current log. */
154
+ function refold(session, events) {
155
+ const snapshot = {
156
+ events,
157
+ scanned: 0,
158
+ latestUserText: '',
159
+ vague: false,
160
+ lastTaskSeq: 0,
161
+ discipline: emptyDisciplineState(),
162
+ grillReminded: false,
163
+ redReminded: false,
164
+ greenReminded: false,
165
+ reportReminded: false,
166
+ lastReviewSeq: -1,
167
+ editsAfterReview: 0,
168
+ };
169
+ foldEvents(snapshot, events, 0, events.length);
170
+ snapshots.set(session, snapshot);
171
+ return snapshot;
172
+ }
173
+ /**
174
+ * Fold the session log to its current guard facts. The log is append-only
175
+ * and `session.events` is an immutable snapshot replaced on each append, so
176
+ * a resumed fold reuses the already-folded prefix: a per-call read costs
177
+ * O(new events) instead of rescanning the whole log.
178
+ */
179
+ function snapshotOf(session) {
180
+ const events = session.events;
181
+ const cached = snapshots.get(session);
182
+ if (cached !== undefined) {
183
+ if (cached.events === events && events.length === cached.scanned)
184
+ return cached;
185
+ if (events.length < cached.scanned)
186
+ return refold(session, events);
187
+ foldEvents(cached, events, cached.scanned, events.length);
188
+ cached.events = events;
189
+ return cached;
190
+ }
191
+ return refold(session, events);
192
+ }
193
+ /** A plugin-sourced notice message carrying the given prose. */
194
+ function notice(summary, text) {
195
+ const source = {
196
+ kind: 'plugin',
197
+ plugin: 'dsh-doublecheck',
198
+ form: 'notice',
199
+ summary,
200
+ };
201
+ return createUserMessage({ content: [{ type: 'text', text }], source });
202
+ }
203
+ /** The grill reminder for this reaction, or undefined when already reminded. */
204
+ function nextGrillReminder(snapshot) {
205
+ if (config.remindOnce && snapshot.grillReminded)
206
+ return undefined;
207
+ snapshot.grillReminded = true;
208
+ return notice('requirements check', prose.grillReminder);
209
+ }
210
+ /** The red-gate reminder for this reaction, or undefined when already reminded. */
211
+ function nextTddRedReminder(snapshot) {
212
+ if (config.remindOnce && snapshot.redReminded)
213
+ return undefined;
214
+ snapshot.redReminded = true;
215
+ return notice('red/green check', prose.tddReminder);
216
+ }
217
+ // The session command stays available even when every module is off:
218
+ // `/doublecheck on` is how a disabled session re-enables the gates.
219
+ ctx.commands.register({
220
+ name: 'doublecheck',
221
+ description: 'inspect or switch the dsh-doublecheck discipline gates for this session',
222
+ input: { hint: 'status|report|on|off' },
223
+ handler: doublecheckHandler({
224
+ config,
225
+ snapshotOf,
226
+ detection,
227
+ stampsIgnorable: hostStampsIgnorable,
228
+ setLocalOverride: (session, enabled) => localOverrides.set(session, enabled),
229
+ }),
230
+ });
231
+ 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');
233
+ return;
234
+ }
235
+ // The policy gate. Observe-and-decide: the grill gate owns vague,
236
+ // spec-less sessions; the red gate owns implementation edits without a
237
+ // failing test on record. Returning without `next()` for warn/block is the
238
+ // deliberate veto — `remind` always delegates.
239
+ ctx.on('tools/pre-execute', async (exec, next) => {
240
+ const agent = exec.agent;
241
+ if (agent === undefined || !guardToolSet.has(exec.name))
242
+ return next();
243
+ if (!doublecheckEnabled(agent.session))
244
+ return next();
245
+ const snapshot = snapshotOf(agent.session);
246
+ // Grill gate: first discipline stage wins while it stays unsatisfied.
247
+ // The gate reopens when a new direct-user task arrives after the latest
248
+ // spec commit (seq comparison): a committed spec covers its own task,
249
+ // not follow-up requests the user appended to the session.
250
+ const grillOpen = !snapshot.discipline.hasSpec || snapshot.discipline.specSeq < snapshot.lastTaskSeq;
251
+ if (config.modules.grill && grillOpen && snapshot.vague) {
252
+ const reminder = nextGrillReminder(snapshot);
253
+ if (reminder !== undefined)
254
+ pendingReminders.set(exec, reminder);
255
+ switch (config.intensity) {
256
+ case 'remind': {
257
+ ctx.emit('doublecheck/reminder', {
258
+ agent,
259
+ session: agent.session,
260
+ toolName: exec.name,
261
+ intensity: config.intensity,
262
+ gate: 'grill',
263
+ verdict: 'reminded',
264
+ ...reminder !== undefined ? { reminder: prose.grillReminder } : {},
265
+ });
266
+ return next();
267
+ }
268
+ case 'warn': {
269
+ ctx.emit('doublecheck/reminder', {
270
+ agent,
271
+ session: agent.session,
272
+ toolName: exec.name,
273
+ intensity: config.intensity,
274
+ gate: 'grill',
275
+ verdict: 'held',
276
+ });
277
+ return { kind: 'ask', reason: prose.grillAsk };
278
+ }
279
+ case 'block': {
280
+ ctx.emit('doublecheck/reminder', {
281
+ agent,
282
+ session: agent.session,
283
+ toolName: exec.name,
284
+ intensity: config.intensity,
285
+ gate: 'grill',
286
+ verdict: 'denied',
287
+ });
288
+ return { kind: 'deny', reason: prose.grillDeny };
289
+ }
290
+ /* v8 ignore next -- GuardIntensity is a closed union; a future member must fail compilation here. */
291
+ default:
292
+ return assertNever(config.intensity, 'guard intensity');
293
+ }
294
+ }
295
+ // Red gate: implementation edits need a failing test on record. Writing
296
+ // test files is the red step itself and always delegates.
297
+ if (config.modules.tdd && snapshot.discipline.color !== 'red') {
298
+ const args = exec.arguments;
299
+ const path = mutationTargetPath(exec.name, args, detection);
300
+ if (path !== undefined && isTestFilePath(path, detection))
301
+ return next();
302
+ const reminder = nextTddRedReminder(snapshot);
303
+ if (reminder !== undefined)
304
+ pendingReminders.set(exec, reminder);
305
+ switch (config.intensity) {
306
+ case 'remind': {
307
+ ctx.emit('doublecheck/reminder', {
308
+ agent,
309
+ session: agent.session,
310
+ toolName: exec.name,
311
+ intensity: config.intensity,
312
+ gate: 'tdd',
313
+ verdict: 'reminded',
314
+ ...reminder !== undefined ? { reminder: prose.tddReminder } : {},
315
+ });
316
+ return next();
317
+ }
318
+ case 'warn': {
319
+ ctx.emit('doublecheck/reminder', {
320
+ agent,
321
+ session: agent.session,
322
+ toolName: exec.name,
323
+ intensity: config.intensity,
324
+ gate: 'tdd',
325
+ verdict: 'held',
326
+ });
327
+ return { kind: 'ask', reason: prose.tddAsk };
328
+ }
329
+ case 'block': {
330
+ ctx.emit('doublecheck/reminder', {
331
+ agent,
332
+ session: agent.session,
333
+ toolName: exec.name,
334
+ intensity: config.intensity,
335
+ gate: 'tdd',
336
+ verdict: 'denied',
337
+ });
338
+ return { kind: 'deny', reason: prose.tddDeny };
339
+ }
340
+ /* v8 ignore next -- GuardIntensity is a closed union; a future member must fail compilation here. */
341
+ default:
342
+ return assertNever(config.intensity, 'guard intensity');
343
+ }
344
+ }
345
+ return next();
346
+ });
347
+ // Observe-and-enrich, never veto: delegate first, then fold the queued
348
+ // reminder onto whatever decision came back. Denied and held calls flow
349
+ // through this same waterfall, so their reminders ride too.
350
+ ctx.on('tools/post-execute', async (exec, _result, next) => {
351
+ const downstream = await next();
352
+ const reminder = pendingReminders.get(exec);
353
+ if (reminder === undefined)
354
+ return downstream;
355
+ pendingReminders.delete(exec);
356
+ if (downstream.kind === 'block') {
357
+ return {
358
+ kind: 'block',
359
+ feedback: downstream.feedback,
360
+ additionalContexts: prependContext(reminder, downstream.additionalContexts),
361
+ };
362
+ }
363
+ return { ...downstream, additionalContexts: prependContext(reminder, downstream.additionalContexts) };
364
+ });
365
+ // Green gate, delivery gate, and adversary review at the turn boundary.
366
+ // Advisory, not a veto — `agent/turn-stopping` never blocks the close, and
367
+ // a review that cannot run settles as an honest "unavailable" notice. The
368
+ // turn's abort signal cancels the review instead of letting it run out the
369
+ // configured timeout.
370
+ ctx.on('agent/turn-stopping', async ({ agent, signal: turnSignal }) => {
371
+ if (!doublecheckEnabled(agent.session))
372
+ return;
373
+ const snapshot = snapshotOf(agent.session);
374
+ if (config.modules.tdd && snapshot.discipline.pendingGreen) {
375
+ if (!config.remindOnce || !snapshot.greenReminded) {
376
+ snapshot.greenReminded = true;
377
+ const text = config.intensity === 'remind' ? prose.greenReminder : prose.greenReminderStrict;
378
+ agent.inject(notice('green gate', text));
379
+ ctx.emit('doublecheck/reminder', {
380
+ agent,
381
+ session: agent.session,
382
+ intensity: config.intensity,
383
+ gate: 'tdd',
384
+ verdict: 'green-pending',
385
+ reminder: text,
386
+ });
387
+ }
388
+ }
389
+ // Delivery gate (advisory): once the loop reaches green and no
390
+ // doublecheck_report is on record, expect the delivery record before
391
+ // completion claims. Requires test-evidence tracking (tdd or adversary),
392
+ // since `green` itself is only known when detection is compiled.
393
+ if (config.modules.tdd || config.modules.adversary) {
394
+ const discipline = snapshot.discipline;
395
+ if (discipline.hasSpec && discipline.editCount > 0 && discipline.color === 'green'
396
+ && !discipline.pendingGreen && discipline.stage !== 'verify') {
397
+ if (!config.remindOnce || !snapshot.reportReminded) {
398
+ snapshot.reportReminded = true;
399
+ const text = prose.reportExpected;
400
+ agent.inject(notice('delivery report', text));
401
+ ctx.emit('doublecheck/reminder', {
402
+ agent,
403
+ session: agent.session,
404
+ intensity: config.intensity,
405
+ gate: 'delivery',
406
+ verdict: 'report-expected',
407
+ reminder: text,
408
+ });
409
+ }
410
+ }
411
+ }
412
+ if (!config.modules.adversary)
413
+ return;
414
+ const discipline = snapshot.discipline;
415
+ if (!discipline.hasSpec || discipline.editCount === 0 || discipline.pendingGreen || discipline.color !== 'green')
416
+ return;
417
+ // Once-per-green, durable: a review is due only when implementation
418
+ // edits exist after the latest review record (first review counts every
419
+ // edit). Folded from the log, so resumes and forks re-derive the same.
420
+ if (snapshot.editsAfterReview === 0)
421
+ return;
422
+ const outcome = await runAdversaryReview(ctx, adversaryConfig(config), agent, turnSignal);
423
+ agent.inject(reviewInjection(outcome));
424
+ ctx.emit('doublecheck/review', {
425
+ session: agent.session,
426
+ agent,
427
+ verdict: outcome.verdict,
428
+ findings: outcome.findings,
429
+ text: outcome.text,
430
+ });
431
+ if (outcome.verdict === 'findings' && config.intensity !== 'remind') {
432
+ agent.steer(notice('adversary review', config.intensity === 'block' ? prose.reviewSteerStrict : prose.reviewSteer));
433
+ }
434
+ });
435
+ // The `doublecheck` session projection: folds the same durable discipline
436
+ // facts the guard reads into a plain-JSON wire value a client renders.
437
+ // Activates only when a projection registry is composed.
438
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
439
+ const registry = projectionCtx.get('sessionProjections');
440
+ if (registry === undefined)
441
+ return;
442
+ registry.register({
443
+ key: 'doublecheck',
444
+ schema: doublecheckViewSchema,
445
+ init: emptyDoublecheckState,
446
+ apply: (state, event) => applyDoublecheckEvent(state, event, detection),
447
+ view: viewDoublecheck,
448
+ stateVersion: 1,
449
+ });
450
+ });
451
+ // The invariant companion, registered from the main plugin so its checks
452
+ // see the live detection knobs. Activates only when an invariant registry
453
+ // is composed.
454
+ ctx.inject(['invariants'], (invariantCtx) => {
455
+ const registry = invariantCtx.get('invariants');
456
+ if (registry === undefined)
457
+ return;
458
+ registry.register(PACKAGE_NAME, installInvariant({ detection: () => detection }));
459
+ });
460
+ }
461
+ /** The adversary knobs in the shape the review runner reads. */
462
+ function adversaryConfig(config) {
463
+ return {
464
+ adversaryProvider: config.adversaryProvider,
465
+ adversaryModel: config.adversaryModel ?? null,
466
+ adversaryMaxFindings: config.adversaryMaxFindings,
467
+ adversaryTools: config.adversaryTools,
468
+ adversaryTimeoutMs: config.adversaryTimeoutMs,
469
+ language: config.language,
470
+ };
471
+ }
472
+ /** Prepend our reminder while preserving every downstream context's source and metadata. */
473
+ function prependContext(ours, theirs) {
474
+ return [ours, ...theirs ?? []];
475
+ }
476
+ function assertPositiveInteger(field, value) {
477
+ if (!Number.isInteger(value) || value < 1) {
478
+ throw new Error(`dsh-doublecheck: ${field} must be an integer >= 1`);
479
+ }
480
+ }
481
+ /** Validate a bounded positive integer knob fail-loud. */
482
+ function assertBoundedInteger(field, value, maximum) {
483
+ assertPositiveInteger(field, value);
484
+ if (value > maximum) {
485
+ throw new Error(`dsh-doublecheck: ${field} must be <= ${maximum}`);
486
+ }
487
+ }
488
+ /** Validate a name list fail-loud: non-empty, non-empty names, no duplicates. */
489
+ function assertNonEmptyNames(field, names) {
490
+ if (names.length === 0)
491
+ throw new Error(`dsh-doublecheck: ${field} must not be empty`);
492
+ for (const name of names) {
493
+ if (name.length === 0)
494
+ throw new Error(`dsh-doublecheck: ${field} must not contain empty names`);
495
+ }
496
+ if (new Set(names).size !== names.length)
497
+ throw new Error(`dsh-doublecheck: ${field} must not contain duplicates`);
498
+ }
499
+ /** Validate the guard-tool list fail-loud: non-empty, non-empty names, no duplicates. */
500
+ function assertGuardTools(tools) {
501
+ if (tools.length === 0) {
502
+ throw new Error('dsh-doublecheck: guardTools must not be empty');
503
+ }
504
+ for (const tool of tools) {
505
+ if (tool.length === 0) {
506
+ throw new Error('dsh-doublecheck: guardTools must not contain empty tool names');
507
+ }
508
+ }
509
+ if (new Set(tools).size !== tools.length) {
510
+ throw new Error('dsh-doublecheck: guardTools must not contain duplicates');
511
+ }
512
+ }
513
+ /** Closed-union exhaustiveness guard for the intensity switch. */
514
+ function assertNever(value, subject) {
515
+ throw new Error(`dsh-doublecheck: unknown ${subject} "${String(value)}"`);
516
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * User-facing injected prose for the discipline guard, localized.
3
+ *
4
+ * The notice SOURCE summaries (`requirements check`, `red/green check`,
5
+ * `green gate`) stay English: they are stable session-log ids the durable
6
+ * once-semantics fold matches, and translating them would silently break
7
+ * `remindOnce` after a language switch. Only the model-facing text localizes.
8
+ * @module dsh-doublecheck/guard/prose
9
+ */
10
+ /** Supported prose languages for guard injections. */
11
+ export type ProseLanguage = 'en' | 'zh';
12
+ /** Every user-facing string one language provides. */
13
+ export interface GuardProse {
14
+ grillReminder: string;
15
+ grillDeny: string;
16
+ grillAsk: string;
17
+ tddReminder: string;
18
+ tddDeny: string;
19
+ tddAsk: string;
20
+ greenReminder: string;
21
+ greenReminderStrict: string;
22
+ reportExpected: string;
23
+ reviewSteer: string;
24
+ reviewSteerStrict: string;
25
+ reviewClean: string;
26
+ reviewUnavailableSeam: string;
27
+ reviewUnavailableFailed: (reason: string) => string;
28
+ reviewUnavailableStopped: (reason: string) => string;
29
+ reviewUnavailableNoFindings: string;
30
+ reviewFindingsHeader: (count: number) => string;
31
+ reviewFindingsFooter: string;
32
+ }
33
+ /** The localized prose tables by language. */
34
+ export declare const PROSE: Readonly<Record<ProseLanguage, GuardProse>>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * User-facing injected prose for the discipline guard, localized.
3
+ *
4
+ * The notice SOURCE summaries (`requirements check`, `red/green check`,
5
+ * `green gate`) stay English: they are stable session-log ids the durable
6
+ * once-semantics fold matches, and translating them would silently break
7
+ * `remindOnce` after a language switch. Only the model-facing text localizes.
8
+ * @module dsh-doublecheck/guard/prose
9
+ */
10
+ const EN = {
11
+ grillReminder: 'Double-check before you ship: this task is brief and no doublecheck spec '
12
+ + 'has been recorded for it yet. Pause the edit and settle the six '
13
+ + 'requirement dimensions first — goal, scope, acceptance criteria, failure '
14
+ + 'modes, priorities, non-goals. Follow the grill-requirements skill: ask '
15
+ + 'the user with the ask_user_question tool until consensus, record the '
16
+ + 'result with doublecheck_spec, and only then resume editing.',
17
+ grillDeny: 'Blocked by the dsh-doublecheck requirements guard: the task statement is '
18
+ + 'vague and no doublecheck_spec exists for this session. Run the '
19
+ + 'requirements grill first (grill-requirements skill → ask_user_question → '
20
+ + 'doublecheck_spec), then retry this call.',
21
+ grillAsk: 'The task statement is vague and no doublecheck spec exists for this '
22
+ + 'session. Allow this edit before the requirements grill has run?',
23
+ tddReminder: 'Red/green discipline: no failing test is on record since the last passing '
24
+ + 'test run. Before editing implementation code, write a test that fails '
25
+ + 'for the missing behavior and run it to see it fail — then make the '
26
+ + 'change. Test files themselves are always editable.',
27
+ tddDeny: 'Blocked by the dsh-doublecheck red/green evidence gate: no failing test '
28
+ + 'is on record since the last passing test run. Write the failing test '
29
+ + 'first (test files are allowed), run it to see it fail, then edit the '
30
+ + 'implementation.',
31
+ tddAsk: 'No failing test is on record since the last passing test run. Allow this '
32
+ + 'implementation edit before the red step?',
33
+ greenReminder: 'Green gate: the implementation changed, but no passing test run is on '
34
+ + 'record after those changes. Run the test suite and confirm it passes '
35
+ + 'before declaring the work done.',
36
+ greenReminderStrict: 'Green gate: the implementation changed, but no passing test run is on '
37
+ + 'record after those changes. Do not claim completion — run the test '
38
+ + 'suite and make it pass first.',
39
+ reportExpected: 'Delivery gate: the loop reached green, but no doublecheck_report is on '
40
+ + 'record for this delivery yet. Before claiming completion, call '
41
+ + 'doublecheck_report to consolidate the spec, the test evidence, and the '
42
+ + 'review into the delivery record (pass verify: true for the '
43
+ + 'per-dimension checks).',
44
+ reviewSteer: 'The adversary review above found objections against this delivery. '
45
+ + 'Address them before finishing: fix what is real, and state plainly what '
46
+ + 'is false.',
47
+ reviewSteerStrict: 'The adversary review above found objections against this delivery. Do not '
48
+ + 'claim completion while they stand: fix every blocker and major finding, '
49
+ + 'or prove it false, before you finish.',
50
+ reviewClean: 'Adversary review: the critic found no objections the session evidence can '
51
+ + 'support. The delivery satisfies its spec as far as the review can tell.',
52
+ reviewUnavailableSeam: 'Adversary review did not run: the subagents seam is not mounted.',
53
+ reviewUnavailableFailed: reason => `Adversary review did not run: ${reason}`,
54
+ reviewUnavailableStopped: reason => `Adversary review did not complete (${reason}); treat the delivery as unreviewed.`,
55
+ reviewUnavailableNoFindings: 'Adversary review returned no structured findings.',
56
+ reviewFindingsHeader: count => `Adversary review found ${count} objection(s) the delivery must answer:`,
57
+ reviewFindingsFooter: 'Answer each: fix what is real, and state plainly what is false.',
58
+ };
59
+ const ZH = {
60
+ grillReminder: '交付前三查:这个任务描述很简短,本会话还没有记录 doublecheck spec。先停下编辑,把六个需求维度确认清楚——目标、范围、验收标准、失败模式、优先级、非目标。按 grill-requirements 技能执行:用 ask_user_question 向用户提问直到达成共识,用 doublecheck_spec 记录结果,然后再继续编辑。',
61
+ grillDeny: '被 dsh-doublecheck 需求守卫拦截:任务描述模糊,且本会话尚无 doublecheck_spec。请先完成需求盘问(grill-requirements 技能 → ask_user_question → doublecheck_spec),再重试本次调用。',
62
+ grillAsk: '任务描述模糊,且本会话尚无 doublecheck spec。是否允许在需求盘问完成之前进行这次编辑?',
63
+ tddReminder: '红绿纪律:自上次通过测试以来,还没有失败测试的记录。在修改实现代码之前,先为缺失的行为写一个会失败的测试并运行它、看到它失败——然后再动手改。测试文件本身始终可以编辑。',
64
+ tddDeny: '被 dsh-doublecheck 红绿证据门拦截:自上次通过测试以来没有失败测试的记录。请先写失败的测试(测试文件允许编辑),运行并看到它失败,然后再改实现。',
65
+ tddAsk: '自上次通过测试以来没有失败测试的记录。是否允许在完成红步之前进行这次实现编辑?',
66
+ greenReminder: '绿门:实现已改动,但之后没有通过测试的记录。在宣布完成之前,运行测试套件并确认通过。',
67
+ greenReminderStrict: '绿门:实现已改动,但之后没有通过测试的记录。不要声称完成——先把测试套件跑起来并让它通过。',
68
+ reportExpected: '交付门:纪律循环已到达 green,但本交付还没有 doublecheck_report 记录。在声称完成之前,调用 doublecheck_report 把 spec、测试证据和审查结果汇总进交付记录(需要逐维度检查时传 verify: true)。',
69
+ reviewSteer: '上面的对抗式审查对本交付提出了反对意见。完成前先处理它们:属实的修掉,不属实的明确说明。',
70
+ reviewSteerStrict: '上面的对抗式审查对本交付提出了反对意见。在它们被解决之前不要声称完成:修复每一个 blocker 和 major 发现,或证明其不成立,然后再收尾。',
71
+ reviewClean: '对抗式审查:审查者没有找到会话证据能支持的反对意见。就本次审查所能判断的范围,交付满足其 spec。',
72
+ reviewUnavailableSeam: '对抗式审查未运行:subagents seam 未挂载。',
73
+ reviewUnavailableFailed: reason => `对抗式审查未运行:${reason}`,
74
+ reviewUnavailableStopped: reason => `对抗式审查未完成(${reason});请把本次交付视为未经审查。`,
75
+ reviewUnavailableNoFindings: '对抗式审查没有返回结构化发现。',
76
+ reviewFindingsHeader: count => `对抗式审查发现 ${count} 条反对意见,交付必须回应:`,
77
+ reviewFindingsFooter: '逐条回应:属实的修掉,不属实的明确说明。',
78
+ };
79
+ /** The localized prose tables by language. */
80
+ export const PROSE = { en: EN, zh: ZH };