failproofai 0.0.11-beta.5 → 0.0.11-beta.7

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 (118) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  34. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/index.html +1 -1
  37. package/.next/standalone/.next/server/app/index.rsc +14 -14
  38. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  40. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  41. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  42. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  47. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/chunks/[root-of-the-server]__13ra2jq._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_mqemn._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1b9z5-i._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/_1-1804f._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  65. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00jkjmt._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013du6r._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0n5_-1w._.js → [root-of-the-server]__08gyb62._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1u8h03r._.js → [root-of-the-server]__08v7sn2._.js} +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e85wxv._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gfxvb1._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1cd25c7._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hlrq6y._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ihxdo5._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mt35_w._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pcxxwg._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1vvfde2._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/_1kje4fm._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html2canvas_dist_html2canvas_esm_20uq69-.js → node_modules_html2canvas_dist_html2canvas_esm_0q4m0_h.js} +1 -1
  84. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  85. package/.next/standalone/.next/server/pages/404.html +1 -1
  86. package/.next/standalone/.next/server/pages/500.html +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  89. package/.next/standalone/.next/static/chunks/{39a26r6z12f79.js → 0avgb78edu0i-.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{1ttc2tjh-zlpe.js → 0fpw1jzeg66_x.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{0v_5uc6i2eumo.js → 0hmxjseydp6as.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{1_5m8kaezrw_o.js → 0xkxbaa-oo180.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{11vc5dmoyrakc.js → 1eq7f419t6ln3.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0_i1vnayvdf0q.js → 2452blhu4bcdr.js} +3 -3
  95. package/.next/standalone/.next/static/chunks/{2ifhvb-ysi1vv.js → 3n28elq6kwpy3.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{0lhdvxusp8db3.js → 3osmt-t2hsrng.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{1btx2c49fk9xt.css → 3wycox197ouus.css} +1 -1
  98. package/.next/standalone/.next/static/chunks/{306bhgs07dody.js → 41vwoxpkauj1w.js} +1 -1
  99. package/.next/standalone/app/actions/get-audit-result.ts +14 -3
  100. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +85 -20
  101. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +98 -0
  102. package/.next/standalone/app/audit/_components/rerun-button.tsx +11 -2
  103. package/.next/standalone/app/audit/_components/return-section.tsx +17 -29
  104. package/.next/standalone/app/audit/audit-styles.css +120 -0
  105. package/.next/standalone/app/audit/page.tsx +19 -9
  106. package/.next/standalone/lib/fetch-with-timeout.ts +37 -20
  107. package/.next/standalone/package.json +1 -1
  108. package/.next/standalone/server.js +1 -1
  109. package/dist/cli.mjs +6 -3
  110. package/lib/fetch-with-timeout.ts +37 -20
  111. package/package.json +1 -1
  112. package/src/audit/archetypes.ts +19 -2
  113. package/src/audit/cache.ts +20 -1
  114. package/src/audit/dashboard-cache.ts +40 -0
  115. package/src/audit/features.ts +10 -2
  116. /package/.next/standalone/.next/static/{TNfsNLuO5dTuOXES0XK8N → 90NFQus9VOnEzD6sP0EyP}/_buildManifest.js +0 -0
  117. /package/.next/standalone/.next/static/{TNfsNLuO5dTuOXES0XK8N → 90NFQus9VOnEzD6sP0EyP}/_clientMiddlewareManifest.js +0 -0
  118. /package/.next/standalone/.next/static/{TNfsNLuO5dTuOXES0XK8N → 90NFQus9VOnEzD6sP0EyP}/_ssgManifest.js +0 -0
@@ -2064,3 +2064,123 @@
2064
2064
  .share-dock-fab { transition: none; }
2065
2065
  .share-dock-caret { transition: none; }
2066
2066
  }
2067
+
2068
+ /* ─────────────────────────────────────────────────────────────────────
2069
+ audit-progress-strip — sticky banner pinned to the top of the viewport
2070
+ while a re-audit is in flight. Pink hard-offset shadow matches the
2071
+ page's `.share-btn:hover` vocabulary. Right-edge pulse is a CSS-only
2072
+ keyframe (transform-only — no layout thrash) and is not a fake
2073
+ progress bar.
2074
+ ───────────────────────────────────────────────────────────────────── */
2075
+ .audit-progress-strip {
2076
+ position: sticky;
2077
+ top: 0;
2078
+ left: 0;
2079
+ right: 0;
2080
+ z-index: 60;
2081
+ background: var(--bg-2);
2082
+ border-bottom: 1px solid var(--accent-pink);
2083
+ box-shadow: 0 4px 0 0 var(--accent-pink-shadow);
2084
+ font-family: var(--font-mono);
2085
+ /* Slide in from above on mount */
2086
+ animation: audit-progress-slide-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
2087
+ }
2088
+ .audit-progress-strip--failed {
2089
+ border-bottom-color: var(--accent-pink);
2090
+ background: linear-gradient(to right, var(--accent-pink-bg), var(--bg-2));
2091
+ }
2092
+ .audit-progress-strip__inner {
2093
+ position: relative;
2094
+ max-width: 1480px;
2095
+ margin: 0 auto;
2096
+ padding: 12px clamp(20px, 3vw, 48px);
2097
+ display: flex;
2098
+ align-items: center;
2099
+ justify-content: space-between;
2100
+ gap: 16px;
2101
+ }
2102
+ .audit-progress-strip__label {
2103
+ display: inline-flex;
2104
+ align-items: center;
2105
+ gap: 10px;
2106
+ color: var(--ink);
2107
+ font-size: 12px;
2108
+ letter-spacing: 0.06em;
2109
+ text-transform: lowercase;
2110
+ }
2111
+ .audit-progress-strip--failed .audit-progress-strip__label { color: var(--accent-pink); }
2112
+ .audit-progress-strip__elapsed {
2113
+ font-variant-numeric: tabular-nums;
2114
+ font-size: 12px;
2115
+ color: var(--accent-pink);
2116
+ letter-spacing: 0.08em;
2117
+ }
2118
+ .audit-progress-strip__spinner {
2119
+ width: 9px;
2120
+ height: 9px;
2121
+ border-radius: 50%;
2122
+ background: transparent;
2123
+ border: 1.5px solid var(--accent-pink);
2124
+ border-top-color: transparent;
2125
+ animation: audit-progress-spin 800ms linear infinite;
2126
+ }
2127
+ .audit-progress-strip__x {
2128
+ display: inline-flex;
2129
+ align-items: center;
2130
+ justify-content: center;
2131
+ width: 14px;
2132
+ height: 14px;
2133
+ border: 1px solid var(--accent-pink);
2134
+ color: var(--accent-pink);
2135
+ font-size: 11px;
2136
+ line-height: 1;
2137
+ }
2138
+ .audit-progress-strip__dismiss {
2139
+ background: transparent;
2140
+ border: 1px solid var(--line-2);
2141
+ color: var(--ink-2);
2142
+ padding: 5px 10px;
2143
+ font-family: var(--font-mono);
2144
+ font-size: 11px;
2145
+ text-transform: lowercase;
2146
+ letter-spacing: 0.06em;
2147
+ cursor: pointer;
2148
+ transition: border-color 140ms ease, color 140ms ease;
2149
+ }
2150
+ .audit-progress-strip__dismiss:hover {
2151
+ border-color: var(--accent-pink);
2152
+ color: var(--accent-pink);
2153
+ }
2154
+ .audit-progress-strip__pulse {
2155
+ position: absolute;
2156
+ bottom: -1px;
2157
+ left: 0;
2158
+ right: 0;
2159
+ height: 1px;
2160
+ background: linear-gradient(
2161
+ 90deg,
2162
+ transparent 0%,
2163
+ var(--accent-pink) 20%,
2164
+ var(--accent-pink) 80%,
2165
+ transparent 100%
2166
+ );
2167
+ transform-origin: left center;
2168
+ animation: audit-progress-pulse 2400ms cubic-bezier(0.55, 0.1, 0.45, 0.9) infinite;
2169
+ }
2170
+ @keyframes audit-progress-spin {
2171
+ to { transform: rotate(360deg); }
2172
+ }
2173
+ @keyframes audit-progress-pulse {
2174
+ 0% { transform: scaleX(0) translateX(0%); opacity: 0.4; }
2175
+ 50% { transform: scaleX(0.6) translateX(20%); opacity: 1; }
2176
+ 100% { transform: scaleX(0) translateX(100%); opacity: 0.2; }
2177
+ }
2178
+ @keyframes audit-progress-slide-in {
2179
+ from { transform: translateY(-100%); opacity: 0; }
2180
+ to { transform: translateY(0); opacity: 1; }
2181
+ }
2182
+ @media (prefers-reduced-motion: reduce) {
2183
+ .audit-progress-strip { animation: none; }
2184
+ .audit-progress-strip__spinner { animation: none; border-top-color: var(--accent-pink); }
2185
+ .audit-progress-strip__pulse { animation: none; opacity: 0.4; transform: scaleX(0.4) translateX(30%); }
2186
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Suspense } from "react";
10
10
  import { notFound } from "next/navigation";
11
- import { readDashboardCache } from "@/src/audit/dashboard-cache";
11
+ import { readDashboardCache, readDashboardCacheMeta } from "@/src/audit/dashboard-cache";
12
12
  import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies";
13
13
  import { AUDIT_DETECTORS } from "@/src/audit/detectors";
14
14
  import { AuditDashboard } from "./_components/audit-dashboard";
@@ -32,14 +32,24 @@ export default async function AuditPage({ searchParams }: PageProps) {
32
32
  const { p } = await searchParams;
33
33
 
34
34
  const cache = readDashboardCache();
35
- const initial = cache
36
- ? {
37
- status: "cached" as const,
38
- cachedAt: cache.cachedAt,
39
- params: cache.params,
40
- result: cache.result,
41
- }
42
- : { status: "empty" as const };
35
+ let initial;
36
+ if (cache) {
37
+ initial = {
38
+ status: "cached" as const,
39
+ cachedAt: cache.cachedAt,
40
+ params: cache.params,
41
+ result: cache.result,
42
+ };
43
+ } else {
44
+ // No fresh cache. Probe meta so we can distinguish "first run" from
45
+ // "your last audit expired" and surface the right top-bar nudge.
46
+ const meta = readDashboardCacheMeta();
47
+ initial = {
48
+ status: "empty" as const,
49
+ expired: meta !== null,
50
+ expiredAt: meta?.cachedAt ?? null,
51
+ };
52
+ }
43
53
 
44
54
  return (
45
55
  <Suspense>
@@ -1,14 +1,28 @@
1
1
  /**
2
- * Shared fetch wrapper with per-request timeout via AbortController.
2
+ * Shared fetch wrapper with per-request timeout and external-signal
3
+ * composition.
3
4
  *
4
5
  * Replaces three byte-equivalent implementations that had drifted on the
5
6
  * default timeout (15s in two client components, 10s in the server-side
6
7
  * api-server-client). Co-locates the `isAbortError` predicate so callers
7
8
  * can classify timeout vs network failures consistently.
8
9
  *
9
- * Server-side uses `AbortSignal.timeout` where available (cheaper); this
10
- * helper uses the AbortController+setTimeout form because the client
11
- * code-paths need a guaranteed cleanup hook.
10
+ * Built on the platform primitives `AbortSignal.timeout()` which
11
+ * aborts with a typed `DOMException(..., "TimeoutError")` rather than
12
+ * the default bare abort that surfaces as Next.js 16's dev-overlay
13
+ * "signal is aborted without reason" warning — and `AbortSignal.any()`,
14
+ * which composes the timeout signal with any caller-supplied
15
+ * `init.signal` so a component-unmount or parent-controller abort
16
+ * actually stops the in-flight fetch. The prior `AbortController` +
17
+ * `setTimeout` implementation called `controller.abort()` with no
18
+ * reason AND silently dropped `init.signal` by spreading then
19
+ * overwriting it with the controller's own signal.
20
+ *
21
+ * Both APIs are available in every runtime Next.js 16 supports:
22
+ * `AbortSignal.timeout` since Node 17.3 / 16.14, `AbortSignal.any`
23
+ * since Node 20.3 (also backported to 18.17). Next.js 16's minimum
24
+ * runtime (Node >= 20.9, per its engines field) covers both. Browser
25
+ * support: Chrome 116+, Firefox 124+, Safari 17.4+.
12
26
  */
13
27
 
14
28
  /** Hard cap on every fetch using this helper unless overridden. Picked to
@@ -16,27 +30,30 @@
16
30
  * so a slow but successful upstream response still lands. */
17
31
  export const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
18
32
 
19
- export async function fetchWithTimeout(
33
+ export function fetchWithTimeout(
20
34
  input: RequestInfo | URL,
21
35
  init: RequestInit = {},
22
36
  timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
23
37
  ): Promise<Response> {
24
- const controller = new AbortController();
25
- const id = setTimeout(() => controller.abort(), timeoutMs);
26
- try {
27
- return await fetch(input, { ...init, signal: controller.signal });
28
- } finally {
29
- clearTimeout(id);
30
- }
38
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
39
+ const signal = init.signal
40
+ ? AbortSignal.any([init.signal, timeoutSignal])
41
+ : timeoutSignal;
42
+ return fetch(input, { ...init, signal });
31
43
  }
32
44
 
33
- /** True when an error came from an AbortController.abort() OR a
34
- * `AbortSignal.timeout` firing. Both surface as `Error` with `name`
35
- * set to "AbortError" or "TimeoutError" respectively. Keeping the two
36
- * in one predicate prevents the "I forgot TimeoutError" drift class. */
45
+ /** True when an error came from an `AbortController.abort()` OR an
46
+ * `AbortSignal.timeout()` firing. Both surface as objects whose `name`
47
+ * is "AbortError" or "TimeoutError" respectively depending on the
48
+ * runtime, they may be `Error` subclasses, `DOMException` instances,
49
+ * both, or neither (the jsdom polyfill, for one, breaks the Error
50
+ * prototype chain), so we duck-type on `name` rather than the
51
+ * provenance. Callers always pass values caught from a try/catch, so
52
+ * the universe of inputs is narrow enough that name-matching is
53
+ * unambiguous. Keeping the two names in one predicate prevents the
54
+ * "I forgot TimeoutError" drift class. */
37
55
  export function isAbortError(err: unknown): boolean {
38
- return (
39
- err instanceof Error
40
- && (err.name === "AbortError" || err.name === "TimeoutError")
41
- );
56
+ if (err === null || typeof err !== "object") return false;
57
+ const name = (err as { name?: unknown }).name;
58
+ return name === "AbortError" || name === "TimeoutError";
42
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.11-beta.5",
3
+ "version": "0.0.11-beta.7",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -9,7 +9,7 @@ const currentPort = parseInt(process.env.PORT, 10) || 3000
9
9
  const hostname = process.env.HOSTNAME || '0.0.0.0'
10
10
 
11
11
  let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
12
- const nextConfig = {"env":{"NEXT_PUBLIC_APP_VERSION":"0.0.11-beta.5"},"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":14400,"formats":["image/webp"],"maximumRedirects":3,"maximumResponseBody":50000000,"dangerouslyAllowLocalIP":false,"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","localPatterns":[{"pathname":"**","search":""}],"remotePatterns":[],"qualities":[75],"unoptimized":true,"customCacheHandler":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{"serverFunctions":true,"browserToTerminal":"warn"},"compiler":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/home/runner/work/failproofai/failproofai","cacheComponents":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":30,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":31536000}},"cacheHandlers":{},"experimental":{"appNewScrollHandler":false,"useSkewCookie":false,"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"cachedNavigations":false,"partialFallbacks":false,"dynamicOnHover":false,"varyParams":false,"prefetchInlining":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","proxyPrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":3,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"imgOptSkipMetadata":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"strictRouteTypes":false,"viewTransition":false,"removeUncaughtErrorAndRejectionListeners":false,"validateRSCRequestHeaders":false,"staleTimes":{"dynamic":0,"static":300},"reactDebugChannel":true,"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"transitionIndicator":false,"gestureTransition":false,"inlineCss":false,"useCache":false,"globalNotFound":false,"browserDebugInfoInTerminal":"warn","lockDistDir":true,"proxyClientMaxBodySize":10485760,"hideLogsAfterAbort":false,"mcpServer":true,"turbopackFileSystemCacheForDev":true,"turbopackFileSystemCacheForBuild":false,"turbopackInferModuleSideEffects":true,"turbopackPluginRuntimeStrategy":"childProcesses","optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-sqlite-node","@effect/sql-sqlite-bun","@effect/sql-sqlite-wasm","@effect/sql-sqlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight","bundlePagesRouterDependencies":false,"configFileName":"next.config.ts","outputFileTracingExcludes":{"*":["node_modules/@img/**","node_modules/sharp/**"]},"turbopack":{"root":"/home/runner/work/failproofai/failproofai"},"distDirRoot":".next"}
12
+ const nextConfig = {"env":{"NEXT_PUBLIC_APP_VERSION":"0.0.11-beta.7"},"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":14400,"formats":["image/webp"],"maximumRedirects":3,"maximumResponseBody":50000000,"dangerouslyAllowLocalIP":false,"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","localPatterns":[{"pathname":"**","search":""}],"remotePatterns":[],"qualities":[75],"unoptimized":true,"customCacheHandler":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{"serverFunctions":true,"browserToTerminal":"warn"},"compiler":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/home/runner/work/failproofai/failproofai","cacheComponents":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":30,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":31536000}},"cacheHandlers":{},"experimental":{"appNewScrollHandler":false,"useSkewCookie":false,"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"cachedNavigations":false,"partialFallbacks":false,"dynamicOnHover":false,"varyParams":false,"prefetchInlining":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","proxyPrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":3,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"imgOptSkipMetadata":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"strictRouteTypes":false,"viewTransition":false,"removeUncaughtErrorAndRejectionListeners":false,"validateRSCRequestHeaders":false,"staleTimes":{"dynamic":0,"static":300},"reactDebugChannel":true,"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"transitionIndicator":false,"gestureTransition":false,"inlineCss":false,"useCache":false,"globalNotFound":false,"browserDebugInfoInTerminal":"warn","lockDistDir":true,"proxyClientMaxBodySize":10485760,"hideLogsAfterAbort":false,"mcpServer":true,"turbopackFileSystemCacheForDev":true,"turbopackFileSystemCacheForBuild":false,"turbopackInferModuleSideEffects":true,"turbopackPluginRuntimeStrategy":"childProcesses","optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-sqlite-node","@effect/sql-sqlite-bun","@effect/sql-sqlite-wasm","@effect/sql-sqlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight","bundlePagesRouterDependencies":false,"configFileName":"next.config.ts","outputFileTracingExcludes":{"*":["node_modules/@img/**","node_modules/sharp/**"]},"turbopack":{"root":"/home/runner/work/failproofai/failproofai"},"distDirRoot":".next"}
13
13
 
14
14
  process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)
15
15
 
package/dist/cli.mjs CHANGED
@@ -16,7 +16,7 @@ var __export = (target, all) => {
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // package.json
19
- var version2 = "0.0.11-beta.5";
19
+ var version2 = "0.0.11-beta.7";
20
20
  var init_package = () => {};
21
21
 
22
22
  // src/posthog-key.ts
@@ -8747,7 +8747,10 @@ var init_telemetry = __esm(() => {
8747
8747
 
8748
8748
  // lib/fetch-with-timeout.ts
8749
8749
  function isAbortError(err) {
8750
- return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
8750
+ if (err === null || typeof err !== "object")
8751
+ return false;
8752
+ const name = err.name;
8753
+ return name === "AbortError" || name === "TimeoutError";
8751
8754
  }
8752
8755
 
8753
8756
  // lib/auth/api-server-client.ts
@@ -9900,7 +9903,7 @@ import { realpathSync as realpathSync3 } from "fs";
9900
9903
  import { dirname as dirname8, resolve as resolve16 } from "path";
9901
9904
  import { fileURLToPath as fileURLToPath3 } from "url";
9902
9905
  // package.json
9903
- var version = "0.0.11-beta.5";
9906
+ var version = "0.0.11-beta.7";
9904
9907
 
9905
9908
  // bin/failproofai.mjs
9906
9909
  if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
@@ -1,14 +1,28 @@
1
1
  /**
2
- * Shared fetch wrapper with per-request timeout via AbortController.
2
+ * Shared fetch wrapper with per-request timeout and external-signal
3
+ * composition.
3
4
  *
4
5
  * Replaces three byte-equivalent implementations that had drifted on the
5
6
  * default timeout (15s in two client components, 10s in the server-side
6
7
  * api-server-client). Co-locates the `isAbortError` predicate so callers
7
8
  * can classify timeout vs network failures consistently.
8
9
  *
9
- * Server-side uses `AbortSignal.timeout` where available (cheaper); this
10
- * helper uses the AbortController+setTimeout form because the client
11
- * code-paths need a guaranteed cleanup hook.
10
+ * Built on the platform primitives `AbortSignal.timeout()` which
11
+ * aborts with a typed `DOMException(..., "TimeoutError")` rather than
12
+ * the default bare abort that surfaces as Next.js 16's dev-overlay
13
+ * "signal is aborted without reason" warning — and `AbortSignal.any()`,
14
+ * which composes the timeout signal with any caller-supplied
15
+ * `init.signal` so a component-unmount or parent-controller abort
16
+ * actually stops the in-flight fetch. The prior `AbortController` +
17
+ * `setTimeout` implementation called `controller.abort()` with no
18
+ * reason AND silently dropped `init.signal` by spreading then
19
+ * overwriting it with the controller's own signal.
20
+ *
21
+ * Both APIs are available in every runtime Next.js 16 supports:
22
+ * `AbortSignal.timeout` since Node 17.3 / 16.14, `AbortSignal.any`
23
+ * since Node 20.3 (also backported to 18.17). Next.js 16's minimum
24
+ * runtime (Node >= 20.9, per its engines field) covers both. Browser
25
+ * support: Chrome 116+, Firefox 124+, Safari 17.4+.
12
26
  */
13
27
 
14
28
  /** Hard cap on every fetch using this helper unless overridden. Picked to
@@ -16,27 +30,30 @@
16
30
  * so a slow but successful upstream response still lands. */
17
31
  export const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
18
32
 
19
- export async function fetchWithTimeout(
33
+ export function fetchWithTimeout(
20
34
  input: RequestInfo | URL,
21
35
  init: RequestInit = {},
22
36
  timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
23
37
  ): Promise<Response> {
24
- const controller = new AbortController();
25
- const id = setTimeout(() => controller.abort(), timeoutMs);
26
- try {
27
- return await fetch(input, { ...init, signal: controller.signal });
28
- } finally {
29
- clearTimeout(id);
30
- }
38
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
39
+ const signal = init.signal
40
+ ? AbortSignal.any([init.signal, timeoutSignal])
41
+ : timeoutSignal;
42
+ return fetch(input, { ...init, signal });
31
43
  }
32
44
 
33
- /** True when an error came from an AbortController.abort() OR a
34
- * `AbortSignal.timeout` firing. Both surface as `Error` with `name`
35
- * set to "AbortError" or "TimeoutError" respectively. Keeping the two
36
- * in one predicate prevents the "I forgot TimeoutError" drift class. */
45
+ /** True when an error came from an `AbortController.abort()` OR an
46
+ * `AbortSignal.timeout()` firing. Both surface as objects whose `name`
47
+ * is "AbortError" or "TimeoutError" respectively depending on the
48
+ * runtime, they may be `Error` subclasses, `DOMException` instances,
49
+ * both, or neither (the jsdom polyfill, for one, breaks the Error
50
+ * prototype chain), so we duck-type on `name` rather than the
51
+ * provenance. Callers always pass values caught from a try/catch, so
52
+ * the universe of inputs is narrow enough that name-matching is
53
+ * unambiguous. Keeping the two names in one predicate prevents the
54
+ * "I forgot TimeoutError" drift class. */
37
55
  export function isAbortError(err: unknown): boolean {
38
- return (
39
- err instanceof Error
40
- && (err.name === "AbortError" || err.name === "TimeoutError")
41
- );
56
+ if (err === null || typeof err !== "object") return false;
57
+ const name = (err as { name?: unknown }).name;
58
+ return name === "AbortError" || name === "TimeoutError";
42
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.11-beta.5",
3
+ "version": "0.0.11-beta.7",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -826,6 +826,14 @@ const ARCHITECT_COWBOY_MAX_LIFT = 1.0;
826
826
  * vector, and 0.75 left goldfish under-reachable (< 4% of the population). */
827
827
  const GOLDFISH_ENTROPY = 0.70;
828
828
  const GOLDFISH_MIN_NONZERO = 4;
829
+ /** Goldfish also requires the SECOND-highest cluster lift to be meaningfully
830
+ * above baseline. Entropy on its own is satisfied by a uniform-at-baseline
831
+ * lift vector (every cluster firing at typical ambient rate → all lifts ≈ 1.0
832
+ * → entropy = max), which would otherwise collapse every "typical" agent onto
833
+ * goldfish. Requiring secondLift > 1.3 means at least TWO clusters are
834
+ * genuinely over-indexing — the actual definition of scatter, not just
835
+ * "average-volume noise spread across many clusters". */
836
+ const GOLDFISH_MIN_SECOND_LIFT = 1.3;
829
837
  /** When the top-two lifts are within this ratio, resolve deterministically by
830
838
  * the behaviour fingerprint instead of always taking the arithmetic winner —
831
839
  * spreads near-ties across the population without any RNG. */
@@ -891,9 +899,18 @@ export function classifyAgent(result: AuditResult, seed = ""): Classification {
891
899
  return { archetype: "architect", secondary: ARCHETYPES.architect.secondary, ...base };
892
900
  }
893
901
 
894
- // 3. goldfish — genuinely scattered across many clusters.
902
+ // 3. goldfish — genuinely scattered across many clusters. Both the entropy
903
+ // and the second-lift gate must clear: entropy catches "many clusters
904
+ // contribute", secondLift catches "the contribution is actually elevated
905
+ // relative to ambient". Without secondLift, a high-volume average user
906
+ // (lifts ≈ 1.0 everywhere) passes the entropy gate and collapses onto
907
+ // goldfish — that's the bug fixed alongside this gate.
895
908
  const nonZero = MAPPABLE_KEYS.filter((k) => f.clusterLift[k] > 0).length;
896
- if (nonZero >= GOLDFISH_MIN_NONZERO && f.entropy > GOLDFISH_ENTROPY) {
909
+ if (
910
+ nonZero >= GOLDFISH_MIN_NONZERO &&
911
+ f.entropy > GOLDFISH_ENTROPY &&
912
+ f.secondLift >= GOLDFISH_MIN_SECOND_LIFT
913
+ ) {
897
914
  const strongest = rankByLift(f, MAPPABLE_KEYS)[0];
898
915
  return { archetype: "goldfish", secondary: strongest, ...base };
899
916
  }
@@ -61,12 +61,26 @@ function getCachePathFor(transcriptPath: string): string {
61
61
  * silently rendered them as `cwd: undefined` (dropped from
62
62
  * `projectsScanned`) and `eventsScanned: 0`. Rejecting v1 forces a
63
63
  * re-scan so the new fields are populated correctly.
64
+ *
65
+ * v3: `CacheEntry` gained `cachedAt` to support the 7-day TTL below. v2
66
+ * entries lack the field; without a bump the age check would treat
67
+ * `cachedAt: undefined` as `NaN` (passes the `<= TTL` comparison
68
+ * accidentally on some paths) so we reject them outright and force a
69
+ * re-scan.
64
70
  */
65
- export const CACHE_SCHEMA_VERSION = 2;
71
+ export const CACHE_SCHEMA_VERSION = 3;
72
+
73
+ /** Hard expiry: a cached transcript result is rejected on read once it's
74
+ * this old. Keeps the audit pipeline honest about long-lived results that
75
+ * may no longer reflect current detector intent even if the transcript
76
+ * bytes and policy hashes haven't changed. */
77
+ export const CACHE_TTL_MS = 7 * 24 * 60 * 60_000;
66
78
 
67
79
  interface CacheEntry {
68
80
  /** Bumped whenever the on-disk shape changes incompatibly. */
69
81
  schemaVersion: number;
82
+ /** ms since epoch when the entry was written. Drives the 7-day TTL. */
83
+ cachedAt: number;
70
84
  mtimeMs: number;
71
85
  sizeBytes: number;
72
86
  engineVersion: string;
@@ -90,6 +104,10 @@ export function readCachedTranscriptResult(
90
104
  if (entry.sizeBytes !== sizeBytes) return null;
91
105
  if (entry.engineVersion !== getEngineVersion()) return null;
92
106
  if (entry.detectorVersion !== getDetectorVersion()) return null;
107
+ // Number.isFinite (not typeof) so a malformed JSON `Infinity` /
108
+ // `NaN` is rejected — those would otherwise bypass the TTL check
109
+ // and pin a stale entry as valid forever.
110
+ if (!Number.isFinite(entry.cachedAt) || Date.now() - (entry.cachedAt as number) > CACHE_TTL_MS) return null;
93
111
  return entry.result ?? null;
94
112
  } catch {
95
113
  return null;
@@ -108,6 +126,7 @@ export function writeCachedTranscriptResult(
108
126
  mkdirSync(join(homedir(), ".failproofai", "cache", "audit"), { recursive: true });
109
127
  const entry: CacheEntry = {
110
128
  schemaVersion: CACHE_SCHEMA_VERSION,
129
+ cachedAt: Date.now(),
111
130
  mtimeMs,
112
131
  sizeBytes,
113
132
  engineVersion: getEngineVersion(),
@@ -18,6 +18,12 @@ import type { AuditResult, RunAuditOptions } from "./types";
18
18
 
19
19
  const DEFAULT_MAX_AGE_MINUTES = 30;
20
20
 
21
+ /** Hard expiry: the dashboard cache is rejected on read once it's this old.
22
+ * Matches the per-transcript TTL in `cache.ts` so the whole audit surface
23
+ * has a consistent 7-day freshness window — past that, `/audit` falls
24
+ * through to its empty state and nudges the user to re-audit. */
25
+ const DASHBOARD_CACHE_TTL_MINUTES = 7 * 24 * 60;
26
+
21
27
  /**
22
28
  * Bump whenever the on-disk shape of a cached entry changes in a way the
23
29
  * reader can't tolerate (added required field, renamed key, swapped result
@@ -75,6 +81,9 @@ export function readDashboardCache(): DashboardCacheEntry | null {
75
81
  if (entry.schemaVersion !== DASHBOARD_CACHE_SCHEMA_VERSION) {
76
82
  return null;
77
83
  }
84
+ if (isCacheStale(entry.cachedAt, DASHBOARD_CACHE_TTL_MINUTES)) {
85
+ return null;
86
+ }
78
87
  return entry;
79
88
  } catch {
80
89
  return null;
@@ -109,3 +118,34 @@ export function isCacheStale(cachedAt: string, maxAgeMinutes: number = DEFAULT_M
109
118
  const ageMs = Date.now() - cachedMs;
110
119
  return ageMs > maxAgeMinutes * 60_000;
111
120
  }
121
+
122
+ /**
123
+ * Read just the `cachedAt` timestamp from the dashboard cache file,
124
+ * **bypassing** the TTL check. Used by the empty-state path to tell apart
125
+ * "no audit has ever run" from "your last audit aged out". A non-null
126
+ * return whose age exceeds `DASHBOARD_CACHE_TTL_MINUTES` means the cache
127
+ * was rejected by `readDashboardCache()` for being expired (rather than
128
+ * missing or schema-incompatible).
129
+ */
130
+ export function readDashboardCacheMeta(): { cachedAt: string } | null {
131
+ const cachePath = getCachePath();
132
+ if (!existsSync(cachePath)) return null;
133
+ try {
134
+ const raw = readFileSync(cachePath, "utf-8");
135
+ const entry = JSON.parse(raw) as Partial<DashboardCacheEntry>;
136
+ // Require schema-version match + a parseable ISO timestamp before
137
+ // surfacing the entry as "this user's audit aged out". A
138
+ // schema-incompatible entry is structurally indistinguishable from
139
+ // "no audit has ever run", so it should fall through to the
140
+ // first-run empty copy, not the expired banner.
141
+ if (
142
+ !entry
143
+ || entry.schemaVersion !== DASHBOARD_CACHE_SCHEMA_VERSION
144
+ || typeof entry.cachedAt !== "string"
145
+ || Number.isNaN(new Date(entry.cachedAt).getTime())
146
+ ) return null;
147
+ return { cachedAt: entry.cachedAt };
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
@@ -249,6 +249,11 @@ export interface AuditFeatures {
249
249
  clusterLift: Record<ArchetypeKey, number>;
250
250
  /** Normalised entropy of the lift vector (goldfish detector). */
251
251
  entropy: number;
252
+ /** Second-highest cluster lift. Goldfish requires this to be elevated:
253
+ * high entropy alone is satisfied by a uniform-at-baseline lift vector
254
+ * (every cluster fires at ambient rate → lifts ≈ 1.0 → entropy = max),
255
+ * which would otherwise collapse every "typical" agent onto goldfish. */
256
+ secondLift: number;
252
257
  /** Share of total signal coming from the two architect caution detectors. */
253
258
  cautionShare: number;
254
259
  cowboyLift: number;
@@ -288,7 +293,10 @@ export function deriveFeatures(result: AuditResult, seed = ""): AuditFeatures {
288
293
  clusterLift[k] = BASELINE_SHARE[k] > 0 ? share / BASELINE_SHARE[k] : 0;
289
294
  }
290
295
 
291
- const entropy = normalizedEntropy(MAPPABLE_KEYS.map((k) => clusterLift[k]));
296
+ const liftVec = MAPPABLE_KEYS.map((k) => clusterLift[k]);
297
+ const entropy = normalizedEntropy(liftVec);
298
+ const sortedLifts = [...liftVec].sort((a, b) => b - a);
299
+ const secondLift = sortedLifts[1] ?? 0;
292
300
  const cautionShare = totalSignal > 0 ? cautionRaw / totalSignal : 0;
293
301
  const faultRate = totalHits / events;
294
302
 
@@ -300,7 +308,7 @@ export function deriveFeatures(result: AuditResult, seed = ""): AuditFeatures {
300
308
 
301
309
  return {
302
310
  events, sessions, totalHits, faultRate,
303
- clusterRaw, totalSignal, clusterLift, entropy,
311
+ clusterRaw, totalSignal, clusterLift, entropy, secondLift,
304
312
  cautionShare, cowboyLift: clusterLift.cowboy, fingerprint,
305
313
  };
306
314
  }