@nexus-cortex/core 4.91.1 → 4.93.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 (46) hide show
  1. package/.env.example +42 -0
  2. package/dist/config/effectiveConfig.d.ts.map +1 -1
  3. package/dist/config/effectiveConfig.js +2 -0
  4. package/dist/config/effectiveConfig.js.map +1 -1
  5. package/dist/middleware/HelperModelMiddleware.d.ts +28 -0
  6. package/dist/middleware/HelperModelMiddleware.d.ts.map +1 -1
  7. package/dist/middleware/HelperModelMiddleware.js +63 -1
  8. package/dist/middleware/HelperModelMiddleware.js.map +1 -1
  9. package/dist/middleware/helpers/helperFrame.d.ts +4 -0
  10. package/dist/middleware/helpers/helperFrame.d.ts.map +1 -1
  11. package/dist/middleware/helpers/helperFrame.js.map +1 -1
  12. package/dist/orchestrator/CortexOrchestrator.d.ts +36 -0
  13. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  14. package/dist/orchestrator/CortexOrchestrator.js +296 -12
  15. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  16. package/dist/orchestrator/emptyResponseClassifier.d.ts +5 -2
  17. package/dist/orchestrator/emptyResponseClassifier.d.ts.map +1 -1
  18. package/dist/orchestrator/emptyResponseClassifier.js +18 -1
  19. package/dist/orchestrator/emptyResponseClassifier.js.map +1 -1
  20. package/dist/orchestrator/outerToolTimeout.d.ts +5 -0
  21. package/dist/orchestrator/outerToolTimeout.d.ts.map +1 -0
  22. package/dist/orchestrator/outerToolTimeout.js +28 -0
  23. package/dist/orchestrator/outerToolTimeout.js.map +1 -0
  24. package/dist/orchestrator/requirementsVerification.d.ts.map +1 -1
  25. package/dist/orchestrator/requirementsVerification.js +22 -3
  26. package/dist/orchestrator/requirementsVerification.js.map +1 -1
  27. package/dist/orchestrator/timeBudget.d.ts +19 -0
  28. package/dist/orchestrator/timeBudget.d.ts.map +1 -0
  29. package/dist/orchestrator/timeBudget.js +24 -0
  30. package/dist/orchestrator/timeBudget.js.map +1 -0
  31. package/dist/tools/ToolProfile.d.ts +5 -0
  32. package/dist/tools/ToolProfile.d.ts.map +1 -1
  33. package/dist/tools/ToolProfile.js +15 -0
  34. package/dist/tools/ToolProfile.js.map +1 -1
  35. package/dist/training/DecisionStore.d.ts +1 -1
  36. package/dist/training/DecisionStore.d.ts.map +1 -1
  37. package/dist/training/DecisionStore.js.map +1 -1
  38. package/dist/training/endTurnResolver.d.ts +46 -0
  39. package/dist/training/endTurnResolver.d.ts.map +1 -0
  40. package/dist/training/endTurnResolver.js +71 -0
  41. package/dist/training/endTurnResolver.js.map +1 -0
  42. package/dist/training/liftPlanner.d.ts +65 -0
  43. package/dist/training/liftPlanner.d.ts.map +1 -0
  44. package/dist/training/liftPlanner.js +93 -0
  45. package/dist/training/liftPlanner.js.map +1 -0
  46. package/package.json +3 -3
@@ -16,6 +16,10 @@ import { verifyCoordinates, deterministicCoordinateScore } from './coordinateVer
16
16
  import { buildRouterSample, appendJsonlRotating } from './cortexTrainingRecord.js';
17
17
  import { join as pathJoin } from 'path';
18
18
  import { existsSync } from 'fs';
19
+ import { execSync } from 'node:child_process';
20
+ import { ENV_RECON_COMMAND, resolveLiftPlanConfig } from '../training/liftPlanner.js';
21
+ import { resolveEndTurnResolverConfig, parseResolverVerdict } from '../training/endTurnResolver.js';
22
+ import { isTaskShaped } from './requirementsVerification.js';
19
23
  import { extractServerSideMetadata, XAIServerSideTools, OpenAIServerSideTools, toCanonicalTool } from '../tools/ServerSideTools.js';
20
24
  // Phase 2.2.1: Timeline and History Storage
21
25
  import { SessionTimeline } from '../session/SessionTimeline.js';
@@ -29,8 +33,10 @@ import { CacheMetricsAccumulator } from '../session/CacheMetricsAccumulator.js';
29
33
  import { SubAgentProcessManager, createSubAgentProcessManager } from './SubAgentProcessManager.js';
30
34
  // Phase 1: Tool Architecture Refactor - Unified Tool Registry
31
35
  import { toolFactory } from '../tools/ToolFactory.js';
