diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6bc6ce65..9d6772d130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,12 +30,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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): **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. ### Changed - fix(quorum): **the stall timer no longer uses byte count as evidence of liveness (STALL-TIMEOUT-03).** `<500 bytes → apply a tighter kill window` was a proxy for "this slot is hung", and a live multi-model quorum falsified it the hard way — by killing three of its own five voters mid-answer. `claude-1` (idle 90s → 90s window), `copilot-1` (idle 30s → 30s), and `claude-z-ai` (90s) were all producing correct output when the timer fired; their partial answers were recoverable from `unavail_message`. `codex-1` and `antigravity-1` survived not because they were faster but because they **stream**, so every chunk rearmed the timer. The discriminator was never byte count. Worse, deriving the window from `idle_timeout_ms` (the #385 fix) made that field do double duty as "how long may this model think before output" — and for most slots the configured value (30s/90s) is far *smaller* than real think time, while the caller had budgeted 300s. There are now two windows, one per failure mode: **before the first byte**, `ttfb_timeout_ms` (default 30s) — zero output is the only honest evidence of a hang, so fast-fail belongs there; **after the first byte**, the inter-chunk ceiling — `inter_chunk_ceiling_ms` if the slot declares one, else the caller's idle budget. `stall_timeout_ms` is kept as a legacy alias for the ceiling, since the values already in that field were measured against post-preamble silence, so no config surface is added. Live-verified: `claude-1` answers in 134s where it previously died at 90s. Measuring also corrected an assumption — the `claude` CLI **buffers** (zero bytes until well past 30s) rather than emitting a preamble like the z-ai/minimax/kimi routes, which is why TTFB is per-slot configurable and why the claude-family slots carry `ttfb_timeout_ms: 150000`. Ratified by quorum (3/3 unanimous, Round 1: codex-1, antigravity-1, claude-minimax) over the alternative of keeping #386 as shipped; debate at `.planning/quorum/debates/2026-08-07-stall-timer-design-decision-a-vs-b.md`. - ## [0.44.3] - 2026-08-07 — Quorum stall-window derivation, no shipped user config, and migration/portability guards ### Fixed diff --git a/bin/quota-reset-cooldown.test.cjs b/bin/quota-reset-cooldown.test.cjs new file mode 100644 index 0000000000..afa5ef7842 --- /dev/null +++ b/bin/quota-reset-cooldown.test.cjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +'use strict'; +// bin/quota-reset-cooldown.test.cjs — QUOTA-RESET-01 +// +// A quota-dead slot states when it comes back, but in shapes parseAvailabilityHint did +// not match, so it fell through to the 5-minute default and was re-dispatched every +// 5 minutes for DAYS — burning a slot in every quorum round and dragging the live-voter +// count toward the min_live_voters floor. Both strings below are verbatim from live +// slots on 2026-08-07: +// antigravity-1 "Resets in 95h26m37s." -> was 5 min, should be ~95h +// claude-z-ai "...will reset at 2026-08-09 10:01:44" -> was 5 min, should be ~41h + +const { test } = require('node:test'); +const assert = require('node:assert'); +const { parseAvailabilityHint } = require('./update-scoreboard.cjs'); + +const minutesOut = (msg) => { + const r = parseAvailabilityHint(msg); + return r ? Math.round((r.available_at.getTime() - Date.now()) / 60000) : null; +}; + +test('QRC-1: a compact duration is honoured, not defaulted to 5 minutes', () => { + const msg = 'Error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 95h26m37s.'; + const mins = minutesOut(msg); + assert.ok(mins > 5700 && mins < 5740, `expected ~5726 min, got ${mins}`); + assert.strictEqual(parseAvailabilityHint(msg).reason, 'quota exceeded'); +}); + +test('QRC-2: an absolute reset timestamp is honoured', () => { + // Far-future date so the assertion doesn't rot; the shape is what matters. + const msg = 'API Error: Request rejected (429) · [1310][Weekly/Monthly Limit Exhausted. Your limit will reset at 2099-01-01 10:01:44]'; + const r = parseAvailabilityHint(msg); + assert.ok(r && r.available_at.getUTCFullYear() === 2099, 'absolute reset time must be parsed'); + assert.strictEqual(r.reason, 'quota exceeded'); +}); + +test('QRC-3: partial compact forms parse (h+m, m+s, bare s)', () => { + assert.ok(Math.abs(minutesOut('quota exhausted, resets in 32h54m') - 1974) <= 1); + assert.ok(Math.abs(minutesOut('retry in 5m30s') - 5) <= 1); + assert.ok(minutesOut('available in 90s') === 1 || minutesOut('available in 90s') === 2); +}); + +test('QRC-4: a transient failure still gets the SHORT default, not a multi-day hold', () => { + // The dangerous direction: over-matching would retire a healthy slot for days on a + // one-off stall. These must stay at the 5-minute default. + assert.strictEqual(minutesOut('STALL: no output at all for 30000ms'), 5); + assert.strictEqual(minutesOut('IDLE_TIMEOUT after 300000ms of inactivity'), 5); + assert.strictEqual(minutesOut('TIMEOUT after 0ms'), 5); +}); + +test('QRC-5: an already-past absolute reset does not produce a negative cooldown', () => { + // A stale message must not resolve to "available in the past" and it must not be + // mistaken for a fresh multi-day hold either. + const r = parseAvailabilityHint('Limit exhausted. Your limit will reset at 2020-01-01 10:00:00'); + assert.ok(!r || r.available_at.getTime() > Date.now(), 'a past reset time must not be recorded as a cooldown'); +}); + +test('QRC-6: the empty-match trap stays closed', () => { + // The first implementation used one pattern of all-optional unit groups, which matches + // the empty string: every capture undefined, silent fall-through to the 5-min default. + // A message with a reset word but NO duration must reach the default deliberately. + assert.strictEqual(minutesOut('quota reached, resets soon'), 5); + assert.strictEqual(parseAvailabilityHint('resets eventually'), null, + 'no failure pattern and no duration means no cooldown at all'); +}); diff --git a/bin/update-scoreboard.cjs b/bin/update-scoreboard.cjs index 7721e31bd2..453b3e4b7b 100644 --- a/bin/update-scoreboard.cjs +++ b/bin/update-scoreboard.cjs @@ -728,6 +728,44 @@ function parseAvailabilityHint(message) { return { available_at: new Date(Date.now() + mins * 60_000), reason: 'unavailable' }; } + // QUOTA-RESET-01: providers state their reset window in shapes the patterns above + // do not match, so a slot that is dead for DAYS fell through to the 5-minute default + // and was re-dispatched every 5 minutes until the quota actually returned. Both of + // these are verbatim from live slots: + // antigravity "Resets in 95h26m37s." + // z-ai "Your limit will reset at 2026-08-09 10:01:44" + // A compact duration first — "95h26m37s", "32h54m", "90s". Written as a two-step + // (locate the duration TOKEN, then decompose it) because a single pattern of all- + // optional unit groups matches the empty string: it "succeeds" with every capture + // undefined and silently falls through to the 5-minute default, which is exactly + // the failure it was meant to fix. + const durTok = message.match(/(?:reset|resets|available|retry)\w*[^0-9]{0,20}(\d+\s*h(?:\s*\d+\s*m)?(?:\s*\d+\s*s)?|\d+\s*m(?:\s*\d+\s*s)?|\d+\s*s)/i); + if (durTok) { + const tok = durTok[1]; + const h = /(\d+)\s*h/i.exec(tok); + const m = /(\d+)\s*m/i.exec(tok); + const sec = /(\d+)\s*s/i.exec(tok); + const ms = (h ? parseInt(h[1], 10) * 3_600_000 : 0) + + (m ? parseInt(m[1], 10) * 60_000 : 0) + + (sec ? parseInt(sec[1], 10) * 1_000 : 0); + if (ms > 0) { + const reason = /quota/i.test(message) ? 'quota exceeded' + : /rate.?limit|429/i.test(message) ? 'rate limit' : 'unavailable'; + return { available_at: new Date(Date.now() + ms), reason }; + } + } + + // Then an absolute ISO-ish timestamp: "reset at 2026-08-09 10:01:44" (or "resets at"). + const isoMatch = message.match(/reset(?:s)?\s+(?:at|on)\s+(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?)/i); + if (isoMatch) { + const when = new Date(isoMatch[1].replace(' ', 'T')); + if (!isNaN(when.getTime()) && when.getTime() > Date.now()) { + const reason = /limit.?exhausted|quota/i.test(message) ? 'quota exceeded' + : /rate.?limit|429/i.test(message) ? 'rate limit' : 'unavailable'; + return { available_at: when, reason }; + } + } + // Default cooldown for recognized failure patterns without explicit time hints. // These error types indicate the slot should be skipped for a few minutes. const DEFAULT_COOLDOWN_MS = 5 * 60_000; // 5 minutes @@ -1207,7 +1245,7 @@ async function main() { // Guard pattern: only export when require()d by tests, not when run as a CLI script if (typeof module !== 'undefined') { - module.exports = { computeDeliveryStats, computeFlakiness, emptyData, loadScoreDeltas, DEFAULT_SCORE_DELTAS }; + module.exports = { computeDeliveryStats, computeFlakiness, emptyData, loadScoreDeltas, DEFAULT_SCORE_DELTAS, parseAvailabilityHint }; } // Only run main() when invoked as a script, not when require()d by tests diff --git a/package.json b/package.json index add8742725..a68e760f31 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/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 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 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",