diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee33a71a4..34246ce953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- feat(quorum): **deep-probe gate — preflight can now tell a quota-dead slot from a healthy one (P1 of #293, the last piece).** L1 (`--version`) proves a binary exists and L2 (`/models`) treats 401/403 as *reachable*, so neither notices a slot that is authenticated, reachable, and out of quota. Demonstrated live on 2026-08-08: preflight reported **7/7 available** while `claude-z-ai` (429, weekly limit) and `antigravity-1` (quota, 74h to reset) could not answer at all — a panel promising full quorum that then produces two UNAVAILs mid-round. `deepProbeSlot()` runs the slot's configured `deep_probe` and is deliberately **lopsided**: it downgrades *only* on a fast, explicit auth/quota signal in the slot's own output; a timeout, spawn error, or unrecognised response is `INCONCLUSIVE` and never downgrades. That asymmetry is the point — the opposite error, killing a slow-but-alive slot, is the one this codebase has already paid for three times (STALL-TIMEOUT-01..03). It runs only when the panel is already **degraded** *and* an explicit `--budget-ms` covers it (a naive auto-enable previously blew the `<8s` liveness budget with `spawnSync ETIMEDOUT`), or on demand via `--deep-probe`. Live-verified end to end: both dead slots are now downgraded with their real reasons and the gate reports `DEGRADED — 5 available`, where the cheap budget-less path still finishes in ~3s having skipped the probe entirely. - feat(quorum): **preflight now validates the roster and decides the quorum gate (P3 + P5 of #293, extracted fresh).** `validateProviders()` reports config faults that would otherwise surface mid-quorum as an opaque UNAVAIL — the two this repo has actually shipped are a slot with neither `cli` nor `mainTool` (spawn receives `null`, taking the whole quorum offline, #197) and an `args_template` with no `{prompt}` placeholder (the CLI runs with flags and no question). Also errors on duplicate names (silently last-wins everywhere downstream), missing/unknown `type`, and http slots without a base URL or any auth; warns on non-positive timeout fields and on third-party-routed slots with no `deep_probe`. Non-fatal by design: it reports on every `--all` and never blocks the preflight. `computeQuorumGate()` turns *"available < max_quorum_size → BLOCK unless `--force-quorum`"* from prose in `commands/nf/quorum.md` into a machine field — `{available_count, quorum_met, degraded, blocked, waiver_required, waiver_used, gate_reason}` — because a gate that depends on a model reading a paragraph correctly is not a gate. It keeps **degraded** and **blocked** distinct (below `max_quorum_size` but above `min_live_voters` still dispatches), and a `--force-quorum` waiver unblocks while being *recorded* rather than silently making the panel look healthy. 11 tests, each of the three core behaviours mutation-proven. - feat(release): **`Align dist-tags` — a manually dispatchable workflow that repairs `@next` drift without a one-time password.** `publish.yml` aligns `@next` automatically after every publish since #391, but that cannot help a release published *before* that fix, or an alignment that failed transiently. Doing it by hand needs `npm dist-tag add`, which on a 2FA-protected account fails with `EOTP` and demands an authenticator code — so it cannot be scripted, and in practice it simply does not get done (0.44.3 shipped with `next` still on 0.44.2). The `NPM_TOKEN` secret is 2FA-exempt, which is what automation tokens are for, so CI can do unattended what an interactive human cannot. Scope is deliberately narrow: it moves a dist-tag and cannot publish. It refuses a prerelease target (a prerelease holding `@next` would silently install for everyone under the alias policy), and — unlike `publish.yml`'s verify step, which must never fail a job whose package is already out — this one **fails** if the tag did not move, because a green manual run that changed nothing is the exact outcome worth preventing. Each `run:` block was executed against a stubbed `npm view` across all four branches (aligned / not-moved / unreadable / prerelease) rather than reviewed by eye. - feat(quorum): **timeout records now say which config produced the window (I5).** `resolveTtfbTimeout` / `resolveInterChunkCeiling` each compute a `source` (`per-slot` / `default` / `idle-budget` / `legacy-stall`) and `recordTelemetry` was dropping both it and the resolved values, so a TIMEOUT in `quorum-rounds-*.jsonl` could be seen but never attributed — the number alone never answers "did *my* config do this?", since today's per-slot value can be tomorrow's default. Records now carry `ttfb_timeout_ms`, `ttfb_source`, `inter_chunk_ceiling_ms` and `inter_chunk_source`, always present (null when unknown) so the schema stays stable for analysis. Passed as a trailing options object rather than a 15th positional parameter. 4 tests drive the **real child** and read the actual log, so the fields are proven to survive the whole dispatch path; mutation-proven. Accepted as improvement I5 by the 2026-08-07 quorum. ### Fixed +- fix(quorum): **the shared error classifier missed `"quota reached"`** — it only knew `"quota exceeded"`, so antigravity's `"Individual quota reached. Resets in 74h38m39s"` was **not classified at all** and the slot was reported healthy with three days of cooldown left. Added `quota reached`, `limit exhausted` and `limit reached`, checked against real messages and against ordinary prose a model might emit (`"I reached the conclusion that the limit is fine"` must not match). +- fix(provider-status): `main()` ran unconditionally on `require()`, so importing the module for its classifier would execute the entire probe sweep — the #198 side-effect-on-require class. Guarded with `require.main === module` and the classifier is now exported, so `quorum-preflight` shares one pattern list instead of forking the definition that decides whether a slot is declared dead. - fix(quorum): **a quota-dead slot is now held out until it actually resets, instead of being retried every 5 minutes for days (QUOTA-RESET-01).** Providers state their reset window, but in shapes `parseAvailabilityHint` did not match — `antigravity-1` says `"Resets in 95h26m37s."` and `claude-z-ai` says `"...will reset at 2026-08-09 10:01:44"`, and neither the `"in N hours"` nor the `"until "` pattern covers them. Both fell through to the 5-minute default, so a slot that was dead for **four days** burned a dispatch in every quorum round, dragging the live-voter count toward the `min_live_voters` floor. Two new shapes are parsed: a compact duration (`95h26m37s`, `32h54m`, `90s`) and an absolute `reset at YYYY-MM-DD HH:MM:SS`. Deliberately conservative in the dangerous direction — a plain `STALL`/`IDLE_TIMEOUT` still gets the 5-minute default, because over-matching would retire a healthy slot for days on one bad round. The compact parser is written as locate-token-then-decompose: a single pattern of all-optional unit groups matches the empty string, "succeeding" with every capture undefined and silently falling through to the very default it exists to replace (which is what the first version did). 6 tests, mutation-proven in both directions. This is P4 of the long-open #293, extracted fresh. - fix(install): **a stale backup no longer clobbers live slot config on every install.** When `bin/providers.json` ships empty (it always does), install reconstructs the user's `providers.json` from `~/.claude.json` and overlays what it can recover for each slot, gathering from four candidates documented as being "in order of freshness" — live `nf-bin/` first, then two `nf-local-patches/` backups, then the legacy live path. The gather loop called `map.set(name, entry)` unconditionally, so the **last** candidate won: the exact inversion of the stated order. On the machine that surfaced it, a `nf-local-patches/nf/bin/providers.json` dated **2026-05-13** overrode the live entry for 7 slots on every single install, silently reverting per-slot tuning (`ttfb_timeout_ms`, `inter_chunk_ceiling_ms`, `idle_timeout_ms`, `model`, `env`). It was found because freshly-set `ttfb_timeout_ms` values kept vanishing after `install.js --claude --global`, which also explains earlier "mystery" resets of `stall_timeout_ms`. First writer now wins, so backups do what they exist for — recovering slots the live file has **lost** — and never override it. 4 tests in `bin/install-providers-precedence.test.cjs` execute the extracted gather loop against stubbed candidates; mutation-proven, and verified against a real install that now preserves every tuned field. diff --git a/bin/deep-probe-gate.test.cjs b/bin/deep-probe-gate.test.cjs new file mode 100644 index 0000000000..8a66987092 --- /dev/null +++ b/bin/deep-probe-gate.test.cjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +'use strict'; +// bin/deep-probe-gate.test.cjs — P1 of #293, extracted fresh. +// +// L1 (`--version`) proves a binary exists; L2 (`/models`) treats 401/403 as reachable. +// Neither can tell a quota-dead slot from a healthy one. Demonstrated live on +// 2026-08-08: preflight reported 7/7 available while claude-z-ai (429, weekly limit) +// and antigravity-1 (quota, 74h to reset) could not answer at all. +// +// The rule is deliberately LOPSIDED, because the opposite error is the one this codebase +// has already paid for three times (STALL-TIMEOUT-01..03): killing a slow-but-alive slot. +// downgrade ONLY on a fast, explicit auth/quota signal from the slot's own output. +// timeout / spawn error / unrecognised output = INCONCLUSIVE, never a downgrade. + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { deepProbeSlot, DOWNGRADE_STATUSES } = require('./quorum-preflight.cjs'); +const { classifyOutput } = require('./provider-status.cjs'); + +const TMP = []; +process.on('exit', () => { for (const d of TMP) { try { fs.rmSync(d, { recursive: true, force: true }); } catch (_) {} } }); + +function slotEmitting(body) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-dp-')); + TMP.push(dir); + const cli = path.join(dir, 'fake.cjs'); + fs.writeFileSync(cli, body, 'utf8'); + return { name: 't', type: 'subprocess', mainTool: 'node', cli: 'node', + args_template: [cli, '{prompt}'], deep_probe: { prompt: 'hi', timeout_ms: 30000 } }; +} + +test('DPG-1: an explicit quota signal downgrades', async () => { + const p = slotEmitting("process.stdout.write('API Error: Request rejected (429) Weekly Limit Exhausted\\n');process.exit(1);"); + const r = await deepProbeSlot(p, 10000); + assert.strictEqual(r.downgrade, true); + assert.strictEqual(r.status, 'QUOTA_EXCEEDED'); +}); + +test('DPG-2: a SLOW but alive slot is never downgraded — the whole point', async () => { + // The failure this must not reproduce. A 30s thinker probed with a 1s budget is + // inconclusive, not dead. + const p = slotEmitting("setTimeout(()=>process.stdout.write('PROBE_OK\\n'), 30000);"); + const r = await deepProbeSlot(p, 1200); + assert.strictEqual(r.downgrade, false, 'a timeout must never downgrade a slot'); + assert.strictEqual(r.status, 'INCONCLUSIVE'); + assert.match(r.reason, /slow is not dead/); +}); + +test('DPG-3: a spawn failure is inconclusive, not a downgrade', async () => { + const p = { name: 't', type: 'subprocess', mainTool: 'definitely-not-a-real-binary-xyz', + cli: '/nonexistent/xyz', args_template: ['{prompt}'], deep_probe: { prompt: 'hi' } }; + const r = await deepProbeSlot(p, 5000); + assert.strictEqual(r.downgrade, false, 'an unrunnable probe says nothing about slot health'); +}); + +test('DPG-4: a healthy slot stays available', async () => { + const r = await deepProbeSlot(slotEmitting("process.stdout.write('PROBE_OK\\n');"), 10000); + assert.strictEqual(r.downgrade, false); + assert.strictEqual(r.status, 'OK'); +}); + +test('DPG-5: a slot with no deep_probe is skipped, not guessed at', async () => { + const p = slotEmitting("process.stdout.write('PROBE_OK\\n');"); + delete p.deep_probe; + const r = await deepProbeSlot(p, 5000); + assert.strictEqual(r.probed, false); +}); + +test('DPG-6: the downgrade reason is the MATCHING line, not trailing noise', async () => { + // First live run blamed "SessionEnd hook failed" for what was a 429, because it + // quoted the last line of output. A wrong cause is worse than no cause. + const p = slotEmitting( + "process.stdout.write('API Error: Request rejected (429) Limit Exhausted\\n');" + + "process.stdout.write('SessionEnd hook failed: Hook cancelled\\n');process.exit(1);"); + const r = await deepProbeSlot(p, 10000); + assert.match(r.reason, /429/); + assert.doesNotMatch(r.reason, /SessionEnd/); +}); + +test('DPG-7: only auth/quota classes may downgrade', () => { + // A format error or an unknown response must not retire a slot. + assert.ok(DOWNGRADE_STATUSES.has('QUOTA_EXCEEDED')); + assert.ok(DOWNGRADE_STATUSES.has('AUTH_ERROR')); + assert.ok(!DOWNGRADE_STATUSES.has('FORMAT_ERROR'), 'a malformed request is not a dead slot'); + assert.ok(!DOWNGRADE_STATUSES.has('TIMEOUT')); +}); + +test('DPG-8: the classifier recognises the real messages that motivated this', () => { + // 'quota exceeded' alone missed antigravity's "Individual quota reached" — the slot + // was reported healthy with 74h of cooldown remaining. + assert.strictEqual(classifyOutput('Error: Individual quota reached. Resets in 74h38m39s.'), 'QUOTA_EXCEEDED'); + assert.strictEqual(classifyOutput('Weekly/Monthly Limit Exhausted'), 'QUOTA_EXCEEDED'); + assert.strictEqual(classifyOutput('Failed to refresh token: 401 Unauthorized'), 'AUTH_ERROR'); + // And must not fire on ordinary prose a model might emit. + assert.strictEqual(classifyOutput('I reached the conclusion that the limit is fine'), null); + assert.strictEqual(classifyOutput('PROBE_OK'), null); +}); diff --git a/bin/provider-status.cjs b/bin/provider-status.cjs index 64f556e3da..2f87a1060c 100644 --- a/bin/provider-status.cjs +++ b/bin/provider-status.cjs @@ -25,7 +25,10 @@ const { resolveArgsTemplate } = require('./provider-arg-templates.cjs'); // ─── Error pattern classification ──────────────────────────────────────────── const ERROR_PATTERNS = [ - { status: 'QUOTA_EXCEEDED', patterns: ['quota exceeded', 'rate_limit', 'credit limit exceeded', 'too many requests', 'resource_exhausted', '429'] }, + // 'quota reached' / 'limit exhausted' / 'limit reached' added 2026-08-08 from live + // output: antigravity says "Individual quota reached", which 'quota exceeded' misses + // entirely — the slot was reported healthy while it had 74h of cooldown left. + { status: 'QUOTA_EXCEEDED', patterns: ['quota exceeded', 'quota reached', 'limit exhausted', 'limit reached', 'rate_limit', 'credit limit exceeded', 'too many requests', 'resource_exhausted', '429'] }, { status: 'AUTH_ERROR', patterns: ['unauthorized', 'refresh_token_reused', 'token_expired', 'invalid api key', 'invalid_api_key', '401', '403', 'please try signing in again'] }, { status: 'FORMAT_ERROR', patterns: ['invalid_request_error', 'request validation error', '400'] }, { status: 'NO_CREDITS', patterns: ['credit limit', '402', 'payment required'] }, @@ -323,7 +326,16 @@ async function main() { } } -main().catch(err => { +// Guard main() so require()ing this module for its classifier does not run the whole +// probe sweep — the #198 class the no-side-effects-on-require gate exists for. +if (require.main === module) { + main().catch(err => { process.stderr.write(`[provider-status] ${err.message}\n`); process.exit(1); }); +} + +// Exported so bin/quorum-preflight.cjs can classify deep-probe output with the SAME +// pattern list. A second copy would drift, and these patterns decide whether a slot is +// declared dead — the one place a stale duplicate is least affordable. +module.exports = { ERROR_PATTERNS, classifyOutput }; diff --git a/bin/quorum-preflight.cjs b/bin/quorum-preflight.cjs index e509afe77f..e245f7d711 100644 --- a/bin/quorum-preflight.cjs +++ b/bin/quorum-preflight.cjs @@ -33,6 +33,11 @@ const https = require('https'); const http = require('http'); const { resolveSpawnTarget } = require('./resolve-cli.cjs'); const { loadProviders } = require('./resolve-providers.cjs'); +// Shared with bin/provider-status.cjs so the auth/quota pattern list has ONE definition — +// these patterns decide whether a slot is declared dead, which is the worst place for a +// stale duplicate. Requiring it is side-effect-free (its main() is require.main-guarded). +const { classifyOutput } = require('./provider-status.cjs'); +const { resolveArgsTemplate } = require('./provider-arg-templates.cjs'); // Probe is ON by default for --all; --no-probe to skip, --probe still accepted for compat const NO_PROBE = process.argv.includes('--no-probe'); @@ -519,6 +524,76 @@ function ensureServices(providers) { } } +// ─── P1: deep-probe gate ──────────────────────────────────────────────────── +// L1 (`--version`) proves a binary exists. L2 (`/models`) treats 401/403 as *reachable*. +// Neither can tell a quota-dead slot from a healthy one — demonstrated live on +// 2026-08-08, when preflight reported 7/7 available while `claude-z-ai` (429, weekly +// limit) and `antigravity-1` (quota, 74h to reset) could not answer at all. A panel +// that says "full quorum" and then produces two UNAVAILs mid-round is worse than one +// that admits it does not know. +// +// The dangerous direction is the SAME false-kill this codebase already paid for in +// STALL-TIMEOUT-01..03: declaring a slow-but-alive slot dead. So the rule is +// deliberately lopsided — +// +// downgrade ONLY on a fast, EXPLICIT auth/quota signal from the slot's own output. +// A timeout, a spawn error, an empty response, or anything unrecognised is +// INCONCLUSIVE and never downgrades. +// +// A deep probe can therefore only ever mark MORE slots unavailable, and only when the +// provider itself said why. +const DOWNGRADE_STATUSES = new Set(['QUOTA_EXCEEDED', 'AUTH_ERROR', 'NO_CREDITS']); + +async function deepProbeSlot(provider, timeoutMs) { + const probe = provider.deep_probe; + if (!probe) return { probed: false, reason: 'no deep_probe configured' }; + + const spawnTarget = resolveSpawnTarget(provider); + const argsTemplate = resolveArgsTemplate(provider); + if (!spawnTarget || !Array.isArray(argsTemplate)) { + return { probed: false, reason: 'no spawn target or args_template' }; + } + const prompt = probe.prompt || 'respond with: PROBE_OK'; + const args = argsTemplate.map(a => + (typeof a === 'string' && a.includes('{prompt}')) ? a.split('{prompt}').join(prompt) : a); + const budget = Math.max(1000, Math.min(timeoutMs, probe.timeout_ms || 45000)); + + const out = await new Promise((resolve) => { + let stdout = '', stderr = '', done = false; + let child; + try { + child = spawn(spawnTarget, args, { + env: { ...process.env, ...(provider.env || {}) }, stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { return resolve({ error: e.message }); } + const timer = setTimeout(() => { + done = true; + try { child.kill('SIGKILL'); } catch (_) {} + resolve({ timedOut: true, text: stdout + stderr }); + }, budget); + child.stdout.on('data', d => { stdout += d.toString().slice(0, 4096); }); + child.stderr.on('data', d => { stderr += d.toString().slice(0, 4096); }); + child.on('close', () => { if (!done) { clearTimeout(timer); resolve({ text: stdout + stderr }); } }); + child.on('error', (e) => { if (!done) { clearTimeout(timer); resolve({ error: e.message }); } }); + }); + + // Inconclusive: could not run it, or it never answered. Says nothing about health. + if (out.error) return { probed: true, downgrade: false, status: 'INCONCLUSIVE', reason: `spawn error: ${out.error}` }; + if (out.timedOut) return { probed: true, downgrade: false, status: 'INCONCLUSIVE', reason: `no answer in ${budget}ms — slow is not dead` }; + + const cls = classifyOutput(out.text || ''); + if (cls && DOWNGRADE_STATUSES.has(cls)) { + // Report the line that actually MATCHED, not the last line of output. CLIs emit + // trailing noise (a SessionEnd hook message, a shutdown notice), and quoting that + // as the downgrade reason misattributes the cause — the first live run blamed + // "SessionEnd hook failed" for what was a 429. + const lines = (out.text || '').split('\n').map(l => l.trim()).filter(Boolean); + const matched = lines.find(l => classifyOutput(l) === cls) || lines[lines.length - 1] || ''; + return { probed: true, downgrade: true, status: cls, reason: matched.slice(0, 200) }; + } + return { probed: true, downgrade: false, status: cls || 'OK', reason: cls ? `non-fatal: ${cls}` : 'answered' }; +} + // ─── P5: providers.json schema validation ─────────────────────────────────── // A malformed slot does not announce itself — it fails at dispatch, mid-quorum, as an // opaque UNAVAIL. This repo has shipped that failure repeatedly: a missing @@ -737,6 +812,46 @@ async function main() { output.available_slots = deduped; output.deduped_slots = dedupedOut; + // P1: run the deep probe only when the panel is DEGRADED and an explicit + // --budget-ms covers it. Both conditions matter: + // • degraded-only — a full panel has nothing to gain and every probe is a real + // model call with real latency and real cost. + // • explicit budget — a naive auto-enable on the budget-less `--all --probe` + // liveness path blew the <8s budget with spawnSync ETIMEDOUT (recorded in + // #293). The cheap path must stay cheap. + const preGate = computeQuorumGate({ + availableCount: output.available_slots.length, + maxQuorumSize: maxSize, + minLiveVoters: (cfg.quorum && cfg.quorum.min_live_voters) || 2, + forceQuorum: process.argv.includes('--force-quorum'), + }); + const wantDeep = process.argv.includes('--deep-probe') + || (preGate.degraded && BUDGET_MS !== null && BUDGET_MS >= 20000); + if (wantDeep) { + // Probes run CONCURRENTLY (Promise.all), so each slot gets the whole budget + // minus a little headroom — not budget/N. Dividing is sequential thinking, and + // it made every slot time out at 12.8s and report INCONCLUSIVE: safe, but + // useless. Reserve 5s so the job still finishes inside the caller's budget. + const perSlot = Math.max(3000, (BUDGET_MS || 45000) - 5000); + const candidates = output.available_slots + .map(name => activeProviders.find(p => p.name === name)) + .filter(p => p && p.deep_probe); + const results = await Promise.all(candidates.map(p => deepProbeSlot(p, perSlot))); + output.deep_probe = {}; + for (const [i, p] of candidates.entries()) { + const r = results[i]; + output.deep_probe[p.name] = r; + if (r.downgrade) { + process.stderr.write(`[quorum-preflight] deep probe DOWNGRADE ${p.name}: ${r.status} — ${r.reason}\n`); + output.available_slots = output.available_slots.filter(n => n !== p.name); + output.unavailable_slots.push({ name: p.name, reason: `deep probe: ${r.status} — ${r.reason}` }); + } else if (r.probed && r.status === 'INCONCLUSIVE') { + // Explicitly NOT a downgrade. Recorded so the reader knows it was tried. + process.stderr.write(`[quorum-preflight] deep probe inconclusive for ${p.name} (kept available): ${r.reason}\n`); + } + } + } + // P3: emit the gate as a decided field rather than leaving the orchestrator to // re-derive "available < max_quorum_size -> BLOCK unless --force-quorum" from // prose. `blocked: true` means no dispatch without a recorded waiver. @@ -775,4 +890,4 @@ if (require.main === module) { main().catch(e => { console.error(e); process.exit(1); }); } -module.exports = { dedupBySlotIdentity, probeHealth, findProviders, validateProviders, computeQuorumGate }; +module.exports = { dedupBySlotIdentity, probeHealth, findProviders, validateProviders, computeQuorumGate, deepProbeSlot, DOWNGRADE_STATUSES }; diff --git a/package.json b/package.json index c2925fe16a..1ee1c58600 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "secrets:history": "bash scripts/secret-audit.sh", "test": "npm run test:ci && npm run test:tui && npm run test:formal", "test:changed": "node bin/test-changed.cjs", - "test:ci": "node scripts/lint-isolation.js && node scripts/verify-hooks-sync.cjs && node --test hooks/nf-precompact.test.js hooks/nf-context-monitor.test.js hooks/nf-session-start.test.js bin/conformance-schema.test.cjs bin/resolve-cli.test.cjs bin/secrets.test.cjs bin/verify-quorum-health.test.cjs hooks/nf-stop.test.js hooks/config-loader.test.js hooks/config-loader-validate-adversarial.test.cjs hooks/config-loader-load-adversarial.test.cjs hooks/config-loader-adversarial2.test.cjs core/bin/nf-tools.test.cjs hooks/nf-circuit-breaker.test.js hooks/nf-prompt.test.js bin/update-scoreboard.test.cjs bin/quota-reset-cooldown.test.cjs bin/update-scoreboard-recording-adversarial.test.cjs bin/update-scoreboard-availability-adversarial.test.cjs bin/update-scoreboard-adversarial2.test.cjs hooks/nf-statusline.test.js bin/review-mcp-logs.test.cjs bin/migrate-to-slots.test.cjs bin/validate-traces.test.cjs bin/write-check-result.test.cjs bin/check-results-exit.test.cjs bin/check-trace-redaction.test.cjs bin/check-trace-schema-drift.test.cjs bin/nForma.test.cjs bin/set-secret.test.cjs bin/issue-classifier.test.cjs bin/generate-tla-cfg.test.cjs bin/ccr-secure-config.test.cjs bin/nf-quorum-slot-worker-improvements.test.cjs bin/quorum-improvements-signal.test.cjs bin/quorum-checkpoint.test.cjs bin/claude-md-references.test.cjs hooks/nf-spec-regen.test.js bin/propose-debug-invariants.test.cjs bin/proximity-embed-lazy-load.test.cjs bin/aggregate-requirements.test.cjs bin/validate-invariant.test.cjs bin/validate-memory.test.cjs bin/validate-requirements-haiku.test.cjs bin/call-quorum-slot-retry.test.cjs bin/provider-mapping.test.cjs bin/provider-concurrency.test.cjs bin/token-dashboard.test.cjs bin/batch7-guards.test.cjs bin/execution-progress.test.cjs bin/memory-store.test.cjs hooks/nf-session-end.test.js bin/skill-extractor.test.cjs bin/learning-extractor.test.cjs bin/continuous-verify.test.cjs bin/context-retriever.test.cjs bin/context-stack.test.cjs hooks/nf-destructive-git-guard.test.js bin/worktree-merge.test.cjs bin/call-quorum-slot-latency.test.cjs bin/nforma-cli.test.cjs bin/solve-trend.test.cjs bin/dedup-changelog.test.cjs bin/changelog-sections.test.cjs bin/oscillation-detector.test.cjs bin/gate-stability.test.cjs bin/predictive-power.test.cjs bin/convergence-report.test.cjs bin/escalation-classifier.test.cjs test/formal-scope-scan-semantic.test.cjs bin/repowise/escape-xml.test.cjs bin/repowise/pack-file.test.cjs bin/repowise/context-packer.test.cjs bin/repowise/hotspot.test.cjs bin/repowise/resolve-hotspot-risk.test.cjs bin/repowise/cochange.test.cjs bin/repowise/inject-cochange-debug.test.cjs bin/repowise/skeleton.test.cjs bin/repowise/budget-compressor.test.cjs test/providers-path-consolidation.test.cjs test/quorum-consensus-gate-cluster.test.cjs test/quorum-preflight-maintool-healthy.test.cjs test/quorum-preflight-probe.test.cjs test/quorum-preflight-dedup.test.cjs bin/preflight-gate-validator.test.cjs test/quorum-preflight-roster-adversarial.test.cjs test/quorum-preflight-roster-adversarial2.test.cjs test/quorum-preflight-probe-adversarial.test.cjs test/quorum-preflight-binary-cache.test.cjs test/resolve-providers-order.test.cjs test/proximity-resolve-guards.test.cjs test/observe-polyrepo-guards.test.cjs bin/no-side-effects-on-require.test.cjs bin/no-shipped-user-config.test.cjs bin/install-providers-precedence.test.cjs bin/alloy-exec.test.cjs test/install-mcp-nfbin.test.cjs bin/semaphore-concurrency.test.cjs bin/quorum-dispatch-argv.test.cjs bin/quorum-slot-dispatch.test.cjs bin/quorum-content-verdict-integrity.test.cjs bin/quorum-slot-authenticity.test.cjs bin/call-quorum-slot-cooldown-path.test.cjs hooks/nf-prompt-cache-budget.test.js bin/coderlm-adapter-filter.test.cjs bin/coderlm-skill-blocks.test.cjs bin/solve-commit-artifacts.test.cjs bin/solve-debt-bridge.test.cjs test/baseline-drift.test.cjs bin/cli-robustness.test.cjs bin/mcp-skill-embedded.test.cjs bin/skill-eval-args.test.cjs bin/observe-handler-upstream.test.cjs bin/skill-mcp-tool-names.test.cjs bin/skill-command-correctness.test.cjs bin/mcp-update-classify.test.cjs bin/formal-test-sync-safety.test.cjs bin/skill-papercut-sweep.test.cjs bin/roadmap-health-parser.test.cjs bin/mcp-setup-env-order.test.cjs bin/sync-baseline-dry-run.test.cjs bin/skill-cli-sweep.test.cjs bin/validate-requirements-staleness.test.cjs bin/audit-allowlist-precision.test.cjs bin/skill-eval-lint.test.cjs bin/machine-path-lint.test.cjs bin/release-guards.test.cjs bin/hooks-dist-completeness.test.cjs bin/quorum-resume.test.cjs bin/autonomous-grind.test.cjs bin/goal-writer-blocks.test.cjs bin/skill-mcp-lint.test.cjs bin/skill-invocation-lint.test.cjs bin/skill-state-guards.test.cjs bin/skill-state-parse-lint.test.cjs bin/map-codebase-secret-scan.test.cjs bin/verify-work-falsepass.test.cjs bin/solve-tui-itemkey.test.cjs bin/provider-arg-templates.test.cjs test/unified-mcp-server-slot-health.test.cjs bin/call-quorum-slot-stall-timeout.test.cjs bin/telemetry-window-source.test.cjs bin/mcp-repair-statusline-refresh.test.cjs bin/observe-config.test.cjs bin/observe-debt-writer.test.cjs bin/observe-handler-deps.test.cjs bin/observe-handler-grafana.test.cjs bin/observe-handler-logstash.test.cjs bin/observe-handler-prometheus.test.cjs bin/observe-handler-session-insights.test.cjs bin/observe-handlers.test.cjs bin/observe-registry.test.cjs bin/observe-render.test.cjs bin/observe-solve-pipe.test.cjs bin/observe-tooling.test.cjs bin/observed-fsm.test.cjs bin/observe-handler-internal.test.cjs bin/observe-pipeline.test.cjs bin/delegate-session.test.cjs bin/delegate-session-store.test.cjs bin/delegate-auditor.test.cjs bin/delegate-loop.test.cjs bin/coding-task-router.test.cjs", + "test:ci": "node scripts/lint-isolation.js && node scripts/verify-hooks-sync.cjs && node --test hooks/nf-precompact.test.js hooks/nf-context-monitor.test.js hooks/nf-session-start.test.js bin/conformance-schema.test.cjs bin/resolve-cli.test.cjs bin/secrets.test.cjs bin/verify-quorum-health.test.cjs hooks/nf-stop.test.js hooks/config-loader.test.js hooks/config-loader-validate-adversarial.test.cjs hooks/config-loader-load-adversarial.test.cjs hooks/config-loader-adversarial2.test.cjs core/bin/nf-tools.test.cjs hooks/nf-circuit-breaker.test.js hooks/nf-prompt.test.js bin/update-scoreboard.test.cjs bin/quota-reset-cooldown.test.cjs bin/update-scoreboard-recording-adversarial.test.cjs bin/update-scoreboard-availability-adversarial.test.cjs bin/update-scoreboard-adversarial2.test.cjs hooks/nf-statusline.test.js bin/review-mcp-logs.test.cjs bin/migrate-to-slots.test.cjs bin/validate-traces.test.cjs bin/write-check-result.test.cjs bin/check-results-exit.test.cjs bin/check-trace-redaction.test.cjs bin/check-trace-schema-drift.test.cjs bin/nForma.test.cjs bin/set-secret.test.cjs bin/issue-classifier.test.cjs bin/generate-tla-cfg.test.cjs bin/ccr-secure-config.test.cjs bin/nf-quorum-slot-worker-improvements.test.cjs bin/quorum-improvements-signal.test.cjs bin/quorum-checkpoint.test.cjs bin/claude-md-references.test.cjs hooks/nf-spec-regen.test.js bin/propose-debug-invariants.test.cjs bin/proximity-embed-lazy-load.test.cjs bin/aggregate-requirements.test.cjs bin/validate-invariant.test.cjs bin/validate-memory.test.cjs bin/validate-requirements-haiku.test.cjs bin/call-quorum-slot-retry.test.cjs bin/provider-mapping.test.cjs bin/provider-concurrency.test.cjs bin/token-dashboard.test.cjs bin/batch7-guards.test.cjs bin/execution-progress.test.cjs bin/memory-store.test.cjs hooks/nf-session-end.test.js bin/skill-extractor.test.cjs bin/learning-extractor.test.cjs bin/continuous-verify.test.cjs bin/context-retriever.test.cjs bin/context-stack.test.cjs hooks/nf-destructive-git-guard.test.js bin/worktree-merge.test.cjs bin/call-quorum-slot-latency.test.cjs bin/nforma-cli.test.cjs bin/solve-trend.test.cjs bin/dedup-changelog.test.cjs bin/changelog-sections.test.cjs bin/oscillation-detector.test.cjs bin/gate-stability.test.cjs bin/predictive-power.test.cjs bin/convergence-report.test.cjs bin/escalation-classifier.test.cjs test/formal-scope-scan-semantic.test.cjs bin/repowise/escape-xml.test.cjs bin/repowise/pack-file.test.cjs bin/repowise/context-packer.test.cjs bin/repowise/hotspot.test.cjs bin/repowise/resolve-hotspot-risk.test.cjs bin/repowise/cochange.test.cjs bin/repowise/inject-cochange-debug.test.cjs bin/repowise/skeleton.test.cjs bin/repowise/budget-compressor.test.cjs test/providers-path-consolidation.test.cjs test/quorum-consensus-gate-cluster.test.cjs test/quorum-preflight-maintool-healthy.test.cjs test/quorum-preflight-probe.test.cjs test/quorum-preflight-dedup.test.cjs bin/preflight-gate-validator.test.cjs bin/deep-probe-gate.test.cjs test/quorum-preflight-roster-adversarial.test.cjs test/quorum-preflight-roster-adversarial2.test.cjs test/quorum-preflight-probe-adversarial.test.cjs test/quorum-preflight-binary-cache.test.cjs test/resolve-providers-order.test.cjs test/proximity-resolve-guards.test.cjs test/observe-polyrepo-guards.test.cjs bin/no-side-effects-on-require.test.cjs bin/no-shipped-user-config.test.cjs bin/install-providers-precedence.test.cjs bin/alloy-exec.test.cjs test/install-mcp-nfbin.test.cjs bin/semaphore-concurrency.test.cjs bin/quorum-dispatch-argv.test.cjs bin/quorum-slot-dispatch.test.cjs bin/quorum-content-verdict-integrity.test.cjs bin/quorum-slot-authenticity.test.cjs bin/call-quorum-slot-cooldown-path.test.cjs hooks/nf-prompt-cache-budget.test.js bin/coderlm-adapter-filter.test.cjs bin/coderlm-skill-blocks.test.cjs bin/solve-commit-artifacts.test.cjs bin/solve-debt-bridge.test.cjs test/baseline-drift.test.cjs bin/cli-robustness.test.cjs bin/mcp-skill-embedded.test.cjs bin/skill-eval-args.test.cjs bin/observe-handler-upstream.test.cjs bin/skill-mcp-tool-names.test.cjs bin/skill-command-correctness.test.cjs bin/mcp-update-classify.test.cjs bin/formal-test-sync-safety.test.cjs bin/skill-papercut-sweep.test.cjs bin/roadmap-health-parser.test.cjs bin/mcp-setup-env-order.test.cjs bin/sync-baseline-dry-run.test.cjs bin/skill-cli-sweep.test.cjs bin/validate-requirements-staleness.test.cjs bin/audit-allowlist-precision.test.cjs bin/skill-eval-lint.test.cjs bin/machine-path-lint.test.cjs bin/release-guards.test.cjs bin/hooks-dist-completeness.test.cjs bin/quorum-resume.test.cjs bin/autonomous-grind.test.cjs bin/goal-writer-blocks.test.cjs bin/skill-mcp-lint.test.cjs bin/skill-invocation-lint.test.cjs bin/skill-state-guards.test.cjs bin/skill-state-parse-lint.test.cjs bin/map-codebase-secret-scan.test.cjs bin/verify-work-falsepass.test.cjs bin/solve-tui-itemkey.test.cjs bin/provider-arg-templates.test.cjs test/unified-mcp-server-slot-health.test.cjs bin/call-quorum-slot-stall-timeout.test.cjs bin/telemetry-window-source.test.cjs bin/mcp-repair-statusline-refresh.test.cjs bin/observe-config.test.cjs bin/observe-debt-writer.test.cjs bin/observe-handler-deps.test.cjs bin/observe-handler-grafana.test.cjs bin/observe-handler-logstash.test.cjs bin/observe-handler-prometheus.test.cjs bin/observe-handler-session-insights.test.cjs bin/observe-handlers.test.cjs bin/observe-registry.test.cjs bin/observe-render.test.cjs bin/observe-solve-pipe.test.cjs bin/observe-tooling.test.cjs bin/observed-fsm.test.cjs bin/observe-handler-internal.test.cjs bin/observe-pipeline.test.cjs bin/delegate-session.test.cjs bin/delegate-session-store.test.cjs bin/delegate-auditor.test.cjs bin/delegate-loop.test.cjs bin/coding-task-router.test.cjs", "embed:build": "node bin/proximity-embed.mjs --cache-only", "test:tui": "NF_TEST_MODE=1 node scripts/run-tui-tests.cjs", "test:install": "node --test test/install-virgin.test.cjs",