Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
### Fixes
- Stop shipping npm lifecycle scripts, removing the `npm warn` every install printed. npm 12 (`allowScripts`, released 2026-07-08 and now the `latest` dist-tag) blocks dependency install scripts by default, so `npm install failproofai` warned `1 package had install scripts blocked … failproofai@… (postinstall: node scripts/postinstall.mjs)` and silently skipped the script; npm 11.16+ prints the advisory `allow-scripts` variant and still runs it. `allowScripts` is purely consumer-side — a package cannot opt itself in (verified: a package declaring `allowScripts`/`trustedDependencies` for *itself* is ignored) — so the only fix is to ship no install scripts. The `postinstall` script's install telemetry (`first_install` / `version_changed` / `package_installed`, with identical event names and properties) now fires from the CLI's first non-hook invocation via `lib/install-check.ts`, reporting at most once per version and no-op'ing on the steady-state path; it is deliberately kept out of `bin/failproofai.mjs`'s `--hook` fast path, which runs on every tool call. This also *recovers* telemetry already being dropped for bun, pnpm, and Yarn ≥4.14 users, which have blocked install scripts for some time. The script's `server.js` check and shadowed-PATH diagnosis were redundant — `scripts/launch.ts` already performs both at launch, with a better error message. `package_installed` now measures install→activation rather than raw installs, and same-version reinstalls (`direction: "reinstall"`) are no longer reported, as the CLI has no signal to detect them. The install-time welcome message is dropped; the `config` wizard's first-run redirect already handles onboarding. (#560)
- Remove `scripts/preuninstall.mjs`, which never ran. npm honours no uninstall lifecycle scripts (verified with a probe package: `postinstall` fired, `preuninstall` did not), so the hook cleanup it appeared to perform has never happened — uninstalling failproofai leaves `__failproofai_hook__` entries behind in settings files. Removing the dead code makes the gap visible; cleanup needs an explicit `failproofai policies --uninstall` before removing the package. (#560)
- `formatDuration` now rounds to the precision the final output uses *before* bucketing into seconds/minutes/hours, so a remainder that rounds to exactly 60 carries into the next unit instead of rendering as an invalid component like `60.0s`, `1m 60s`, or `59m 60s`. (#529)
- Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook <Event>` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use <other>` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564)
- Report the onboarding auto-audit to PostHog. `runPostSetupAudit()` — the audit that runs automatically at the end of first-run setup, and therefore the **first audit every new user ever runs** — emitted no telemetry at all, while an explicit `failproofai audit` reported `cli_audit_started` / `cli_audit_completed` / `cli_audit_failed`. First-run audits were invisible, so the audit funnel silently undercounted exactly the activation moment it exists to measure (confirmed live: a fresh install's auto-audit wrote its dashboard cache and reached PostHog with nothing). It now emits the same three events, tagged `source: "onboarding"` against the existing `source: "cli"`, so the two paths stay distinguishable. `cli_audit_completed` fires before the empty-history return, matching `runAuditCli`, so a fresh user with no agent history is still counted; `cli_audit_failed` is awaited because the function returns straight into the dashboard boot, which would otherwise race a fire-and-forget send. The completed-event properties are now built by one shared helper so the two entry points cannot drift. Onboarding remains best-effort: it never throws and never exits. (#562)
- Report only our own dashboard failures, not browser extensions'. `GlobalErrorListeners` registers page-global `error` / `unhandledrejection` handlers, and browser extensions inject content scripts into the same page and share the same `window` — so their failures reached our listeners and went to PostHog stamped `$lib: failproofai-web`, as though the dashboard had thrown them. Observed live: MetaMask's "Failed to connect to MetaMask" (`error_name: "i"`, its minified class) arriving as a failproofai `unhandled_rejection` on `/policies`. Extensions are user-installed and open-ended, so the noise was unbounded, depended on which extensions a user happened to run rather than on our code, and would eventually have drowned the real signal these listeners exist to catch. Both handlers now attribute the error before reporting (new `lib/error-origin.ts`) and report it only when it traces back to our own origin. Attribution is positive rather than a denylist of known extensions, so an unrecognised extension is filtered by default; the match is on the shared `<vendor>-extension://` suffix, so a new browser's scheme needs no code change. Unattributable errors (cross-origin `"Script error."`, rejections of non-Error values) are dropped too — there is nothing in them to debug. React render errors are unaffected: the error boundaries report `client_error` directly, and React only invokes those for errors thrown inside our own tree. (#560)
Expand Down
41 changes: 41 additions & 0 deletions __tests__/lib/format-duration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,45 @@ describe("formatDuration", () => {
it("large: 86400000ms (24h)", () => {
expect(formatDuration(86400000)).toBe("24h 0m");
});

// Issue #521: rounding must happen before bucketing so carries propagate.
it("carries rounded seconds to minutes (59999ms -> 1m 0s)", () => {
expect(formatDuration(59999)).toBe("1m 0s");
});

it("carries rounded seconds to minutes (119600ms -> 2m 0s)", () => {
expect(formatDuration(119600)).toBe("2m 0s");
});

it("carries rounded seconds to hours (3599600ms -> 1h 0m)", () => {
expect(formatDuration(3599600)).toBe("1h 0m");
});

it("boundary: just below second-to-minute carry (59949ms)", () => {
expect(formatDuration(59949)).toBe("59.9s");
});

it("boundary: second-to-minute carry (59950ms -> 1m 0s)", () => {
expect(formatDuration(59950)).toBe("1m 0s");
});

it("boundary: just below minute-to-hour carry (3599499ms)", () => {
expect(formatDuration(3599499)).toBe("59m 59s");
});

it("boundary: minute-to-hour carry (3599500ms -> 1h 0m)", () => {
expect(formatDuration(3599500)).toBe("1h 0m");
});

it("boundary: just above hour bucket (3660000ms -> 1h 1m)", () => {
expect(formatDuration(3660000)).toBe("1h 1m");
});

it("regression pin: seconds rounding still formats 1499ms as 1.5s", () => {
expect(formatDuration(1499)).toBe("1.5s");
});

it("regression pin: minutes rounding still formats 312000ms as 5m 12s", () => {
expect(formatDuration(312000)).toBe("5m 12s");
});
});
32 changes: 23 additions & 9 deletions lib/format-duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,28 @@ export function formatRelativeTime(ts: number): string {

export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const totalMinutes = Math.floor(seconds / 60);
if (totalMinutes >= 60) {
const hours = Math.floor(totalMinutes / 60);
const remainingMinutes = totalMinutes % 60;
return `${hours}h ${remainingMinutes}m`;

// Round to the precision the output will actually use, then bucket.
// Seconds are shown with one decimal place, so round to the nearest 0.1 s
// before deciding whether we have crossed into the minute range.
const deciseconds = Math.round(ms / 100);
if (deciseconds < 600) {
return `${(deciseconds / 10).toFixed(1)}s`;
}

// Minutes are shown with whole seconds, so round to the nearest second
// before splitting into minutes and seconds.
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
const remainingSeconds = (seconds % 60).toFixed(0);
return `${totalMinutes}m ${remainingSeconds}s`;

// Hours are shown with whole minutes, so round to the nearest minute
// before splitting into hours and minutes.
const totalMinutes = Math.round(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${minutes}m`;
}