32
- import { resolveToolProfile, resolveToolAnchor, resolveFrameProfile, resolveLiftNudge, resolveHeadlessDropAskUser, resolveDeferredLoading, isNarrowProfile, isToolAllowedByProfile, applyToolProfile, webToolBlocked, resolveVisionHelperModel, resolveVisionHandoffMax, resolveSliceNudge } from '../tools/ToolProfile.js';
36
+ import { resolveToolProfile, resolveToolAnchor, resolveFrameProfile, resolveLiftNudge, resolveLiftPlan, resolveEndTurnResolver, resolveHeadlessDropAskUser, resolveDeferredLoading, isNarrowProfile, isToolAllowedByProfile, applyToolProfile, webToolBlocked, resolveVisionHelperModel, resolveVisionHandoffMax, resolveSliceNudge } from '../tools/ToolProfile.js';
33
37
  import { TurnEvidence, evaluateEndTurnGates, buildMissingEndTurnReminder } from './endTurnGates.js';
38
+ import { resolveOuterToolDeadlineMs } from './outerToolTimeout.js';
39
+ import { resolveTurnDeadlineMs, timeBudgetState, timeBudgetWarnNudge } from './timeBudget.js';
34
40
  import { readStagedDoctrine, applyCuratedDoctrine, runOrientForStaging, withTimeout } from './doctrineCuration.js';
35
41
  import { ExactRepeatTracker } from '../training/loopLadder.js';
36
42
  import { slashCommandRegistry } from '../commands/SlashCommandRegistry.js';
