failproofai 1.0.0-beta.16 → 1.0.0-beta.18

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 (119) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  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/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  28. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +14 -14
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  36. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  41. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  44. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  45. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  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/build-manifest.json +2 -2
  57. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  61. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  62. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0no2gzh._.js → [root-of-the-server]__1a-my61._.js} +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1vwv3db._.js +3 -0
  75. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/{_09tcu2b._.js → _1ifzifk._.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  87. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  88. package/.next/standalone/.next/server/pages/404.html +1 -1
  89. package/.next/standalone/.next/server/pages/500.html +1 -1
  90. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  91. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  92. package/.next/standalone/.next/static/chunks/{1_as8528fwqxf.js → 0rsx_d5zvy-dc.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{0fx3tgb_n0vz5.js → 16_af09907p72.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{2okcei5v0-xrx.js → 27nyktroru6xd.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{16yzosmvpinbh.js → 2_g_ltvh3jcgh.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{3xp43uu8jj8t4.js → 2cgpelmb2zoh-.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2f2h7g8sm0bhr.js → 2kjsoq1g8t4qc.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{1nsy22eg5q-pi.js → 2q5ygkgs9l1gk.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{2n6wkcj5q_0yd.js → 2toc5dmy7efqk.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{2rl5gq4xl8oa2.js → 31fmsp1soxi07.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{43kqvfa-2jq0g.js → 3wq4n27ea5hkm.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{3vsiw3ayz3c7_.js → 3yxozwzbd09eg.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{turbopack-0ud32mdjjcn6y.js → turbopack-3diub4ctvn1n0.js} +1 -1
  104. package/.next/standalone/package.json +9 -9
  105. package/.next/standalone/server.js +1 -1
  106. package/dist/cli.mjs +175 -142
  107. package/dist/worker.mjs +1 -1
  108. package/package.json +9 -9
  109. package/src/hooks/configure-wizard.ts +34 -1
  110. package/src/hooks/daemon-service.ts +89 -62
  111. package/src/hooks/fp-reset.ts +16 -0
  112. package/src/hooks/harness-cli.ts +88 -11
  113. package/src/hooks/hook-activity-store.ts +29 -1
  114. package/src/hooks/migrations.ts +40 -15
  115. package/src/hooks/tui.ts +28 -0
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hxmox-._.js +0 -3
  117. /package/.next/standalone/.next/static/{kSTfP_Tzl760gYDnYr-JC → d2alqN0WTpN7MpYISLm81}/_buildManifest.js +0 -0
  118. /package/.next/standalone/.next/static/{kSTfP_Tzl760gYDnYr-JC → d2alqN0WTpN7MpYISLm81}/_clientMiddlewareManifest.js +0 -0
  119. /package/.next/standalone/.next/static/{kSTfP_Tzl760gYDnYr-JC → d2alqN0WTpN7MpYISLm81}/_ssgManifest.js +0 -0
package/dist/worker.mjs CHANGED
@@ -2510,7 +2510,7 @@ var init_builtin_policies = __esm(() => {
2510
2510
  });
2511
2511
 
2512
2512
  // package.json
2513
- var version = "1.0.0-beta.16";
2513
+ var version = "1.0.0-beta.18";
2514
2514
  var init_package = () => {};
2515
2515
 
2516
2516
  // src/posthog-key.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.0-beta.16",
3
+ "version": "1.0.0-beta.18",
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",
@@ -112,15 +112,15 @@
112
112
  "sharp": "0.35.0"
113
113
  },
114
114
  "optionalDependencies": {
115
- "@failproofai/failproofaid-linux-x64": "1.0.0-beta.16",
116
- "@failproofai/failproofaid-linux-arm64": "1.0.0-beta.16",
117
- "@failproofai/failproofaid-darwin-x64": "1.0.0-beta.16",
118
- "@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.16"
115
+ "@failproofai/failproofaid-linux-x64": "1.0.0-beta.18",
116
+ "@failproofai/failproofaid-linux-arm64": "1.0.0-beta.18",
117
+ "@failproofai/failproofaid-darwin-x64": "1.0.0-beta.18",
118
+ "@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.18"
119
119
  },
120
120
  "failproofaidBinaries": {
121
- "linux-x64": "b82471309d2e52724781d8074bae55444892030eb1b48305ef74053bb7938931",
122
- "linux-arm64": "5a0bdc9f726844e7ed109c896bb894e84a51c7592fe4aed26eceea78c97fde04",
123
- "darwin-x64": "08881822befb31fe13974d2861fc50b6b2c1c72741986656e8f9087586202d88",
124
- "darwin-arm64": "cc85096de72f3e5813145b7c906ea6a2d4788f39f4dae6a86532d88107802cab"
121
+ "linux-x64": "54bf7cc41ee8b5a2ea9430459c1fe9b8b03ece25c18d8bee49441feee93c7057",
122
+ "linux-arm64": "1f5e7c4be2d9c591cf4fd39e5fc122e71f507115cdb2ad1a27c5f30f22a30028",
123
+ "darwin-x64": "ae510969144aaec5e8891cfa5cdf7374c5efbd7554547046efa5913aab5c3ab2",
124
+ "darwin-arm64": "bc8d96e295aa9a68590be6e12d4bf3711749a31371206e63ccc36dd42b275e18"
125
125
  }
126
126
  }
@@ -967,6 +967,24 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
967
967
  // is stated, not prompted), so ← there would sometimes go nowhere.
968
968
  let presets: string[] | null = null;
969
969
  let clisSel: string[] | null = null;
970
+ /**
971
+ * What the harness step had ticked when ← was last pressed.
972
+ *
973
+ * A SEPARATE variable, because `clisSel` cannot do this job: it is the loop's
974
+ * own condition (`while (clisSel === null)`), so it is null on every entry into
975
+ * the body by definition, and it is assigned only on the line that ends the
976
+ * loop. The restore that read `clisSel` was therefore unreachable — provably
977
+ * dead, with a comment stating the opposite intent.
978
+ *
979
+ * The cost was not cosmetic: deselect a CLI, press ← to fix an earlier answer,
980
+ * come back, and the step showed the detected defaults again. Pressing ↵ then —
981
+ * reasonably, having been told the selection was carried back — re-enabled hook
982
+ * installation for a CLI the user had explicitly turned off.
983
+ *
984
+ * `presets` just above works because it is assigned MID-loop and survives to the
985
+ * next iteration; this mirrors that, filled from the prompt's `onBack`.
986
+ */
987
+ const carried: { clis: string[] | null } = { clis: null };
970
988
  while (clisSel === null) {
971
989
  // Re-entering after a ← must show what was picked, not a blank slate.
972
990
  // Selection state lives on each choice, so carry it back in.
@@ -986,7 +1004,16 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
986
1004
 
987
1005
  // 3 — Which harnesses? An "Everything available" row protects every supported
988
1006
  // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes.
989
- const priorClis = clisSel as string[] | null;
1007
+ // Read off a HOLDER OBJECT, not a bare `let`, and not through a cast.
1008
+ //
1009
+ // A `let` assigned only inside a callback is narrowed by control-flow analysis
1010
+ // to its initializer, so `priorClis.includes` will not compile — and the
1011
+ // original defeated that with `clisSel as string[] | null`. That cast is
1012
+ // precisely why the dead code type-checked and nobody noticed: it silenced the
1013
+ // compiler making exactly the point the reviewer later made by hand, that the
1014
+ // value could only ever be null. A property read carries the declared type
1015
+ // without suppressing anything.
1016
+ const priorClis = carried.clis;
990
1017
  const picked: string[] | typeof BACK | null = await multiSelect<string>({
991
1018
  message: "Which harnesses should it protect?",
992
1019
  choices: [
@@ -1006,6 +1033,12 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1006
1033
  summaryNoun: "harnesses",
1007
1034
  hint: "detected CLIs are pre-selected · space toggles · ctrl+a all · ← back · ↵ confirm",
1008
1035
  allowBack: true as const,
1036
+ // `BACK` is a symbol and cannot carry the selection, so the prompt reports
1037
+ // it here instead — otherwise a ← discards what the user had ticked and the
1038
+ // next pass redraws the detected defaults.
1039
+ onBack: (checkedNow) => {
1040
+ carried.clis = checkedNow;
1041
+ },
1009
1042
  stdin,
1010
1043
  stdout,
1011
1044
  });
@@ -1506,77 +1506,104 @@ export async function ensureDaemonServiceCurrent(): Promise<DaemonUpgradeResult>
1506
1506
  * to in-process evaluation would trade a visible failure for an invisible one.
1507
1507
  * The lines say so.
1508
1508
  */
1509
- export async function refreshDaemonToCliVersion(): Promise<{ ok: boolean; lines: string[] }> {
1510
- const lines: string[] = [];
1511
- if (daemonServiceStatus() === "not-installed") {
1509
+ export async function refreshDaemonToCliVersion(
1510
+ /**
1511
+ * Injectable for tests, matching `waitForDaemonRunning`'s shape in this file.
1512
+ * A `vi.spyOn` on the module namespace cannot intercept these — the module
1513
+ * calls its own local bindings — so without a seam a test asserting either
1514
+ * branch passes for the wrong reason: on a machine with no service, "no-op"
1515
+ * and "the spy worked" are indistinguishable.
1516
+ */
1517
+ deps: {
1518
+ status?: () => DaemonServiceStatus;
1519
+ install?: () => Promise<DaemonInstallResult>;
1520
+ prime?: () => boolean;
1521
+ interactive?: () => boolean;
1522
+ } = {},
1523
+ ): Promise<{ ok: boolean; lines: string[] }> {
1524
+ const status = deps.status ?? daemonServiceStatus;
1525
+ const install = deps.install ?? installDaemonService;
1526
+ const prime = deps.prime ?? primeElevation;
1527
+ const interactive =
1528
+ deps.interactive ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
1529
+ if (status() === "not-installed") {
1512
1530
  return { ok: true, lines: ["No failproofaid service on this machine; nothing to update."] };
1513
1531
  }
1514
1532
 
1515
- const binary = await ensureFailproofaidBinary();
1516
- if (!binary.path) {
1517
- return {
1518
- ok: false,
1519
- lines: [
1520
- `Could not put the failproofaid ${version} binary in place: ${binary.reason ?? "unknown reason"}`,
1521
- `The installed daemon is unchanged. It refuses to start against a layout it does`,
1522
- `not speak, so if this machine has just been migrated, run \`failproofai config\`.`,
1523
- ],
1524
- };
1525
- }
1526
- lines.push(`failproofaid ${version} binary in place.`);
1527
-
1528
- const upgrade = await ensureDaemonServiceCurrent();
1529
- if (upgrade.outcome === "failed") {
1530
- return {
1531
- ok: false,
1532
- lines: [
1533
- ...lines,
1534
- `The service definition could not be refreshed: ${upgrade.reason ?? "unknown reason"}`,
1535
- ...(upgrade.daemonRunning === false
1536
- ? [
1537
- `failproofaid is NOT running. On a machine configured to require it, every tool`,
1538
- `call denies until it is back — run \`failproofai config\` to rebuild the service.`,
1539
- ]
1540
- : []),
1541
- ],
1542
- };
1543
- }
1544
-
1545
- // `daemonRestartCommand()` returns the human-facing string (`sudo systemctl
1546
- // restart …`); the actual call goes through `runPrivileged`, which is `sudo -n`
1547
- // and never prompts. A machine that cannot elevate gets the command to run by
1548
- // hand — the same contract `installDaemonService` follows.
1549
- const restart = daemonRestartCommand();
1550
- try {
1551
- if (process.platform === "darwin") {
1552
- runPrivileged("launchctl", ["kickstart", "-k", `system/${launchdLabel()}`]);
1553
- } else {
1554
- runPrivileged("systemctl", ["restart", systemdUnitName()]);
1555
- }
1556
- } catch {
1557
- return {
1558
- ok: false,
1559
- lines: [
1560
- ...lines,
1561
- `Could not restart failproofaid without a password. Run this yourself:`,
1562
- ` ${restart ?? "restart the failproofaid service"}`,
1563
- ],
1564
- };
1565
- }
1566
- // Probed, never inferred from the restart command's exit code — see
1567
- // `waitForDaemonRunning`, which exists because one reading of `is-active`
1568
- // passes a daemon that died immediately after forking.
1569
- if (!(await waitForDaemonRunning())) {
1533
+ // `installDaemonService()`, not a binary fetch plus a restart.
1534
+ //
1535
+ // The first version of this did the latter, and it did not work: it called
1536
+ // `ensureFailproofaidBinary()` (which correctly lands
1537
+ // `bin/failproofaid-<this version>`) and then `ensureDaemonServiceCurrent()`,
1538
+ // whose rewrite goes through `upgradedServiceDefinition` — and that PRESERVES
1539
+ // the existing `ExecStart` via `installedExecStart(definition)`, by design,
1540
+ // because its job is to upgrade the unit's SHAPE without changing which binary
1541
+ // runs. So on a machine coming from an older release the new binary was
1542
+ // downloaded, the unit was rewritten, the service was restarted, and the OLD
1543
+ // binary came back up — under a message that said the daemon had been
1544
+ // refreshed. Worse than not having the command, because it reports success.
1545
+ //
1546
+ // `installDaemonService()` is the function that already knows how to make the
1547
+ // service run THIS CLI's version: it resolves the binary for this version,
1548
+ // writes the unit around that path, removes any legacy user unit, reloads,
1549
+ // enables, and waits for the service to reach AND HOLD a running state rather
1550
+ // than trusting the moment a `Type=simple` unit reports active. It is
1551
+ // idempotent, which is what makes it safe to call on an already-installed
1552
+ // machine.
1553
+ // ASK FOR THE PASSWORD, when there is somebody to ask.
1554
+ //
1555
+ // Writing the unit needs root, and `runPrivileged` uses `sudo -n` — which never
1556
+ // prompts. That rule exists for the WIZARD, whose reason is stated where it is
1557
+ // enforced: a password prompt fired from underneath a full-screen TUI is
1558
+ // unreadable at best. It does not transfer to this command, which is plain line
1559
+ // output with nothing to corrupt, and the wizard itself calls `primeElevation()`
1560
+ // for exactly this at exactly this point.
1561
+ //
1562
+ // Without it, `failproofai update` on the machine it exists for — one with a
1563
+ // daemon, upgrading — failed with "sudo credentials were not available" and a
1564
+ // thirty-line unit file to paste by hand. The only working alternatives were
1565
+ // `sudo -v` first (undocumented) or `failproofai config`, an interactive wizard.
1566
+ // That is not an upgrade path.
1567
+ //
1568
+ // Gated on a TTY, not attempted blindly: on a CI runner or a fleet box there is
1569
+ // nobody to type a password, and `sudo -v` there would block on a prompt nothing
1570
+ // will answer. Those runs still fall through to `sudo -n`, fail, and get the
1571
+ // exact commands to run — which is the right outcome for an unattended machine.
1572
+ // `primeElevation()` is itself a no-op when already root or NOPASSWD.
1573
+ if (interactive()) prime();
1574
+
1575
+ const result = await install();
1576
+ if (!result.installed) {
1570
1577
  return {
1571
1578
  ok: false,
1572
1579
  lines: [
1573
- ...lines,
1574
- `failproofaid was restarted but did not come back up. \`${daemonStatusCommand()}\` will say why.`,
1575
- `On a machine configured to require it, every tool call denies until it does.`,
1580
+ `failproofaid ${version} could not be installed: ${result.reason ?? "unknown reason"}`,
1581
+ `The previous daemon is untouched. On a machine configured to require it,`,
1582
+ `enforcement continues; collection and cloud policy may be stale until this`,
1583
+ `succeeds. \`${daemonStatusCommand() ?? "systemctl status"}\` will say more.`,
1576
1584
  ],
1577
1585
  };
1578
1586
  }
1579
- return { ok: true, lines: [...lines, `failproofaid restarted and running.`] };
1587
+ // RECORD THE VERSION, or the refresh is invisible to everything that asks.
1588
+ //
1589
+ // `installDaemonService()` deliberately does not write it — only the wizard did
1590
+ // (`setDaemonConfigured(true, cliVersion)`), because that is where "this machine
1591
+ // is now configured, at this version" is decided. So without this line the new
1592
+ // binary runs while `VERSION.daemon` still names the old one, and
1593
+ // `daemonVersionSkew()` reads that file: every later CLI command keeps nudging
1594
+ // about a stale daemon that was just replaced, and the wizard's `daemonMaybeUp`
1595
+ // stays false on the skew it should no longer see — so a later `failproofai
1596
+ // config` would tear down and rebuild a perfectly current service.
1597
+ //
1598
+ // `writeVersionFile` rather than `setDaemonConfigured(true, …)`: that helper also
1599
+ // sets `daemon.configured`, and turning on fail-closed enforcement as a SIDE
1600
+ // EFFECT of an update is not this command's decision to make. An update refreshes
1601
+ // what is installed; it does not change whether the machine requires it.
1602
+ writeVersionFile({ daemon: version });
1603
+ return {
1604
+ ok: true,
1605
+ lines: [`failproofaid ${version} installed, service restarted and holding.`],
1606
+ };
1580
1607
  }
1581
1608
 
1582
1609
  export async function uninstallDaemonService(): Promise<void> {
@@ -207,6 +207,22 @@ export function migrateConventionPolicies(): string[] {
207
207
  // the same rule, one level down.
208
208
  if (entry.isDirectory() && statSync(target).isDirectory()) {
209
209
  mergeInto(source, target, `${name}/`);
210
+ // AND remove the child once the recursion drained it. Without this, a
211
+ // merged directory leaves an empty husk behind, so the `rmdirSync(from)`
212
+ // below throws ENOTEMPTY into a swallowing `catch` and
213
+ // `custom-policies/` survives the migration it just completed —
214
+ // permanently, because the next run recurses into the same empty child
215
+ // and fails the same way, so it never self-heals.
216
+ //
217
+ // Only reachable when the merge moved EVERYTHING: a genuine leaf
218
+ // collision inside leaves a file here, `rmdirSync` refuses, and the
219
+ // catch is then correct — that husk is the user's remaining source,
220
+ // which is the one thing this function must not delete.
221
+ try {
222
+ rmdirSync(source);
223
+ } catch {
224
+ // Not empty, so something was deliberately left. Keep it.
225
+ }
210
226
  }
211
227
  }
212
228
  };
@@ -35,6 +35,8 @@
35
35
  * running daemon never picks up.
36
36
  */
37
37
 
38
+ import { homedir } from "node:os";
39
+ import { resolve } from "node:path";
38
40
  import { readConfig, updateConfig } from "./fp-config";
39
41
  import { configFile } from "./fp-home";
40
42
 
@@ -143,6 +145,55 @@ const TAKES_EFFECT_HINT = [
143
145
  "The daemon picks this up on its own within a few seconds — no restart, no sudo.",
144
146
  ];
145
147
 
148
+ /**
149
+ * The daemon's label rule, FOR COMPARISON ONLY.
150
+ *
151
+ * Mirrors `sanitize_label()` in `crates/fpai-collect/src/extra_paths.rs`:
152
+ * lowercase, every non-alphanumeric run collapsed to one `-`, leading and
153
+ * trailing dashes trimmed.
154
+ *
155
+ * Deliberately NOT used to rewrite what is stored. The daemon is the authority on
156
+ * the grammar, and this file's header is explicit that it must not re-implement
157
+ * the parser — "two parsers is how the CLI comes to accept a path the daemon then
158
+ * silently drops". Normalising for a duplicate CHECK is the narrow opposite of
159
+ * that: it makes the CLI's own pre-flight agree with the rule it exists to
160
+ * pre-empt, while the stored string stays exactly what the user typed and the
161
+ * daemon still derives the real label.
162
+ */
163
+ function comparableLabel(raw: string): string {
164
+ return raw
165
+ .toLowerCase()
166
+ .replace(/[^a-z0-9]+/g, "-")
167
+ .replace(/^-+|-+$/g, "");
168
+ }
169
+
170
+ /**
171
+ * The daemon's path rule, for comparison only. Mirrors `clean()`: `~/` expanded,
172
+ * trailing slashes trimmed.
173
+ */
174
+ function comparablePath(raw: string): string {
175
+ const home = homedir();
176
+ const expanded = raw === "~" ? home : raw.startsWith("~/") ? resolve(home, raw.slice(2)) : raw;
177
+ return expanded.replace(/\/+$/, "");
178
+ }
179
+
180
+ /**
181
+ * What the daemon will call this entry, label given or derived.
182
+ *
183
+ * Mirrors `derive_label()` for the unlabelled case: the folder name with a leading
184
+ * dot stripped, then sanitised. Without this an unlabelled
185
+ * `/mnt/team-a/.claude/projects` and `/mnt/team-b/.claude/projects` both derive
186
+ * "projects" — the CLI's label check was skipped entirely for unlabelled entries,
187
+ * so both were written and the daemon silently dropped the second.
188
+ */
189
+ function effectiveLabel(entry: string): string {
190
+ const given = labelOf(entry);
191
+ if (given) return comparableLabel(given);
192
+ const path = comparablePath(pathOf(entry));
193
+ const base = path.split("/").filter(Boolean).pop() ?? "";
194
+ return comparableLabel(base.replace(/^\.+/, ""));
195
+ }
196
+
146
197
  export function addPath(harness: string, entry: string): HarnessResult {
147
198
  if (!(HARNESS_KEYS as readonly string[]).includes(harness)) return unknownHarness(harness);
148
199
  const trimmed = entry.trim();
@@ -163,7 +214,11 @@ export function addPath(harness: string, entry: string): HarnessResult {
163
214
  // Both are rejected HERE rather than left to the daemon, because the daemon
164
215
  // resolves them at startup and drops one — so the CLI would report success
165
216
  // for a path that is never captured.
166
- const samePath = existing.find((e) => pathOf(e) === pathOf(trimmed));
217
+ // Compared NORMALISED, not raw. `add-path claude a=/srv/x/` then
218
+ // `add-path claude b=/srv/x` differ as strings and are the same path to the
219
+ // daemon, which resolves them at startup and drops one — so the CLI reported
220
+ // success for a path that is never captured.
221
+ const samePath = existing.find((e) => comparablePath(pathOf(e)) === comparablePath(pathOf(trimmed)));
167
222
  if (samePath) {
168
223
  return fail([
169
224
  `${harness} already captures ${pathOf(trimmed)} as ${JSON.stringify(samePath)}.`,
@@ -173,24 +228,46 @@ export function addPath(harness: string, entry: string): HarnessResult {
173
228
  ]);
174
229
  }
175
230
  const wanted = labelOf(trimmed);
176
- if (wanted) {
177
- const sameLabel = existing.find((e) => labelOf(e) === wanted);
178
- if (sameLabel) {
179
- return fail([
180
- `${harness} already uses the label ${JSON.stringify(wanted)} for ${pathOf(sameLabel)}.`,
181
- "",
182
- "Labels namespace agent ids, so two paths cannot share one.",
183
- ]);
184
- }
231
+ // The EFFECTIVE label, normalised, and computed for unlabelled entries too.
232
+ // Three inputs slipped past the old exact-string check on the given label:
233
+ // `"Team Share"` vs `team-share` (the daemon lowercases and substitutes), and two
234
+ // unlabelled paths whose folder name derives the same label — for which the check
235
+ // did not run at all. In every case both entries were written, `harness list`
236
+ // showed both, and only one was captured.
237
+ const effective = effectiveLabel(trimmed);
238
+ const sameLabel = effective
239
+ ? existing.find((e) => effectiveLabel(e) === effective)
240
+ : undefined;
241
+ if (sameLabel) {
242
+ return fail([
243
+ `${harness} already uses the label ${JSON.stringify(effective)} for ${pathOf(sameLabel)}.`,
244
+ "",
245
+ "Labels namespace agent ids, so two paths cannot share one — and the daemon",
246
+ "lowercases them and collapses punctuation to `-`, so labels that differ only",
247
+ "in case or spacing collide. An unlabelled path takes its folder name.",
248
+ "",
249
+ "Give this one an explicit label:",
250
+ ` failproofai harness add-path ${harness} <label>=${pathOf(trimmed)}`,
251
+ ]);
185
252
  }
186
253
 
187
254
  writePaths(harness, [...existing, trimmed]);
188
255
  return ok([
189
- `${harness}: now also capturing ${pathOf(trimmed)}`,
256
+ // "configured", NOT "now capturing". The checks above cover what this side can
257
+ // know — the entries already in the file — and there is one rejection it
258
+ // cannot: a path overlapping the harness's own DEFAULT capture root, which the
259
+ // daemon owns and refuses. Claiming capture there was a promise this command
260
+ // is not in a position to make, so it says what it did and where the answer
261
+ // is. Teaching the CLI all thirteen sources' default roots would be the second
262
+ // parser this file exists to avoid.
263
+ `${harness}: configured to also capture ${pathOf(trimmed)}`,
190
264
  wanted
191
265
  ? ` agent ids will be namespaced ${wanted}-*`
192
266
  : " a label will be derived from the folder name; `failproofai harness list` shows it",
193
267
  ` written to ${configFile()}`,
268
+ "",
269
+ " The daemon validates it on the next read and reports what it rejected —",
270
+ " `failproofai harness list` shows what is actually being captured.",
194
271
  ...TAKES_EFFECT_HINT,
195
272
  ]);
196
273
  }
@@ -388,6 +388,34 @@ export function getHookActivityHistory(page: number): {
388
388
 
389
389
  // ── Internal helpers ──
390
390
 
391
+ /**
392
+ * Accept the pre-rename spelling of the two cloud-attribution fields.
393
+ *
394
+ * These pages are written by the DAEMON, so a machine that was cloud-connected
395
+ * before `cloudRevision`→`cloudVersion` / `cloudGeneration`→`cloudDeployment` has
396
+ * real rows on disk naming the old keys. Nothing here validates the shape — the
397
+ * line is `JSON.parse`d and cast — so those rows do not error, they simply carry
398
+ * keys nothing reads: every pre-upgrade cloud-decided decision renders as
399
+ * unattributed, which is the exact question these fields were added to answer.
400
+ *
401
+ * The Rust writer takes the same two aliases (`transform.rs`). Renaming a symbol
402
+ * is safe; renaming the name of data an older build already wrote is not.
403
+ *
404
+ * One-directional and non-destructive: the new key wins if both somehow appear,
405
+ * and the old key is left in place rather than deleted, since nothing reads it and
406
+ * removing it would rewrite history to look like it was always current.
407
+ */
408
+ function withRenamedCloudFields(raw: Record<string, unknown>): HookActivityEntry {
409
+ const entry = raw as HookActivityEntry & Record<string, unknown>;
410
+ if (entry.cloudVersion === undefined && typeof raw.cloudRevision === "number") {
411
+ entry.cloudVersion = raw.cloudRevision;
412
+ }
413
+ if (entry.cloudDeployment === undefined && typeof raw.cloudGeneration === "number") {
414
+ entry.cloudDeployment = raw.cloudGeneration;
415
+ }
416
+ return entry;
417
+ }
418
+
391
419
  function readJsonlFile(filePath: string): HookActivityEntry[] {
392
420
  const content = readFileSafe(filePath);
393
421
  if (!content.trim()) return [];
@@ -395,7 +423,7 @@ function readJsonlFile(filePath: string): HookActivityEntry[] {
395
423
  const entries: HookActivityEntry[] = [];
396
424
  for (const line of content.trim().split("\n")) {
397
425
  try {
398
- entries.push(JSON.parse(line) as HookActivityEntry);
426
+ entries.push(withRenamedCloudFields(JSON.parse(line) as Record<string, unknown>));
399
427
  } catch {
400
428
  // Skip malformed lines
401
429
  }
@@ -207,7 +207,44 @@ function appendLedger(entry: LedgerEntry): void {
207
207
  * such a bug would hurt.
208
208
  */
209
209
  const BACKED_UP = [configFile, credentialsFile, globalPolicyConfigFile, versionFile];
210
- const BACKED_UP_LEGACY = [legacy.configToml, legacy.credentialsToml, legacy.policyConfig];
210
+ const BACKED_UP_LEGACY = [
211
+ legacy.configToml,
212
+ legacy.credentialsToml,
213
+ legacy.policyConfig,
214
+ // Layout 1's credentials, which the migration DELETES and which nothing
215
+ // regenerates. They were missing here while the carry that reads them was
216
+ // added, so on the layout-1 leg — the one a user upgrading from the published
217
+ // `latest` actually takes — the cloud token was removed with no copy kept. That
218
+ // is precisely the file this backup exists for, and its absence made the backup
219
+ // most incomplete exactly where it mattered most.
220
+ legacy.cloudCredentials,
221
+ legacy.ingestCredentials,
222
+ ];
223
+
224
+ /**
225
+ * The files that exist right now and would be backed up, each exactly once.
226
+ *
227
+ * ONE function, because there were two: `backupBeforeMigrating` deduped by path
228
+ * and `describePlan` built the same list again without deduping, so
229
+ * `migrate --dry-run` printed `policies-config.json` TWICE while the backup
230
+ * correctly wrote it once. Seen on a real machine, in the one command whose
231
+ * entire job is to state accurately what is about to happen.
232
+ *
233
+ * The duplicate is not a typo waiting to be spotted, it is structural:
234
+ * `legacy.policyConfig()` and `globalPolicyConfigFile()` are the SAME path,
235
+ * because layout 3 put the policy config back exactly where layout 1 kept it. Any
236
+ * caller that walks both lists sees it twice. So the walk lives here once and
237
+ * nobody walks those arrays directly — which is the same "state it once, derive
238
+ * the rest" move `resettablePaths()` makes over `HOME_CLASSES`.
239
+ */
240
+ function filesToBackUp(): string[] {
241
+ const seen = new Set<string>();
242
+ for (const at of [...BACKED_UP, ...BACKED_UP_LEGACY]) {
243
+ const path = at();
244
+ if (existsSync(path)) seen.add(path);
245
+ }
246
+ return [...seen];
247
+ }
211
248
 
212
249
  /** Copy the irreplaceable files aside. Returns the basenames it saved. */
213
250
  export function backupBeforeMigrating(from: number): string[] {
@@ -221,16 +258,7 @@ export function backupBeforeMigrating(from: number): string[] {
221
258
  } catch {
222
259
  return [];
223
260
  }
224
- // DEDUPED BY PATH. `legacy.policyConfig()` and `globalPolicyConfigFile()` are
225
- // the SAME file — layout 3 put the policy config back exactly where layout 1
226
- // kept it — so listing both copied it twice and printed the name twice in the
227
- // "Saved first:" line the user actually reads.
228
- const seen = new Set<string>();
229
- for (const at of [...BACKED_UP, ...BACKED_UP_LEGACY]) {
230
- const source = at();
231
- if (seen.has(source)) continue;
232
- seen.add(source);
233
- if (!existsSync(source)) continue;
261
+ for (const source of filesToBackUp()) {
234
262
  try {
235
263
  copyFileSync(source, resolve(dir, basename(source)));
236
264
  saved.push(basename(source));
@@ -350,10 +378,7 @@ export function describePlan(from: number): string[] {
350
378
  ...chain.map((m) => ` ${m.from} → ${m.to} ${m.describe}`),
351
379
  ``,
352
380
  `These would be copied to ${migrationBackupDir(from)} first:`,
353
- ...[...BACKED_UP, ...BACKED_UP_LEGACY]
354
- .map((at) => at())
355
- .filter((p) => existsSync(p))
356
- .map((p) => ` ${basename(p)}`),
381
+ ...filesToBackUp().map((p) => ` ${basename(p)}`),
357
382
  ``,
358
383
  `Settings, cloud enrolment, policy selection, your own policy files, decision`,
359
384
  `history and undelivered events are carried, not removed — see \`HOME_CLASSES\`.`,
package/src/hooks/tui.ts CHANGED
@@ -68,6 +68,22 @@ export interface MultiChoice<T> {
68
68
  export interface MultiSelectOptions<T> {
69
69
  /** Offer ← to go back a step. The caller must handle `BACK`. */
70
70
  allowBack?: boolean;
71
+ /**
72
+ * Called with what was ticked at the moment ← was pressed.
73
+ *
74
+ * `BACK` is a symbol, so it cannot carry a value, and the selection lives in a
75
+ * local array here rather than on the caller's choice objects — so a caller had
76
+ * no way to learn what a user had toggled before stepping back. The wizard's
77
+ * harness step needs exactly that: without it, deselecting a CLI and pressing ←
78
+ * discards the deselection, the step is redrawn from the detected defaults, and
79
+ * confirming re-enables hook installation for a CLI the user explicitly turned
80
+ * off.
81
+ *
82
+ * OPTIONAL and additive rather than a change to the return type: `BACK` is
83
+ * shared with `selectOne` and every other caller, and widening that contract to
84
+ * fix one step's state would be a much larger surface than the bug.
85
+ */
86
+ onBack?: (checkedNow: T[]) => void;
71
87
  message: string;
72
88
  choices: MultiChoice<T>[];
73
89
  minSelected?: number;
@@ -429,6 +445,8 @@ interface PromptSpec<R> {
429
445
  warnLine?: () => string | null;
430
446
  /** When set, ← resolves `BACK` so the caller can step backwards. */
431
447
  allowBack?: boolean;
448
+ /** Called just before ← resolves, so a caller can keep in-progress state. */
449
+ onBack?: () => void;
432
450
  footer: string;
433
451
  /** Handle non-navigation keys. `{done}` finishes, `"redraw"` repaints. */
434
452
  onKey: (key: readline.Key, cursor: number) => { done: R } | "redraw" | undefined;
@@ -520,6 +538,11 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
520
538
  } else if (key.name === "left" && p.allowBack) {
521
539
  // Only when the caller opted in. A prompt with nowhere to go back TO
522
540
  // must not appear to offer it.
541
+ //
542
+ // Reported BEFORE finishing, because `finish` collapses the prompt and
543
+ // resolves — after that the selection is gone and the caller is already
544
+ // running.
545
+ p.onBack?.();
523
546
  finish(BACK as unknown as never);
524
547
  } else if (key.name === "up") {
525
548
  cursor = cursor > 0 ? cursor - 1 : choices.length - 1;
@@ -648,6 +671,11 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null
648
671
  },
649
672
  warnLine: () => (warn ? c.warn(`Select at least ${minSelected}.`) : null),
650
673
  allowBack: opts.allowBack,
674
+ // Reads the SAME `checked` array the prompt is driving, so what the caller
675
+ // learns is exactly what was on screen when ← was pressed.
676
+ onBack: opts.onBack
677
+ ? () => opts.onBack?.(choices.filter((_, i) => checked[i]).map((ch) => ch.value))
678
+ : undefined,
651
679
  footer:
652
680
  opts.hint ??
653
681
  (opts.allowBack