@adhdev/daemon-core 0.9.82-rc.456 → 0.9.82-rc.457

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.
@@ -22,6 +22,8 @@ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pt
22
22
  import { StatusMonitor } from './status-monitor.js';
23
23
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
24
24
  import { LOG } from '../logging/logger.js';
25
+ import { recordDebugTrace } from '../logging/debug-trace.js';
26
+ import { shouldCollectTraceCategory } from '../logging/debug-config.js';
25
27
  import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
26
28
  import type { ChatMessage } from '../types.js';
27
29
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
@@ -1526,9 +1528,23 @@ export class CliProviderInstance implements ProviderInstance {
1526
1528
  }
1527
1529
 
1528
1530
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1531
+ // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1532
+ // pure message-content check ("does the last visible bubble read as a finalized assistant
1533
+ // reply, post-dating the turn start?"). That LOWER bound alone treated the FIRST assistant
1534
+ // bubble of a turn that is STILL running — a tool call in flight between two assistant
1535
+ // bubbles — as proof the turn ended (RCA cases a & b). Require in ADDITION that the turn is
1536
+ // genuinely OVER: hasAdapterPendingResponse() folds the three upper-bound discriminators
1537
+ // into one — currentTurnScope closed, no in-flight tool (isProcessing false), and no partial
1538
+ // response buffer. So a mid-turn point-sample (short-gen / fast-collapse inline paths that
1539
+ // do NOT route through getCompletedFinalizationBlock) yields present=false and is held/settled
1540
+ // rather than fired. A genuinely-finished turn (adapter idle, no pending) is unaffected — the
1541
+ // gate stays open and the completion fires exactly as before. This mirrors the established
1542
+ // `completionHasFinalAssistantMessage(...) && !hasAdapterPendingResponse()` pairing already
1543
+ // used by the no-progress monitor reconcile path.
1544
+ const turnClosed = !this.hasAdapterPendingResponse();
1529
1545
  if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1530
1546
  return {
1531
- present: true,
1547
+ present: turnClosed,
1532
1548
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1533
1549
  source: 'parsed',
1534
1550
  };
@@ -1537,7 +1553,7 @@ export class CliProviderInstance implements ProviderInstance {
1537
1553
  const externalMessages = this.readExternalCompletionMessages();
1538
1554
  if (externalMessages) {
1539
1555
  return {
1540
- present: this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1556
+ present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1541
1557
  messages: externalMessages,
1542
1558
  source: 'external-native',
1543
1559
  };
@@ -1690,11 +1706,21 @@ export class CliProviderInstance implements ProviderInstance {
1690
1706
 
1691
1707
  const adapterAny = this.adapter as any;
1692
1708
  const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1693
- if (!approvalResolvedIdle) {
1694
- if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1695
- if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1696
- if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1697
- }
1709
+ // (FALSEIDLE-a FixA) The adapter pending-response checks run UNCONDITIONALLY.
1710
+ // Previously they were SKIPPED when approvalResolvedIdle, on the assumption that
1711
+ // a waiting_approval→idle transition proved the approval's turn was over. But
1712
+ // auto-approve RESOLVES the modal and the agent RESUMES the same turn — currentTurnScope
1713
+ // / isWaitingForResponse stay set, or a tool runs — so skipping the guard let the FIRST
1714
+ // assistant bubble of the still-running turn be mistaken for the last and fired an early
1715
+ // completion the coordinator could never correct (RCA case a). Keep the guard live for the
1716
+ // approval path too: when the resumed turn genuinely ends these clear and the completion
1717
+ // fires. Approval-resolved holds are NON-terminal (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS)
1718
+ // so a provider that never closes its turn-scope still force-fires a weak completion rather
1719
+ // than wedging; the non-approval path keeps its terminal hold (a genuinely-busy adapter must
1720
+ // never force a completion out).
1721
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: !approvalResolvedIdle };
1722
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: !approvalResolvedIdle };
1723
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: !approvalResolvedIdle };
1698
1724
 
1699
1725
  const partial = typeof this.adapter.getPartialResponse === 'function'
