dsh-llm-verifier 0.8.0 → 0.8.2

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.
package/src/auto.ts CHANGED
@@ -33,6 +33,8 @@ export interface AutoTaskEvidence {
33
33
  /** Whether background subagents started during the task remain in flight. */
34
34
  pendingSubagents: boolean
35
35
  pendingUserInteraction: boolean
36
+ /** Plan mode is active: the agent may only research and propose, never be pushed to execute. */
37
+ planMode: boolean
36
38
  eligible: boolean
37
39
  reason: string
38
40
  }
@@ -448,9 +450,31 @@ export function inspectUserInteractionPause(
448
450
  return undefined
449
451
  }
450
452
 
453
+ /**
454
+ * Whether the session is currently in plan mode.
455
+ *
456
+ * The host logs one `plan/mode` event per committed transition and folds the log as
457
+ * "empty log → inactive, last event wins" (its own projection does exactly this). Hosts
458
+ * without plan mode never log the event, so nothing changes there. While planning, the
459
+ * agent is expected to research and PROPOSE: any automatic route or acceptance verdict at
460
+ * the turn-stopping boundary can only fail against the missing implementation and steer
461
+ * "actually implement it" — commanding execution the human has not approved yet. The
462
+ * `exit_plan_mode` pre-review is the one gate that still runs in this state.
463
+ * @param events - Session event log (the whole log is folded; the mode may predate the task).
464
+ * @returns True when the newest `plan/mode` event activated plan mode.
465
+ */
466
+ export function planModeActive(events: readonly SessionEvent[]): boolean {
467
+ let active = false
468
+ for (const event of events) {
469
+ if ((event.type as string) !== 'plan/mode') continue
470
+ active = (event as unknown as { data?: { active?: unknown } }).data?.active === true
471
+ }
472
+ return active
473
+ }
474
+
451
475
  export function analyzeAutoTask(events: readonly SessionEvent[], policy: AutoVerifyPolicy, sessionId?: string): AutoTaskEvidence {
452
476
  const taskStartSeq = latestDirectUserSeq(events)
453
- if (taskStartSeq === undefined) return { taskStartSeq: 0, toolCalls: 0, completedToolResults: 0, consequentialToolCalls: 0, hasManualSessionVerification: false, manualVerificationAccepted: false, pendingSubagents: false, pendingUserInteraction: false, eligible: false, reason: 'no-direct-user-task' }
477
+ if (taskStartSeq === undefined) return { taskStartSeq: 0, toolCalls: 0, completedToolResults: 0, consequentialToolCalls: 0, hasManualSessionVerification: false, manualVerificationAccepted: false, pendingSubagents: false, pendingUserInteraction: false, planMode: planModeActive(events), eligible: false, reason: 'no-direct-user-task' }
454
478
 
455
479
  const relevant = events.filter(event => event.seq >= taskStartSeq)
456
480
  const calls = relevant.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
@@ -523,15 +547,20 @@ export function analyzeAutoTask(events: readonly SessionEvent[], policy: AutoVer
523
547
  const pendingSubagents = hasPendingSubagents(events, taskStartSeq)
524
548
  const userPause = inspectUserInteractionPause(events, taskStartSeq)
525
549
  const pendingUserInteraction = userPause !== undefined
526
-
527
- if (policy.mode === 'manual') return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, eligible: false, reason: 'manual-mode' }
528
- if (manualVerificationAccepted) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, eligible: false, reason: 'already-verified' }
529
- if (pendingSubagents) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents: true, pendingUserInteraction, eligible: false, reason: 'pending-subagents' }
530
- if (pendingUserInteraction) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction: true, eligible: false, reason: 'user-interaction-paused' }
531
- if (consequentialToolCalls === 0) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, eligible: false, reason: 'no-consequential-work' }
532
- if (completedToolResults === 0) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, eligible: false, reason: 'no-completed-evidence' }
533
- if (policy.mode === 'smart' && toolCalls < policy.minToolCalls) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, eligible: false, reason: 'insufficient-tool-evidence' }
534
- return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, eligible: true, reason: policy.mode + '-eligible' }
550
+ const planMode = planModeActive(events)
551
+
552
+ if (policy.mode === 'manual') return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode, eligible: false, reason: 'manual-mode' }
553
+ // Planning is the operator's review boundary, checked before every other suppression: an
554
+ // acceptance verdict here could only fail against the missing implementation and command
555
+ // execution the human has not approved yet.
556
+ if (planMode) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode: true, eligible: false, reason: 'plan-mode-active' }
557
+ if (manualVerificationAccepted) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode, eligible: false, reason: 'already-verified' }
558
+ if (pendingSubagents) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents: true, pendingUserInteraction, planMode, eligible: false, reason: 'pending-subagents' }
559
+ if (pendingUserInteraction) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction: true, planMode, eligible: false, reason: 'user-interaction-paused' }
560
+ if (consequentialToolCalls === 0) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode, eligible: false, reason: 'no-consequential-work' }
561
+ if (completedToolResults === 0) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode, eligible: false, reason: 'no-completed-evidence' }
562
+ if (policy.mode === 'smart' && toolCalls < policy.minToolCalls) return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode, eligible: false, reason: 'insufficient-tool-evidence' }
563
+ return { taskStartSeq, toolCalls, completedToolResults, consequentialToolCalls, hasManualSessionVerification, manualVerificationAccepted, pendingSubagents, pendingUserInteraction, planMode, eligible: true, reason: policy.mode + '-eligible' }
535
564
  }