@@ -59,7 +65,7 @@ import { ListAvailableMcpServers, SearchMcpServers, GetMcpConfig, EnableMcpServe
59
65
  import { InitCortexContext, MemoryWrite, MemoryRecall } from '../tools/context-management/index.js';
60
66
  import { pruneAgedToolResults } from '../conversation/ToolResultPruner.js';
61
67
  import { detectTailRepetition, tailLoopGuardEnabled } from './tailRepetitionDetector.js';
62
- import { classifyEmptyResponse, emptyResponseNudge } from './emptyResponseClassifier.js';
68
+ import { classifyEmptyResponse, emptyResponseNudge, nudgeForbidsTools } from './emptyResponseClassifier.js';
63
69
  // PTC: Progressive tool loading for non-PTC providers
64
70
  import { ClientSideToolFilter } from '../tools/ClientSideToolFilter.js';
65
71
  /** Grace the orchestrator's outer tool-abort waits past the tool's own deadline (ShellTool promote/kill fires first; 4.90.1). */
@@ -163,6 +169,9 @@ export class CortexOrchestrator {
163
169
  * exactly once, at the anchor-lift boundary. One-shot per orchestrator. */
164
170
  deferredCorpusDelivered = false;
165
171
  liftNudgeDelivered = false; // A′ proposal-1: lift-boundary SearchTools/AskForAdvice nudge, one-shot
172
+ liftPlanDelivered = false; // LIFT_MENTOR_PLANNER: bounded mentor-planner at the lift, one-shot
173
+ cachedEnvReport; // ENV_RECON_COMMAND output, gathered once, shared by lift-planner + endturn-resolver
174
+ endTurnResolverRejects = 0; // endTurnResolver: GAP vetoes so far this task (bounded by maxRejects → fallback-accept)
166
175
  effectiveDeferredLoading = true; // per-turn resolved deferred-loading (card > env > settings); set at assembly
167
176
  /**
168
177
  * Item 10 — helper-curated doctrine freshness (operator-designed; docs/
@@ -339,6 +348,214 @@ export class CortexOrchestrator {
339
348
  console.warn(`[Anchor] lift nudge delivery failed (continuing without): ${e?.message ?? e}`);
340
349
  }
341
350
  }
351
+ /**
352
+ * LIFT_MENTOR_PLANNER (spec §2) — at the anchor-lift boundary, when CORTEX_LIFT_PLAN is armed, a
353
+ * bounded max-reasoning mentor plans the task ONCE (adversarial analysis + confirm the REAL grader
354
+ * criteria + a criteria-anchored step plan, or a RETIRE plan) and the plan is appended as a
355
+ * system-reminder to the just-recorded first tool_result. The narrow-door model resumes with the
356
+ * plan in front of it, never having issued a planning call (forced tool_choice is dead on DeepSeek
357
+ * once history holds a prior tool_call; orchestrator-direct-invoke + system-reminder delivery is
358
+ * the path that provably works — same pattern as the CORTEX_MENTOR_AUTO consult). One-shot per
359
+ * orchestrator; bounded by a timeout + the helper's output budget (NOT a token cap, which would
360
+ * truncate DeepSeek mid-thought). Fail-open: on timeout/error the model just proceeds. Mirrors
361
+ * deliverDeferredCorpusAtLift + ensureDoctrineFresh. No-op unless CORTEX_LIFT_PLAN truthy. */
362
+ async deliverLiftPlanAtLift(model) {
363
+ if (this.liftPlanDelivered)
364
+ return;
365
+ if (!resolveLiftPlan(process.env, model.liftPlan))
366
+ return;
367
+ // 🔴 INTERACTIVE GATE (operator caveat 2026-09-04): the planner adds a ~30-60s silent pause at
368
+ // the lift (pro @ max reasoning). In a headless/one-shot/bench session (autoApproveActions=true)
369
+ // there is no human watching — fine. In an interactive TUI a silent pause reads as a frozen UI,
370
+ // so the planner is SUPPRESSED there UNLESS a thinking-indicator is wired and opted in via
371
+ // CORTEX_LIFT_PLAN_INTERACTIVE=true. (autoApproveActions = !(stdin.isTTY && stdout.isTTY).)
372
+ if (!this.approvalMode.autoApproveActions && process.env.CORTEX_LIFT_PLAN_INTERACTIVE !== 'true') {
373
+ return; // interactive session, no thinking-indicator opt-in → do not fire (do not consume one-shot)
374
+ }
375
+ this.liftPlanDelivered = true; // one-shot even if the call fails (never re-fire mid-task)
376
+ if (!this.helperMiddleware?.generateTaskPlan)
377
+ return;
378
+ const store = this.getDecisionStore();
379
+ const sessionId = this.currentSessionId ?? 'unknown';
380
+ try {
381
+ const lastMsg = this.messageHistory[this.messageHistory.length - 1];
382
+ // Same shape guard as the corpus/nudge deliveries: only when the last message is the first
383
+ // tool_result — that IS the environment observation the planner reasons from.
384
+ if (lastMsg?.message?.content?.[0]?.type !== 'tool_result')
385
+ return;
386
+ const observations = lastMsg.message.content
387
+ .filter((b) => b?.type === 'tool_result')
388
+ .map((b) => (typeof b.content === 'string' ? b.content : JSON.stringify(b.content)))
389
+ .join('\n')
390
+ .slice(0, 4000);
391
+ // v1.1 (2026-09-04): gather a GUARANTEED environment report so the planner steers installs +
392
+ // Bash timeouts from the box's REAL resources (tooling/packages/disk/tests) — not just whatever
393
+ // the model's first action happened to observe. Bounded + fail-open (partial stdout on
394
+ // timeout/non-zero is still useful; empty on total failure → plan from observations alone).
395
+ const envReport = this.gatherEnvReport();
396
+ const timeoutMs = parseInt(process.env.CORTEX_LIFT_PLAN_TIMEOUT_MS ?? '90000', 10);
397
+ const t0 = Date.now();
398
+ const plan = await withTimeout(this.helperMiddleware.generateTaskPlan({
399
+ task: this.lastRealUserText(),
400
+ observations,
401
+ envReport,
402
+ helperModelId: this.config.reactiveMentorship?.helperModelId,
403
+ }), timeoutMs);
404
+ const latencyMs = Date.now() - t0;
405
+ if (!plan || !plan.trim()) {
406
+ if (store)
407
+ void store.recordEvent({
408
+ sessionId, kind: 'lift_plan', detail: { fired: true, planChars: 0, empty: true },
409
+ }).catch(() => { });
410
+ return; // fail-open: proceed without a plan
411
+ }
412
+ const retire = /\bRETIRE\b/i.test(plan);
413
+ const criteriaStated = /criteri|grader|test\.sh|expected|verify/i.test(plan);
414
+ lastMsg.message.content.push({
415
+ type: 'text',
416
+ text: `<system-reminder>\nA senior engineer prepared this PLAN OF ATTACK for the task — follow it. ` +
417
+ `It is anchored to the REAL success criteria (what the task's own grader checks), NOT your own ` +
418
+ `tests. Your own tests are a means, never the finish line.\n\n${plan}\n</system-reminder>`,
419
+ });
420
+ if (store)
421
+ void store.recordEvent({
422
+ sessionId,
423
+ kind: 'lift_plan',
424
+ // OBSERVABILITY (resolver-AB follow-up): bank the plan TEXT + latency, not just counts —
425
+ // so a k=5 run can score plan QUALITY (and read the reasoning behind a RETIRE).
426
+ detail: { fired: true, planChars: plan.length, retire, criteriaStated, latencyMs, planText: plan.slice(0, 4000) },
427
+ }).catch(() => { });
428
+ if (this.config.debug) {
429
+ console.log(`[LiftPlan] plan delivered at lift (${plan.length} chars, retire=${retire}, criteria=${criteriaStated})`);
430
+ }
431
+ }
432
+ catch (e) {
433
+ if (store)
434
+ void store.recordEvent({
435
+ sessionId, kind: 'lift_plan', detail: { fired: true, error: String(e?.message ?? e).slice(0, 120) },
436
+ }).catch(() => { });
437
+ if (this.config.debug)
438
+ console.warn(`[LiftPlan] delivery failed (continuing without): ${e?.message ?? e}`);
439
+ }
440
+ }
441
+ /** Bounded read-only environment recon (ENV_RECON_COMMAND), gathered ONCE and cached — shared by the
442
+ * lift planner and the EndTurn resolver. Fail-open: partial stdout on timeout/non-zero, else empty. */
443
+ gatherEnvReport() {
444
+ if (this.cachedEnvReport !== undefined)
445
+ return this.cachedEnvReport;
446
+ let r = '';
447
+ try {
448
+ r = String(execSync(ENV_RECON_COMMAND, {
449
+ cwd: this.config.projectPath || process.cwd(),
450
+ timeout: resolveLiftPlanConfig().reconTimeoutMs,
451
+ encoding: 'utf8',
452
+ maxBuffer: 512 * 1024,
453
+ stdio: ['ignore', 'pipe', 'ignore'],
454
+ shell: '/bin/sh',
455
+ })).slice(0, 6000);
456
+ }
457
+ catch (re) {
458
+ r = String(re?.stdout ?? '').slice(0, 6000);
459
+ }
460
+ this.cachedEnvReport = r;
461
+ return r;
462
+ }
463
+ /** The most recent assistant message's text (the draft/work product the model just produced). */
464
+ lastAssistantText() {
465
+ for (let i = this.messageHistory.length - 1; i >= 0; i--) {
466
+ const m = this.messageHistory[i];
467
+ if (m?.message?.role !== 'assistant')
468
+ continue;
469
+ const c = m.message.content;
470
+ if (typeof c === 'string')
471
+ return c;
472
+ if (Array.isArray(c))
473
+ return c.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n');
474
+ return '';
475
+ }
476
+ return '';
477
+ }
478
+ /**
479
+ * endTurnResolver (operator design 2026-09-04) — the mentor-as-EndTurn-judge, the finish-side twin of
480
+ * deliverLiftPlanAtLift. Runs AFTER evaluateEndTurnGates: only when the mechanical gate ACCEPTED the
481
+ * EndTurn (ev.endTurnCalled === true). A bounded max-reasoning mentor adjudicates "does the work product
482
+ * meet the task's real requirements?" — MEETS leaves the finish accepted; GAP VETOES it (ev.endTurnCalled
483
+ * = false) and mutates the EndTurn tool_result into the mentor's fix plan (same channel the gate uses), so
484
+ * the narrow-door model executes a plan instead of reasoning its way out of a mechanical rejection.
485
+ * Bounded by CORTEX_ENDTURN_RESOLVER_MAX_REJECTS then fallback-accepts (liveness). No-op when the resolver
486
+ * is unarmed → byte-identical. Fail-open on any error → leaves the finish accepted.
487
+ */
488
+ async adjudicateEndTurn(ev, toolResults, toolUses, model) {
489
+ if (!resolveEndTurnResolver(process.env, model.endTurnResolver))
490
+ return;
491
+ if (ev.endTurnCalled !== true)
492
+ return; // gate rejected (or no EndTurn) — the resolver only vetoes PASSES
493
+ if (!this.helperMiddleware?.evaluateEndTurn)
494
+ return;
495
+ const task = this.lastRealUserText();
496
+ if (!isTaskShaped(task))
497
+ return; // only adjudicate real work tasks
498
+ const cfg = resolveEndTurnResolverConfig();
499
+ if (this.endTurnResolverRejects >= cfg.maxRejects)
500
+ return; // fallback-accept: liveness beats loops
501
+ const et = toolResults.find((tr) => tr.tool_name === 'EndTurn');
502
+ if (!et)
503
+ return;
504
+ const store = this.getDecisionStore();
505
+ const sessionId = this.currentSessionId ?? 'unknown';
506
+ try {
507
+ const etInput = toolUses.find((t) => t.id === et.tool_use_id || t.name === 'EndTurn')?.input ?? {};
508
+ const outputs = (ev.outputs ?? []).join('\n').slice(0, 4000);
509
+ const workProduct = `${this.lastAssistantText()}\n\n--- checks / tool outputs this task ---\n${outputs}`.slice(0, 8000);
510
+ const attestation = JSON.stringify(etInput).slice(0, 2000);
511
+ const timeoutMs = parseInt(process.env.CORTEX_ENDTURN_RESOLVER_TIMEOUT_MS ?? '90000', 10);
512
+ const t0 = Date.now();
513
+ const text = await withTimeout(this.helperMiddleware.evaluateEndTurn({
514
+ task,
515
+ envReport: this.gatherEnvReport(),
516
+ workProduct,
517
+ attestation,
518
+ helperModelId: this.config.reactiveMentorship?.helperModelId,
519
+ }), timeoutMs);
520
+ const latencyMs = Date.now() - t0;
521
+ const verdict = parseResolverVerdict(text ?? ''); // withTimeout → null on timeout; fail-open to MEETS
522
+ // OBSERVABILITY (resolver-AB follow-up, 2026-09-05): bank the TEXT the resolver produced +
523
+ // what it judged, not just counts — so a k=5 run can score the resolver's JUDGMENT QUALITY
524
+ // (was the GAP warranted? was the fix plan good? did it judge the right artifact?) and not
525
+ // merely that it fired. Bounded caps keep the decisions row sane. See HARNESS_IMPROVEMENT_BACKLOG.
526
+ if (store)
527
+ void store.recordEvent({
528
+ sessionId,
529
+ kind: 'endturn_resolver',
530
+ toolName: 'EndTurn',
531
+ detail: {
532
+ meets: verdict.meets, planChars: verdict.plan.length, rejects: this.endTurnResolverRejects, parsed: verdict.parsed,
533
+ latencyMs, rawLen: (text ?? '').length,
534
+ planText: verdict.plan.slice(0, 4000),
535
+ workProductSample: workProduct.slice(0, 1500),
536
+ attestation: attestation.slice(0, 800),
537
+ },
538
+ }).catch(() => { });
539
+ if (!verdict.meets && verdict.plan) {
540
+ this.endTurnResolverRejects += 1;
541
+ ev.endTurnCalled = false; // VETO the finish
542
+ et.is_error = true;
543
+ et.content =
544
+ `EndTurn HELD (finish review) — the work does not yet meet the task's requirements. Fix plan:\n\n` +
545
+ `${verdict.plan}\n\nDo this, verify against the task's own criteria (not your own tests), then call EndTurn again.`;
546
+ if (this.config.debug)
547
+ console.warn(`[EndTurnResolver] GAP — vetoed finish (${verdict.plan.length}-char plan, reject ${this.endTurnResolverRejects}/${cfg.maxRejects})`);
548
+ }
549
+ else if (this.config.debug) {
550
+ console.log(`[EndTurnResolver] MEETS — finish confirmed (parsed=${verdict.parsed})`);
551
+ }
552
+ }
553
+ catch (e) {
554
+ if (store)
555
+ void store.recordEvent({ sessionId, kind: 'endturn_resolver', detail: { error: String(e?.message ?? e).slice(0, 120) } }).catch(() => { });
556
+ // fail-open: leave the finish accepted
557
+ }
558
+ }
342
559
  /** Narrow `tools` to the anchor profile while the anchor is armed; no-op
343
560
  * once lifted or when CORTEX_TOOL_ANCHOR is unset. Applied AFTER the
344
561
  * deferred filter at both request-assembly sites. */
@@ -1178,6 +1395,10 @@ export class CortexOrchestrator {
1178
1395
  // (no reminders, no force-synthesis cap). Runaway protection then rests on
1179
1396
  // loop detection, the consecutive-error breaker, and MAX_TOOL_ITERATIONS.
1180
1397
  const TOOL_BUDGET_HARD = TOOL_BUDGET_SOFT > 0 ? TOOL_BUDGET_SOFT * 2 : Infinity; // force-synthesis cap
1398
+ // #2 (2026-09-04): per-turn wall-clock budget (opt-in — 0 disables, no behaviour change when unset).
1399
+ const TURN_DEADLINE_MS = loopDefaults.turnDeadlineMs;
1400
+ const loopStartMs = Date.now();
1401
+ let timeWarnFired = false;
1181
1402
  let totalToolErrors = 0;
1182
1403
  // Round 18b: track whether we've already retried on empty response so
1183
1404
  // we don't loop forever. See empty-detection block below.
@@ -1294,7 +1515,9 @@ export class CortexOrchestrator {
1294
1515
  // no_visible_content (nothing at all) for observability + a nudge
1295
1516
  // tailored to the failure shape. Does NOT change the one-bounded-retry
1296
1517
  // decision — only the log + nudge text.
1297
- const emptyClass = classifyEmptyResponse(currentAssistantCanonicalMessage.content);
1518
+ // D-E (2026-09-04): pass stopReason so a max_tokens truncation is classified 'truncated'
1519
+ // (continue), not 'reasoning_only' (which demands a final answer and just re-truncates).
1520
+ const emptyClass = classifyEmptyResponse(currentAssistantCanonicalMessage.content, convertedResponse.stopReason);
1298
1521
  console.warn(`[Orchestrator] Empty response detected (${emptyClass.kind}, hadReasoning=${emptyClass.hadReasoning}, iteration=${toolCallIteration}). ` +
1299
1522
  `Retrying once with explicit completion prompt.`);
1300
1523
  // R26 (2026-05-15, surfaced by A/B benchmark): the empty assistant
@@ -1335,7 +1558,7 @@ export class CortexOrchestrator {
1335
1558
  role: 'user',
1336
1559
  content: [{
1337
1560
  type: 'text',
1338
- text: `<system-reminder>${emptyResponseNudge(emptyClass.kind)} Do not call any more tools.</system-reminder>`,
1561
+ text: `<system-reminder>${emptyResponseNudge(emptyClass.kind)}${nudgeForbidsTools(emptyClass.kind) ? ' Do not call any more tools.' : ''}</system-reminder>`,
1339
1562
  }],
1340
1563
  },
1341
1564
  timeline: {
@@ -1874,10 +2097,13 @@ export class CortexOrchestrator {
1874
2097
  // 0 promotions). The outer timer is the last-resort cap for hung tools: it fires a grace
1875
2098
  // period AFTER the tool's own deadline.
1876
2099
  const abortController = new AbortController();
2100
+ // D-A (2026-09-04): outer cap must sit ABOVE the model's REQUESTED Bash timeout, not a static
2101
+ // ~150s, or a legit `Bash({ timeout: 250000 })` is killed before ShellTool's own promote-at-deadline.
2102
+ const outerDeadlineMs = resolveOuterToolDeadlineMs(toolUseBlocks, TOOL_TIMEOUT_MS, OUTER_TIMEOUT_GRACE_MS);
1877
2103
  const timeoutId = setTimeout(() => {
1878
- console.warn(`[Orchestrator Phase 2.5] Tool execution timeout after ${TOOL_TIMEOUT_MS + OUTER_TIMEOUT_GRACE_MS}ms`);
2104
+ console.warn(`[Orchestrator Phase 2.5] Tool execution timeout after ${outerDeadlineMs}ms`);
1879
2105
  abortController.abort();
1880
- }, TOOL_TIMEOUT_MS + OUTER_TIMEOUT_GRACE_MS);
2106
+ }, outerDeadlineMs);
1881
2107
  // Track which tool_use_ids have been processed to avoid duplicates in error handler
1882
2108
  const processedToolUseIds = new Set();
1883
2109
  try {
@@ -1995,6 +2221,7 @@ export class CortexOrchestrator {
1995
2221
  ev.noteToolResults(toolResults);
1996
2222
  // Stages 2/4/5 + effort tail — ONE implementation shared with the streaming loop (endTurnGates.ts, 4.91.0).
1997
2223
  evaluateEndTurnGates(ev, toolResults, toolUseBlocks, this.gateDeps());
2224
+ await this.adjudicateEndTurn(ev, toolResults, toolUseBlocks, effectiveModel); // endTurnResolver (dark unless CORTEX_ENDTURN_RESOLVER)
1998
2225
  const pendingImages = this.collectPendingImages(toolResults, effectiveModel.vision === true);
1999
2226
  await this.applyVisionHandoffs();
2000
2227
  // Create tool_result messages and add to history
@@ -2131,8 +2358,14 @@ export class CortexOrchestrator {
2131
2358
  const progressStalled = isToolProgressStalled(recentToolCalls);
2132
2359
  const budgetSignal = computeToolBudgetSignal(effectiveToolBudgetCount, TOOL_BUDGET_SOFT, progressStalled);
2133
2360
  const diversityWarning = this.getDiversityWarning(toolCallCounts);
2134
- if (budgetSignal || diversityWarning || ladderSignal) {
2135
- const signals = [budgetSignal, diversityWarning, ladderSignal].filter(Boolean).join('\n');
2361
+ // #2: one-shot wall-clock warning at 90% of the deadline — tell the model to converge + EndTurn.
2362
+ let timeSignal = null;
2363
+ if (!timeWarnFired && timeBudgetState(Date.now() - loopStartMs, TURN_DEADLINE_MS) === 'warn') {
2364
+ timeSignal = timeBudgetWarnNudge(Date.now() - loopStartMs, TURN_DEADLINE_MS);
2365
+ timeWarnFired = true;
2366
+ }
2367
+ if (budgetSignal || diversityWarning || ladderSignal || timeSignal) {
2368
+ const signals = [budgetSignal, diversityWarning, ladderSignal, timeSignal].filter(Boolean).join('\n');
2136
2369
  const lastMsg = this.messageHistory[this.messageHistory.length - 1];
2137
2370
  if (lastMsg?.message?.content?.[0]?.type === 'tool_result') {
2138
2371
  const block = lastMsg.message.content[0];
@@ -2149,6 +2382,7 @@ export class CortexOrchestrator {
2149
2382
  budgetSignal ? 'budget' : null,
2150
2383
  diversityWarning ? 'diversity' : null,
2151
2384
  ladderSignal ? 'ladder' : null,
2385
+ timeSignal ? 'time' : null,
2152
2386
  ].filter(Boolean);
2153
2387
  void store
2154
2388
  .recordEvent({
@@ -2185,6 +2419,23 @@ export class CortexOrchestrator {
2185
2419
  `recent calls cycling). Forcing synthesis.`);
2186
2420
  break;
2187
2421
  }
2422
+ // #2 (2026-09-04) wall-clock break — UNCONDITIONAL (unlike R29b's progress-gate): a grinder
2423
+ // making novel-but-non-converging calls never trips the count/stall guards but burns to
2424
+ // MAX_TOOL_ITERATIONS (the loop-killed 24.8M-token tasks that drove the run's real cost). The
2425
+ // 90% warn above gave the model its chance; break now → post-loop R29a synthesis yields a
2426
+ // best-effort answer instead of a zero-deliverable timeout.
2427
+ if (timeBudgetState(Date.now() - loopStartMs, TURN_DEADLINE_MS) === 'break') {
2428
+ console.warn(`[Orchestrator] #2: turn wall-clock deadline reached (${TURN_DEADLINE_MS}ms) — forcing synthesis.`);
2429
+ const store = this.getDecisionStore();
2430
+ if (store) {
2431
+ void store.recordEvent({
2432
+ sessionId: this.currentSessionId ?? 'unknown',
2433
+ kind: 'time_budget_break',
2434
+ detail: { deadlineMs: TURN_DEADLINE_MS, elapsedMs: Date.now() - loopStartMs, iteration: toolCallIteration },
2435
+ }).catch(() => { });
2436
+ }
2437
+ break;
2438
+ }
2188
2439
  // Interleaved thinking: inject subtle reflection between tool iterations (iteration 2+)
2189
2440
  if (this.shouldUseInterleavedThinking() && this.helperMiddleware && toolCallIteration > 1) {
2190
2441
  try {
@@ -2236,6 +2487,7 @@ export class CortexOrchestrator {
2236
2487
  // full tool catalog (no-op unless CORTEX_PROMPT_MASS=defer).
2237
2488
  await this.deliverDeferredCorpusAtLift(effectiveModel);
2238
2489
  this.deliverLiftNudge(allTools, effectiveModel); // A′ proposal-1: SearchTools/AskForAdvice signpost at lift
2490
+ await this.deliverLiftPlanAtLift(effectiveModel); // LIFT_MENTOR_PLANNER: bounded mentor-planner at lift (dark unless CORTEX_LIFT_PLAN)
2239
2491
  if (this.config.debug)
2240
2492
  console.log('[Anchor] lifted at first tool_result boundary — session profile applies');
2241
2493
  }
@@ -3292,6 +3544,10 @@ export class CortexOrchestrator {
3292
3544
  let streamEndTurnNudges = 0;
3293
3545
  let pendingGateRequest = false; // Stage-1: a reminder was appended; run one continuation with NO tool batch
3294
3546
  const streamEndTurnGateEnabled = process.env.CORTEX_ENDTURN_GATE === 'true';
3547
+ // #2 (2026-09-04): per-turn wall-clock budget (opt-in — 0 disables; streaming parity with non-stream).
3548
+ const TURN_DEADLINE_MS = loopDefaults.turnDeadlineMs;
3549
+ const loopStartMs = Date.now();
3550
+ let timeWarnFired = false;
3295
3551
  while (hasToolUse && toolCallIteration < MAX_TOOL_ITERATIONS) {
3296
3552
  const gateReRequest = pendingGateRequest;
3297
3553
  pendingGateRequest = false;
@@ -3598,12 +3854,14 @@ export class CortexOrchestrator {
3598
3854
  // Execute tools with timeout (outer cap fires a grace period AFTER the tool's own deadline —
3599
3855
  // see the non-stream site: ShellTool's promote-at-deadline must win the race; 4.90.1)
3600
3856
  const abortController = new AbortController();
3857
+ // D-A (2026-09-04): outer cap sits above the requested Bash timeout (see the non-stream site).
3858
+ const outerDeadlineMs = resolveOuterToolDeadlineMs(toolUseBlocks, TOOL_TIMEOUT_MS, OUTER_TIMEOUT_GRACE_MS);
3601
3859
  const timeoutId = setTimeout(() => {
3602
3860
  if (this.config.debug) {
3603
- console.warn(`[Orchestrator Streaming] Tool execution timeout after ${TOOL_TIMEOUT_MS + OUTER_TIMEOUT_GRACE_MS}ms`);
3861
+ console.warn(`[Orchestrator Streaming] Tool execution timeout after ${outerDeadlineMs}ms`);
3604
3862
  }
3605
3863
  abortController.abort();
3606
- }, TOOL_TIMEOUT_MS + OUTER_TIMEOUT_GRACE_MS);
3864
+ }, outerDeadlineMs);
3607
3865
  try {
3608
3866
  // Execute tools (reuse existing method)
3609
3867
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
@@ -3612,6 +3870,7 @@ export class CortexOrchestrator {
3612
3870
  // 4.91.0: EndTurn gates for STREAMING (were sendMessage-only) — same evidence + evaluation.
3613
3871
  ev.noteToolResults(toolResults);
3614
3872
  evaluateEndTurnGates(ev, toolResults, toolUseBlocks, this.gateDeps());
3873
+ await this.adjudicateEndTurn(ev, toolResults, toolUseBlocks, effectiveModel); // endTurnResolver (dark unless CORTEX_ENDTURN_RESOLVER)
3615
3874
  // Unified Outcome Ladder (streaming parity with sendMessage).
3616
3875
  ladderSignal = null;
3617
3876
  {
@@ -3805,8 +4064,14 @@ export class CortexOrchestrator {
3805
4064
  const progressStalled = isToolProgressStalled(allToolCalls);
3806
4065
  const budgetSignal = computeToolBudgetSignal(effectiveToolBudgetCount, TOOL_BUDGET_SOFT, progressStalled);
3807
4066
  const diversityWarning = this.getDiversityWarning(toolCallCounts);
3808
- if (budgetSignal || diversityWarning || ladderSignal) {
3809
- const signals = [budgetSignal, diversityWarning, ladderSignal].filter(Boolean).join('\n');
4067
+ // #2: one-shot wall-clock warning at 90% of the deadline (streaming parity).
4068
+ let timeSignal = null;
4069
+ if (!timeWarnFired && timeBudgetState(Date.now() - loopStartMs, TURN_DEADLINE_MS) === 'warn') {
4070
+ timeSignal = timeBudgetWarnNudge(Date.now() - loopStartMs, TURN_DEADLINE_MS);
4071
+ timeWarnFired = true;
4072
+ }
4073
+ if (budgetSignal || diversityWarning || ladderSignal || timeSignal) {
4074
+ const signals = [budgetSignal, diversityWarning, ladderSignal, timeSignal].filter(Boolean).join('\n');
3810
4075
  const lastMsg = this.messageHistory[this.messageHistory.length - 1];
3811
4076
  if (lastMsg?.message?.content?.[0]?.type === 'tool_result') {
3812
4077
  const block = lastMsg.message.content[0];
@@ -3853,6 +4118,22 @@ export class CortexOrchestrator {
3853
4118
  hasToolUse = false;
3854
4119
  break;
3855
4120
  }
4121
+ // #2 (2026-09-04) wall-clock break — UNCONDITIONAL (streaming parity; the 90% warn above gave
4122
+ // the model its chance). A grinder making novel-but-non-converging calls never trips the
4123
+ // count/stall guards but burns to MAX_TOOL_ITERATIONS → post-loop synthesis yields best-effort.
4124
+ if (timeBudgetState(Date.now() - loopStartMs, TURN_DEADLINE_MS) === 'break') {
4125
+ console.warn(`[Orchestrator Streaming] #2: turn wall-clock deadline reached (${TURN_DEADLINE_MS}ms) — forcing synthesis.`);
4126
+ const store = this.getDecisionStore();
4127
+ if (store) {
4128
+ void store.recordEvent({
4129
+ sessionId: this.currentSessionId ?? 'unknown',
4130
+ kind: 'time_budget_break',
4131
+ detail: { deadlineMs: TURN_DEADLINE_MS, elapsedMs: Date.now() - loopStartMs, iteration: toolCallIteration, streaming: true },
4132
+ }).catch(() => { });
4133
+ }
4134
+ hasToolUse = false;
4135
+ break;
4136
+ }
3856
4137
  // Interleaved thinking: inject subtle reflection between tool iterations
3857
4138
  if (this.shouldUseInterleavedThinking() && this.helperMiddleware && toolCallIteration > 1) {
3858
4139
  try {
@@ -3895,6 +4176,7 @@ export class CortexOrchestrator {
3895
4176
  // P6 deferral: same one-shot corpus delivery as the sendMessage path.
3896
4177
  await this.deliverDeferredCorpusAtLift(effectiveModel);
3897
4178
  this.deliverLiftNudge(allTools, effectiveModel); // A′ proposal-1: SearchTools/AskForAdvice signpost at lift
4179
+ await this.deliverLiftPlanAtLift(effectiveModel); // LIFT_MENTOR_PLANNER: bounded mentor-planner at lift (dark unless CORTEX_LIFT_PLAN)
3898
4180
  if (this.config.debug)
3899
4181
  console.log('[Anchor] lifted at first tool_result boundary — session profile applies');
3900
4182
  }
@@ -4534,6 +4816,8 @@ export class CortexOrchestrator {
4534
4816
  toolBudgetSoft: Number.isFinite(softRaw) && softRaw >= 0 ? softRaw : 400,
4535
4817
  toolTimeoutMs: this.config.loopControl?.toolTimeoutMs ?? 120000,
4536
4818
  maxLoopRepetitions: this.config.loopControl?.maxLoopRepetitions ?? 5,
4819
+ // #2 wall-clock deadline: opt-in (0 = disabled), env CORTEX_TURN_DEADLINE_MS is the fallback.
4820
+ turnDeadlineMs: resolveTurnDeadlineMs(this.config.loopControl?.turnDeadlineMs),
4537
4821
  };
4538
4822
  }
4539
4823
  // Item 13c: fire on threshold CROSSINGS only (10, 20, 40… doubling) — the