1700
1726
  ? this.adapter.getPartialResponse()
@@ -1891,6 +1917,40 @@ export class CliProviderInstance implements ProviderInstance {
1891
1917
  };
1892
1918
  }
1893
1919
 
1920
+ // COMPLETION-EARLYNOTIFY instrumentation. A session-keyed FSM-transition +
1921
+ // completion-gate snapshot recorded into the shared debug-trace ring buffer
1922
+ // (secret-safe, length/role/pattern-name only — never screen or bubble text).
1923
+ // Retrieved via getRecentDebugTrace (chat_debug_bundle). Both categories are a
1924
+ // no-op unless collectDebugTrace is on AND the category is selected, so the
1925
+ // hot-path guards below (completionTraceOn / fsmTraceOn) keep production cost
1926
+ // at a single boolean check.
1927
+ private completionTraceOn(): boolean {
1928
+ return shouldCollectTraceCategory('completion-gate');
1929
+ }
1930
+ private fsmTraceOn(): boolean {
1931
+ return shouldCollectTraceCategory('fsm-transition');
1932
+ }
1933
+ private recordCompletionGateTrace(stage: string, payload: Record<string, unknown>): void {
1934
+ recordDebugTrace({
1935
+ category: 'completion-gate',
1936
+ stage,
1937
+ level: 'debug',
1938
+ sessionId: this.instanceId,
1939
+ providerType: this.type,
1940
+ payload,
1941
+ });
1942
+ }
1943
+ private recordFsmTransitionTrace(payload: Record<string, unknown>): void {
1944
+ recordDebugTrace({
1945
+ category: 'fsm-transition',
1946
+ stage: 'transition',
1947
+ level: 'debug',
1948
+ sessionId: this.instanceId,
1949
+ providerType: this.type,
1950
+ payload,
1951
+ });
1952
+ }
1953
+
1894
1954
  private flushCompletedDebounceIfFinalized(): void {
1895
1955
  const pending = this.completedDebouncePending;
1896
1956
  if (!pending) {
@@ -1904,6 +1964,13 @@ export class CliProviderInstance implements ProviderInstance {
1904
1964
  LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
1905
1965
  if (latestVisibleStatus !== 'idle') {
1906
1966
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
1967
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
1968
+ blockReason: 'resumed_status',
1969
+ latestVisibleStatus,
1970
+ previousStatus: pending.previousStatus,
1971
+ busyEpochAtArm: pending.busyEpochAtArm,
1972
+ busyEpoch: this.busyEpoch,
1973
+ });
1907
1974
  this.completedDebouncePending = null;
1908
1975
  this.completedDebounceTimer = null;
1909
1976
  return;
@@ -1921,6 +1988,14 @@ export class CliProviderInstance implements ProviderInstance {
1921
1988
  // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
1922
1989
  if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
1923
1990
  LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
1991
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
1992
+ blockReason: 'busy_reentry',
1993
+ latestVisibleStatus,
1994
+ previousStatus: pending.previousStatus,
1995
+ busyEpochAtArm: pending.busyEpochAtArm,
1996
+ busyEpoch: this.busyEpoch,
1997
+ busyEpochDelta: this.busyEpoch - pending.busyEpochAtArm,
1998
+ });
1924
1999
  this.completedDebouncePending = null;
1925
2000
  this.completedDebounceTimer = null;
1926
2001
  return;
@@ -1930,6 +2005,14 @@ export class CliProviderInstance implements ProviderInstance {
1930
2005
  && typeof latestOutputAt === 'number'
1931
2006
  && latestOutputAt > pending.lastOutputAtArm) {
1932
2007
  LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
2008
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
2009
+ blockReason: 'new_pty_output',
2010
+ latestVisibleStatus,
2011
+ previousStatus: pending.previousStatus,
2012
+ lastOutputAtArm: pending.lastOutputAtArm,
2013
+ lastOutputAt: latestOutputAt,
2014
+ lastOutputAtDelta: latestOutputAt - pending.lastOutputAtArm,
2015
+ });
1933
2016
  this.completedDebouncePending = null;