536
565
 
537
566
  /** One criterion's outcome from a session acceptance (candidate A is the session). */
package/src/index.test.ts CHANGED
@@ -1306,6 +1306,30 @@ describe('automatic gate lifecycle', () => {
1306
1306
  })
1307
1307
 
1308
1308
 
1309
+ it('skips every automatic route and the final gate while plan mode is active', async () => {
1310
+ // The reported bug: a planning session was judged against the missing implementation and
1311
+ // steered to "actually implement the change" before the human approved the plan.
1312
+ const calls: Array<Record<string, unknown>> = []
1313
+ const { handlers } = assemble(JUDGE, { stream: scriptedStream(1, [], calls), sessions: [{ id: 'topic-plan', createdAt: 1 }] })
1314
+ const steered: unknown[] = []
1315
+ const planningEvents = [
1316
+ user(0, 'Implement it'),
1317
+ call(1, 'e', 'edit'), result(2, 'e', 'file edited'),
1318
+ { seq: 3, type: 'plan/mode', data: { active: true } },
1319
+ call(4, 'r', 'read'), result(5, 'r', 'file content'),
1320
+ call(6, 'g', 'grep'), result(7, 'g', 'matches'),
1321
+ ]
1322
+ await handlers.get('agent/turn-stopping')!({ agent: agent(planningEvents, steered), signal: new AbortController().signal })
1323
+ // No judge call, no routing record, no steering: the turn simply closes for plan review.
1324
+ expect(calls.length).toBe(0)
1325
+ expect(steered.length).toBe(0)
1326
+
1327
+ // Plan approved (plan/mode inactive): the same boundary gates the earlier work again.
1328
+ const approvedEvents = [...planningEvents, { seq: 8, type: 'plan/mode', data: { active: false } }]
1329
+ await handlers.get('agent/turn-stopping')!({ agent: agent(approvedEvents, steered), signal: new AbortController().signal })
1330
+ expect(calls.length).toBeGreaterThan(0)
1331
+ })
1332
+
1309
1333
  it('records a low-confidence classification so "not routed" has a reason', async () => {
1310
1334
  // Diagnostics gap named by the review: a task that was never routed showed nothing on the
1311
1335
  // dashboard. The classification decision itself is now stored with its reason.
@@ -1803,6 +1827,16 @@ describe('early candidate review through agent/pre-step', () => {
1803
1827
  expect(judgeCalls(calls)).toHaveLength(6)
1804
1828
  })
1805
1829
 
1830
+ it('does not inject into a step while plan mode is active', async () => {
1831
+ const calls: Array<Record<string, unknown>> = []
1832
+ const { handlers } = assemble(JUDGE, { stream: reviewStream(calls), sessions: [{ id: 'agent-pre', createdAt: 1 }] })
1833
+ const events = [...workflowEvents([candidate('1', 'candidate one'), candidate('2', 'candidate two')]), { type: 'plan/mode', seq: 3, data: { active: true } }]
1834
+ const base = { kind: 'enter' as const, messages: [] }
1835
+ const decision = await handlers.get('agent/pre-step')!({ agent: agent(events), messages: [], turn: 1, step: 2, signal: signal() }, async () => base)
1836
+ expect(decision).toBe(base)
1837
+ expect(calls).toHaveLength(0)
1838
+ })
1839
+
1806
1840
  it('does not re-buy the same pair at the following stop boundary', async () => {
1807
1841
  const calls: Array<Record<string, unknown>> = []
1808
1842
  const { handlers } = assemble(JUDGE, { stream: reviewStream(calls), sessions: [{ id: 'agent-pre', createdAt: 1 }] })
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ import { VerifierEngine, mergeRunStats, normalizeCriteria, partialStats, type Ju
15
15
  import { loadVerifierImages } from './images.ts'
16
16
  import { extractSession, sanitizeVerifierText, sessionEvents, type SessionExtraction } from './session.ts'
17
17
  import { CriteriaResolver, type ResolvedCriteria } from './criteria.ts'
18
- import { analyzeAutoTask, automaticFeedback, compareRouteFeedbackDetail, failedAcceptanceCriteria, inspectUserInteractionPause, isSubagentSession, selectRouteFeedbackDetail, sessionAccepted, MAX_ROUTE_FEEDBACK_CHARS, type AcceptanceCriterion, type RoutedCandidateRef } from './auto.ts'
18
+ import { analyzeAutoTask, automaticFeedback, compareRouteFeedbackDetail, failedAcceptanceCriteria, inspectUserInteractionPause, isSubagentSession, planModeActive, selectRouteFeedbackDetail, sessionAccepted, MAX_ROUTE_FEEDBACK_CHARS, type AcceptanceCriterion, type RoutedCandidateRef } from './auto.ts'
19
19
  import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRouteView, estimateRoutedCalls, inspectDeliveryPhase, inspectRecoverySignal, latestDirectUserSeq, nextDiagnosticCycleId, parseSemanticRoute, routedRepeats, semanticDecision, semanticReferencesVisible, semanticRouteHint, type CandidateArtifact, type Reservation, type RouteDecision, type RoutedAgent, type RoutedVerifierKind, type SemanticRouteView } from './router.ts'
20
20
  import { VerifierActivities, createActivityObserver, type ActivityView } from './verifier-activity.ts'
21
21
  import { ProcessCycleStore, ProcessSelector, resolveProcessFile, type ProcessCycleReport } from './process-selection.ts'
@@ -1337,6 +1337,10 @@ export function apply(ctx: Context, config: Config = {}): void {
1337
1337
  if (!selected.enabled || selected.autoVerifyMode !== 'smart') return decision
1338
1338
  if (payload.signal.aborted) return decision
1339
1339
  if (!selected.autoVerifySubagents && isSubagentSession(payload.agent)) return decision
1340
+ // Planning is the operator's review boundary: injecting candidate feedback or buying a
1341
+ // process-selection cycle here would push the agent to execute a plan the human has not
1342
+ // approved yet.
1343
+ if (planModeActive(sessionEvents(payload.agent.session))) return decision
1340
1344
  // A step carrying a fresh direct or team task belongs to the operator, not to us.
1341
1345
  const carriesTask = payload.messages.some(message => {
1342
1346
  const kind = (message as { source?: { kind?: unknown } }).source?.kind
@@ -1379,6 +1383,17 @@ export function apply(ctx: Context, config: Config = {}): void {
1379
1383
  const userPause = inspectUserInteractionPause(snapshot, evidence.taskStartSeq, turn)
1380
1384
  const userInteractionPending = evidence.pendingUserInteraction || userPause !== undefined
1381
1385
 
1386
+ // Plan mode is the operator's review boundary: the agent is supposed to research and
1387
+ // PROPOSE, so no automatic route, team gate or final acceptance may run here — a verdict
1388
+ // could only fail against the missing implementation and steer "actually implement it",
1389
+ // commanding execution the human has not approved yet. The exit_plan_mode pre-review is
1390
+ // the one gate that still runs while planning, so this boundary resumes once the mode is
1391
+ // exited (plan/mode active → inactive) and real work exists.
1392
+ if (evidence.planMode) {
1393
+ ctx.logger.info?.('llm-verifier automatic session verification skipped: plan mode is active')
1394
+ return
1395
+ }
1396
+
1382
1397
  // A manual `verifier_current_session` that covered the whole task and passed is the
1383
1398
  // strongest acceptance signal available; it discharges the mandatory final gate
1384
1399
  // BEFORE another routed decision is purchased. `analyzeAutoTask` already refused any