1934
2017
  this.completedDebounceTimer = null;
1935
2018
  return;
@@ -1975,6 +2058,17 @@ export class CliProviderInstance implements ProviderInstance {
1975
2058
  if (this.isMeshWorkerSession()) {
1976
2059
  traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1977
2060
  }
2061
+ // COMPLETION-EARLYNOTIFY: a hold is the CORRECT outcome when the turn is not
2062
+ // yet proven done (the FixA/FixB gates route here); trace it so an early-notify
2063
+ // investigation can see the gate holding rather than firing.
2064
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
2065
+ blockReason,
2066
+ latestVisibleStatus,
2067
+ terminal: block.terminal === true,
2068
+ holdForTranscript: block.holdForTranscript === true,
2069
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2070
+ waitedMs,
2071
+ });
1978
2072
  pending.loggedBlockReason = blockReason;
1979
2073
  }
1980
2074
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
@@ -1997,6 +2091,19 @@ export class CliProviderInstance implements ProviderInstance {
1997
2091
  if (this.isMeshWorkerSession()) {
1998
2092
  traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1999
2093
  }
2094
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
2095
+ path: isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'canon_c_decoupled' : 'forced_timeout',
2096
+ blockReason,
2097
+ latestVisibleStatus,
2098
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2099
+ finalAssistantPresent: (completionDiagnostic as any).finalAssistantPresent === true,
2100
+ evidenceSource: (completionDiagnostic as any).finalAssistantEvidenceSource ?? null,
2101
+ lastVisibleRole: (completionDiagnostic as any).lastVisibleRole ?? null,
2102
+ lastVisibleContentLen: (completionDiagnostic as any).lastVisibleContentLength ?? null,
2103
+ emittedAfterFinalizationTimeout,
2104
+ waitedMs,
2105
+ busyEpoch: this.busyEpoch,
2106
+ });
2000
2107
  this.pushEvent({
2001
2108
  event: 'agent:generating_completed',
2002
2109
  chatTitle: pending.chatTitle,
@@ -2028,6 +2135,14 @@ export class CliProviderInstance implements ProviderInstance {
2028
2135
  if (this.isMeshWorkerSession()) {
2029
2136
  traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
2030
2137
  }
2138
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
2139
+ path: 'clean',
2140
+ latestVisibleStatus,
2141
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2142
+ finalAssistantPresent: true,
2143
+ duration: pending.duration,
2144
+ busyEpoch: this.busyEpoch,
2145
+ });
2031
2146
  this.pushEvent({
2032
2147
  event: 'agent:generating_completed',
2033
2148
  chatTitle: pending.chatTitle,
@@ -2424,6 +2539,22 @@ export class CliProviderInstance implements ProviderInstance {
2424
2539
  const previousStatus = this.lastStatus;
2425
2540
  if (newStatus !== this.lastStatus) {
2426
2541
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
2542
+ // COMPLETION-EARLYNOTIFY: snapshot every FSM status transition (the arm/fire/cancel
2543
+ // decisions downstream all hang off these edges). Guarded so production pays only a
2544
+ // boolean check; payload carries the visibility/auto-approve flags and the continuity
2545
+ // clocks (busyEpoch / lastOutputAt / lastScreenChangeAt) that the completion gate reads.
2546
+ if (this.fsmTraceOn()) this.recordFsmTransitionTrace({
2547
+ from: this.lastStatus,
2548
+ to: newStatus,
2549
+ rawStatus,
2550
+ autoApproveActive,
2551
+ autoApproveHoldIdle,
2552
+ autoApproveBusy: this.autoApproveBusy,
2553
+ hasPending: this.hasAdapterPendingResponse(),
2554
+ busyEpoch: this.busyEpoch,
2555
+ lastOutputAt: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
2556
+ lastScreenChangeAt: typeof adapterStatus?.lastScreenChangeAt === 'number' ? adapterStatus.lastScreenChangeAt : null,
2557
+ });
2427
2558
  // GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
2428
2559
  // is in 'starting' until its startup-grace settles to idle. When the FIRST inject
2429
2560
  // lands inside that grace window, the adapter can report status DIRECTLY
@@ -2661,6 +2792,17 @@ export class CliProviderInstance implements ProviderInstance {
2661
2792
  if (this.isMeshWorkerSession()) {
2662
2793
  traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
2663
2794
  }
2795
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
2796
+ branch: 'short_generating',
2797
+ previousStatus: this.lastStatus,
2798
+ turnStartedAt: shortTurnStartedAt || null,
2799
+ busyEpochAtArm: this.busyEpoch,
2800
+ lastOutputAtArm: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
2801
+ flushDelay: NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
2802
+ evidenceSource: shortEvidenceSource,
2803
+ missingEvidence,
2804
+ hasFinalSummary: !!shortFinalSummary,
2805
+ });
2664
2806
  this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
2665
2807
  } else if (missingEvidence) {
2666
2808
  // NON-MESH, missing evidence: suppress the completion event entirely (the
@@ -2749,6 +2891,16 @@ export class CliProviderInstance implements ProviderInstance {
2749
2891
  ? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2750
2892
  : 3000;
2751
2893
  LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshSettle=${meshSettleSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2894
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
2895
+ branch: 'normal',
2896
+ previousStatus: this.completedDebouncePending.previousStatus,
2897
+ turnStartedAt: this.completedDebouncePending.turnStartedAt ?? null,
2898
+ busyEpochAtArm: this.completedDebouncePending.busyEpochAtArm ?? null,
2899
+ lastOutputAtArm: this.completedDebouncePending.lastOutputAtArm ?? null,
2900
+ flushDelay,
2901
+ ownsExternalHistory,
2902
+ meshSettle: meshSettleSession,
2903
+ });
2752
2904
  this.scheduleCompletedDebounceFlush(flushDelay);
2753
2905
  }
2754
2906
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
@@ -37,6 +37,11 @@ import {
37
37
  resolveActiveSource,
38
38
  } from './external-sources.js';
39
39
  import type { ProviderSourceMode } from '../config/config.js';
40
+ import {
41
+ resolveRegistryBaseUrl,
42
+ resolveProviderTarballUrl,
43
+ resolveProviderTarballTarget,
44
+ } from '../config/registry-resolver.js';
40
45
  import type { ProviderSourceConfigSnapshot, ProviderUserDirSource } from '../config/provider-source-config.js';
41
46
  import { executeNativeHistory } from './spec/native-history-executor.js';
42
47
  import { createNativeHistoryDispatcher, type ReaderId } from './native-history/dispatcher.js';
@@ -186,13 +191,19 @@ export class ProviderLoader {
186
191
  private versionArchive: VersionArchive | null = null;
187
192
  private scriptsCache = new Map<string, Partial<ProviderScripts>>();
188
193
 
194
+ /**
195
+ * Resolved registry base URL and provider tarball URL. Resolution order:
196
+ * explicit config field (constructor option) → env var → vendor default.
197
+ * See `config/registry-resolver.ts`.
198
+ */
199
+ private readonly registryBaseUrl: string;
200
+ private readonly providerTarballUrl: string;
201
+
189
202
  /** Inject VersionArchive so resolve() can auto-detect installed versions */
190
203
  setVersionArchive(archive: VersionArchive): void {
191
204
  this.versionArchive = archive;
192
205
  }
193
206
 
194
- private static readonly GITHUB_TARBALL_URL = 'https://github.com/vilmire/adhdev-providers/archive/refs/heads/main.tar.gz';
195
- private static readonly REGISTRY_BASE_URL = 'https://api.adhf.dev/api/v1/registry';
196
207
  private static readonly META_FILE = '.meta.json';
197
208
  private static readonly REGISTRY_META_FILE = '.registry-meta.json';
198
209
  private static readonly REPO_PROVIDER_DIRNAME = 'adhdev-providers';
@@ -278,9 +289,21 @@ export class ProviderLoader {
278
289
  * probing; production code should leave this unset.
279
290
  */
280
291
  probeStarts?: string[];
292
+ /**
293
+ * Explicit provider registry base URL override (config.registryUrl).
294
+ * Highest-priority resolver source, ahead of ADHDEV_REGISTRY_URL + default.
295
+ */
296
+ registryUrl?: string;
297
+ /**
298
+ * Explicit provider tarball URL override (config.providerTarballUrl).
299
+ * Highest-priority resolver source, ahead of ADHDEV_PROVIDER_TARBALL_URL + default.
300
+ */
301
+ providerTarballUrl?: string;
281
302
  }) {
282
303
  this.logFn = options?.logFn || LOG.forComponent('Provider').asLogFn();
283
304
  this.probeStarts = options?.probeStarts ?? [process.cwd(), __dirname];
305
+ this.registryBaseUrl = resolveRegistryBaseUrl(options?.registryUrl);
306
+ this.providerTarballUrl = resolveProviderTarballUrl(options?.providerTarballUrl);
284
307
 
285
308
  // Default directory for auto-downloads
286
309
  this.defaultProvidersDir = path.join(os.homedir(), '.adhdev', 'providers');
@@ -1554,7 +1577,7 @@ export class ProviderLoader {
1554
1577
  this.log('Registry sync skipped (sourceMode=no-upstream)');
1555
1578
  return { updated: false };
1556
1579
  }
1557
- this.log(`Registry sync starting (${ProviderLoader.REGISTRY_BASE_URL})...`);
1580
+ this.log(`Registry sync starting (${this.registryBaseUrl})...`);
1558
1581
 
1559
1582
  const https = require('https') as typeof import('https');
1560
1583
  const regMetaPath = path.join(this.upstreamDir, ProviderLoader.REGISTRY_META_FILE);
@@ -1569,7 +1592,7 @@ export class ProviderLoader {
1569
1592
 
1570
1593
  try {
1571
1594
  // 1. Fetch provider list
1572
- const listUrl = `${ProviderLoader.REGISTRY_BASE_URL}/providers`;
1595
+ const listUrl = `${this.registryBaseUrl}/providers`;
1573
1596
  const listBody = await new Promise<string>((resolve, reject) => {
1574
1597
  const req = https.get(listUrl, { headers: { 'User-Agent': 'adhdev-daemon', 'Accept': 'application/json' }, timeout: 10000 }, (res) => {
1575
1598
  if (res.statusCode !== 200) { reject(new Error(`registry list HTTP ${res.statusCode}`)); return; }
@@ -1592,7 +1615,7 @@ export class ProviderLoader {
1592
1615
  if (cachedChecksums[cacheKey] === checksum) continue; // already current
1593
1616
 
1594
1617
  // Download this provider's manifest
1595
- const dlUrl = `${ProviderLoader.REGISTRY_BASE_URL}/providers/${type}/${version}/download`;
1618
+ const dlUrl = `${this.registryBaseUrl}/providers/${type}/${version}/download`;
1596
1619
  const manifestBody = await new Promise<string>((resolve, reject) => {
1597
1620
  const req = https.get(dlUrl, { headers: { 'User-Agent': 'adhdev-daemon', 'Accept': 'application/json' }, timeout: 30000 }, (res) => {
1598
1621
  if (res.statusCode !== 200) { reject(new Error(`registry download HTTP ${res.statusCode} for ${type}@${version}`)); return; }
@@ -1667,13 +1690,17 @@ export class ProviderLoader {
1667
1690
  return { updated: false };
1668
1691
  }
1669
1692
 
1693
+ // Resolve the tarball target (config → env → vendor default) once so the
1694
+ // HEAD probe and the download below hit the same (possibly self-hosted) URL.
1695
+ const tarballTarget = resolveProviderTarballTarget(this.providerTarballUrl);
1696
+
1670
1697
  try {
1671
1698
  // Step 1: HEAD request to check ETag
1672
1699
  const etag = await new Promise<string>((resolve, reject) => {
1673
1700
  const options = {
1674
1701
  method: 'HEAD',
1675
- hostname: 'github.com',
1676
- path: '/vilmire/adhdev-providers/archive/refs/heads/main.tar.gz',
1702
+ hostname: tarballTarget.hostname,
1703
+ path: tarballTarget.path,
1677
1704
  headers: { 'User-Agent': 'adhdev-launcher' },
1678
1705
  timeout: 10000,
1679
1706
  };
@@ -1718,7 +1745,7 @@ export class ProviderLoader {
1718
1745
  const tmpExtract = path.join(os.tmpdir(), `adhdev-providers-extract-${Date.now()}`);
1719
1746
 
1720
1747
  // Download tarball
1721
- await this.downloadFile(ProviderLoader.GITHUB_TARBALL_URL, tmpTar);
1748
+ await this.downloadFile(tarballTarget.url, tmpTar);
1722
1749
 
1723
1750
  // Extract
1724
1751
  fs.mkdirSync(tmpExtract, { recursive: true });
@@ -1825,7 +1852,7 @@ export class ProviderLoader {
1825
1852
  etag,
1826
1853
  timestamp,
1827
1854
  lastCheck: new Date(timestamp).toISOString(),
1828
- source: ProviderLoader.GITHUB_TARBALL_URL,
1855
+ source: this.providerTarballUrl,
1829
1856
  }, null, 2));
1830
1857
  } catch { }
1831
1858
  }
@@ -38,6 +38,8 @@ import { loadFsmSpec } from './fsm-loader.js';
38
38
  import { applyPreLaunchTrust } from './pre-launch-trust.js';
39
39
  import type { Control, DelegateTrigger } from './types.js';
40
40
  import { LOG } from '../../logging/logger.js';
41
+ import { recordDebugTrace } from '../../logging/debug-trace.js';
42
+ import { shouldCollectTraceCategory } from '../../logging/debug-config.js';
41
43
  import {
42
44
  WIN32_PTY_WRITE_CHUNK_CHARS,
43
45
  WIN32_PTY_WRITE_CHUNK_GAP_MS,
@@ -300,6 +302,11 @@ export class FsmDriver implements ISpecDriver {
300
302
  * (−1 = whole screen), or a `section:<id>` / `<region>#ignore:<pat>` string
301
303
  * when the clause scopes to a section or declares an ignore_lines filter. */
302
304
  private regionLastChangedAt = new Map<number | string, number>();
305
+ /** COMPLETION-EARLYNOTIFY stable-eval trace: last stable/not-stable verdict
306
+ * recorded per stable region, so the trace fires only when the verdict FLIPS
307
+ * (not every quiet frame). Cleared on every transition alongside
308
+ * regionLastChangedAt. Diagnostic-only — never consulted by the FSM. */
309
+ private stableVerdictCache = new Map<number | string, boolean>();
303
310
  /** Timer that re-runs evaluate() when a time-condition would flip true
304
311
  * with no PTY frame to trigger it. */
305
312
  private wakeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -684,6 +691,7 @@ export class FsmDriver implements ISpecDriver {
684
691
  // Region change timestamps are relative to the previous state's
685
692
  // activity; reset so stable_ms in the new state measures from entry.
686
693
  this.regionLastChangedAt.clear();
694
+ this.stableVerdictCache.clear();
687
695
  this.pushHistory(fired.to, stateById(this.spec, fired.to)?.label ?? fired.to, {
688
696
  reason: 'transition',
689
697
  via: `${from}→${fired.to}`,
@@ -818,6 +826,13 @@ export class FsmDriver implements ISpecDriver {
818
826
  private trackRegionChanges(currentLines: string[], cursor: { row: number; col: number }, now: number): void {
819
827
  if (this.prevScreenLines.length === 0) return;
820
828
  const descs = this.stableRegionDescriptors();
829
+ // COMPLETION-EARLYNOTIFY hook 4: record the stable/not-stable verdict for each
830
+ // tracked region, but ONLY when the verdict flips (see stableVerdictCache) so a
831
+ // quiet screen does not spam the ring buffer. This is the case-b diagnostic — an
832
+ // ignore_lines-scoped stable clause declaring a tool-execution screen "stable-idle"
833
+ // shows up here as verdict:true with a short fingerprint. Payload carries lengths
834
+ // and the pattern SOURCE only — never screen text.
835
+ const stableTraceOn = shouldCollectTraceCategory('fsm-transition');
821
836
  // Section ranges depend on screen content, so resolve per-frame for both
822
837
  // frames — but only when some tracked region is actually section-scoped.
823
838
  const needsSections = descs.some(d => !!d.section);
@@ -839,6 +854,28 @@ export class FsmDriver implements ISpecDriver {
839
854
  const cur = filterIgnoredLines(curLines, d.ignoreRe).join('\n');
840
855
  const prev = filterIgnoredLines(prevLines, d.ignoreRe).join('\n');
841
856
  if (cur !== prev) this.regionLastChangedAt.set(d.key, now);
857
+ if (stableTraceOn && typeof d.holdMs === 'number') {
858
+ const lastChanged = this.regionLastChangedAt.get(d.key) ?? this.stateEnteredAt;
859
+ const ageMs = now - lastChanged;
860
+ const verdict = ageMs >= d.holdMs;
861
+ if (this.stableVerdictCache.get(d.key) !== verdict) {
862
+ this.stableVerdictCache.set(d.key, verdict);
863
+ recordDebugTrace({
864
+ category: 'fsm-transition',
865
+ stage: 'stable-eval',
866
+ level: 'debug',
867
+ payload: {
868
+ state: this.currentStateId,
869
+ regionKey: String(d.key),
870
+ ignorePattern: d.ignoreRe?.source ?? null,
871
+ fingerprintLen: cur.length,
872
+ ageMs,
873
+ holdMs: d.holdMs,
874
+ verdict,
875
+ },
876
+ });
877
+ }
878
+ }
842
879
  }
843
880
  }
844
881
 
@@ -1440,6 +1477,11 @@ interface StableRegionDescriptor {
1440
1477
  section?: string;
1441
1478
  cursor_above?: number;
1442
1479
  ignoreRe?: RegExp;
1480
+ /** The stable_ms threshold the FIRST clause on this region declares. Used
1481
+ * only by the COMPLETION-EARLYNOTIFY stable-eval trace to report the
1482
+ * stable/not-stable verdict; the FSM decision itself is owned by the
1483
+ * evaluator against the live clause. */
1484
+ holdMs?: number;
1443
1485
  }
1444
1486
 
1445
1487
  function collectStableDescriptors(when: FsmTransition['when'], byKey: Map<number | string, StableRegionDescriptor>): void {
@@ -1447,14 +1489,19 @@ function collectStableDescriptors(when: FsmTransition['when'], byKey: Map<number
1447
1489
  const w = when as any;
1448
1490
  if ('stable_ms' in w) {
1449
1491
  const key = stableRegionKey(w);
1450
- if (!byKey.has(key)) {
1492
+ const existing = byKey.get(key);
1493
+ if (!existing) {
1451
1494
  let ignoreRe: RegExp | undefined;
1452
1495
  if (w.ignore_lines) {
1453
1496
  // Compile once here; a bad pattern is validated at load time, so
1454
1497
  // this is best-effort and simply skips the filter if it throws.
1455
1498
  try { ignoreRe = new RegExp(w.ignore_lines, 'm'); } catch { /* validated at load */ }
1456
1499
  }
1457
- byKey.set(key, { key, section: w.section, cursor_above: w.cursor_above, ignoreRe });
1500
+ byKey.set(key, { key, section: w.section, cursor_above: w.cursor_above, ignoreRe, holdMs: typeof w.stable_ms === 'number' ? w.stable_ms : undefined });
1501
+ } else if (existing.holdMs === undefined && typeof w.stable_ms === 'number') {
1502
+ // Enrich the -1 whole-screen seed (or an earlier clause) with a threshold
1503
+ // so its verdict can be traced. Geometry/ignoreRe from the first set win.
1504
+ existing.holdMs = w.stable_ms;
1458
1505
  }
1459
1506
  return;
1460
1507
  }