failproofai 1.0.8 → 1.0.9-beta.1

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 (191) 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 +4 -4
  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 +6 -6
  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/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  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 +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  32. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/index.html +1 -1
  36. package/.next/standalone/.next/server/app/index.rsc +15 -15
  37. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  38. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  39. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  40. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  46. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  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/build-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  64. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
  68. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03m2iw6._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12e7nhs._.js → [root-of-the-server]__1-bm5zf._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/_11ilmst._.js +23 -0
  90. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +3 -3
  91. package/.next/standalone/.next/server/chunks/ssr/{_0-vcssj._.js → _1jatlmf._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +3 -3
  94. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +1 -1
  102. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  103. package/.next/standalone/.next/server/pages/404.html +1 -1
  104. package/.next/standalone/.next/server/pages/500.html +1 -1
  105. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  106. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  107. package/.next/standalone/.next/static/chunks/{06jxjnw8bfczc.js → 0542cuk_va8g1.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/{0wbjy0zo-m7is.js → 08ckontevw-0w.js} +1 -1
  109. package/.next/standalone/.next/static/chunks/0pefjgvu4x-sx.js +1 -0
  110. package/.next/standalone/.next/static/chunks/{21uv-uusw329x.js → 1-d48e9k-7e1a.js} +1 -1
  111. package/.next/standalone/.next/static/chunks/{0m-9d6yn9hx4j.js → 1r74hmr1wm_sd.js} +1 -1
  112. package/.next/standalone/.next/static/chunks/1uec0n0lcx5h1.js +1 -0
  113. package/.next/standalone/.next/static/chunks/21-pqc3-l9ak2.js +6 -0
  114. package/.next/standalone/.next/static/chunks/21qlo00jis4w-.js +1 -0
  115. package/.next/standalone/.next/static/chunks/2n00c3zqyloy9.js +1 -0
  116. package/.next/standalone/.next/static/chunks/{0bhidk90e-07f.css → 3_e_wg5_oay87.css} +1 -1
  117. package/.next/standalone/.next/static/chunks/{3c3qbmosdjl6w.js → 3tv107_for_n7.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{2qdpj67x6ifk_.js → 3vp54x0dyrrm6.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{turbopack-38nzuvp95_2rv.js → turbopack-11dn2pegmg9_9.js} +1 -1
  120. package/.next/standalone/app/actions/get-jev-config.ts +2 -2
  121. package/.next/standalone/app/actions/update-jev-config.ts +12 -9
  122. package/.next/standalone/app/components/jev-notices.tsx +10 -10
  123. package/.next/standalone/app/settings/jev-panel.tsx +11 -11
  124. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +2 -0
  125. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +26 -0
  126. package/.next/standalone/fp-cloud-cli/tests/test_output.py +27 -0
  127. package/.next/standalone/fp-cloud-cli/uv.lock +3 -3
  128. package/.next/standalone/hermes-plugin/README.md +18 -8
  129. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  130. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  131. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  132. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  133. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  134. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  135. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  136. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  137. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  138. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  139. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  140. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  141. package/.next/standalone/node_modules/next/package.json +10 -10
  142. package/.next/standalone/package.json +12 -12
  143. package/.next/standalone/sdk/python/uv.lock +18 -18
  144. package/.next/standalone/server.js +1 -1
  145. package/bin/failproofai.mjs +33 -7
  146. package/dist/cli.mjs +1385 -1158
  147. package/dist/worker.mjs +59 -375
  148. package/hermes-plugin/README.md +18 -8
  149. package/package.json +12 -12
  150. package/scripts/build-policy-pack.mjs +6 -9
  151. package/src/hooks/cloud-connection.ts +12 -6
  152. package/src/hooks/custom-hooks-loader.ts +1 -1
  153. package/src/hooks/effective-reviewers.ts +50 -43
  154. package/src/hooks/handler.ts +25 -16
  155. package/src/hooks/hermes-update.ts +74 -0
  156. package/src/hooks/hook-activity-store.ts +3 -3
  157. package/src/hooks/integrations.ts +472 -68
  158. package/src/hooks/jev-activity.ts +8 -3
  159. package/src/hooks/jev-cli.ts +17 -17
  160. package/src/hooks/jev-cloud-connection.ts +4 -4
  161. package/src/hooks/manager.ts +3 -2
  162. package/src/hooks/pack-cli.ts +24 -23
  163. package/src/hooks/pack-manifest.ts +25 -2
  164. package/src/hooks/pack-store.ts +2 -2
  165. package/src/hooks/policy-authority.ts +51 -32
  166. package/src/hooks/policy-evaluator.ts +5 -5
  167. package/src/hooks/policy-registry.ts +5 -7
  168. package/src/hooks/policy-reviewability.ts +30 -12
  169. package/src/hooks/policy-types.ts +4 -4
  170. package/src/hooks/safe-config-write.ts +119 -0
  171. package/src/hooks/semantic/combine.ts +13 -13
  172. package/src/hooks/semantic/evaluator.ts +5 -5
  173. package/src/hooks/semantic/jev-client.ts +1 -1
  174. package/src/hooks/semantic/jev-config.ts +21 -12
  175. package/src/hooks/semantic/jev-review.ts +3 -3
  176. package/src/hooks/semantic/jev-stats.ts +17 -17
  177. package/src/hooks/semantic/pack-policies.ts +48 -48
  178. package/src/hooks/semantic/policies.ts +13 -425
  179. package/src/hooks/semantic/precondition-names.ts +1 -1
  180. package/src/hooks/semantic/preconditions.ts +1 -1
  181. package/src/hooks/semantic/types.ts +1 -1
  182. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13t1zkw._.js +0 -3
  183. package/.next/standalone/.next/server/chunks/ssr/_13kfn90._.js +0 -23
  184. package/.next/standalone/.next/static/chunks/06dnzbolj00mc.js +0 -6
  185. package/.next/standalone/.next/static/chunks/16f3fa-lx38hk.js +0 -1
  186. package/.next/standalone/.next/static/chunks/2g2tki08kdhie.js +0 -1
  187. package/.next/standalone/.next/static/chunks/3-nbtkhg9y-1j.js +0 -1
  188. package/.next/standalone/.next/static/chunks/355km0ihuqo1p.js +0 -1
  189. /package/.next/standalone/.next/static/{5yKM8NoTOpUI0MoQl8eo5 → ozuWoyIwOTqeUUjKqOKiH}/_buildManifest.js +0 -0
  190. /package/.next/standalone/.next/static/{5yKM8NoTOpUI0MoQl8eo5 → ozuWoyIwOTqeUUjKqOKiH}/_clientMiddlewareManifest.js +0 -0
  191. /package/.next/standalone/.next/static/{5yKM8NoTOpUI0MoQl8eo5 → ozuWoyIwOTqeUUjKqOKiH}/_ssgManifest.js +0 -0
@@ -14,14 +14,18 @@ import {
14
14
  existsSync,
15
15
  lstatSync,
16
16
  mkdirSync,
17
+ readlinkSync,
18
+ realpathSync,
17
19
  renameSync,
18
20
  rmSync,
21
+ symlinkSync,
19
22
  unlinkSync,
20
23
  } from "node:fs";
21
- import { resolve, dirname } from "node:path";
24
+ import { resolve, dirname, basename } from "node:path";
22
25
  import { fileURLToPath } from "node:url";
23
26
  import { homedir } from "node:os";
24
27
  import { parseDocument, type Document } from "yaml";
28
+ import { writeConfigFileAtomic } from "./safe-config-write";
25
29
  import { listHermesProfiles, hermesRoot } from "../../lib/hermes-profiles";
26
30
  import { listOpenClawProfiles } from "../../lib/openclaw-profiles";
27
31
  import {
@@ -62,32 +66,82 @@ import {
62
66
 
63
67
  // ── Generic helpers ─────────────────────────────────────────────────────────
64
68
 
69
+ /**
70
+ * The error every config reader throws for a file it cannot read faithfully.
71
+ *
72
+ * These files belong to the user's agent. A config that exists but does not
73
+ * parse is never treated as empty: writing our entries into an "empty" document
74
+ * would replace every other setting in it, and the agent would come back without
75
+ * its model, keys or channels. Refusing leaves the file exactly as the user has
76
+ * it and says which file and why.
77
+ */
78
+ export class UnreadableAgentConfigError extends Error {
79
+ constructor(readonly path: string, reason: string) {
80
+ super(
81
+ `Refusing to modify ${path}: ${reason}. ` +
82
+ `failproofai never rewrites a config it could not parse — fix or move the file, then retry.`,
83
+ );
84
+ this.name = "UnreadableAgentConfigError";
85
+ }
86
+ }
87
+
65
88
  function readJsonFile(path: string): Record<string, unknown> {
66
89
  if (!existsSync(path)) return {};
67
- const raw = readFileSync(path, "utf8");
68
- return JSON.parse(raw) as Record<string, unknown>;
90
+ let raw: string;
91
+ try {
92
+ raw = readFileSync(path, "utf8");
93
+ } catch (err) {
94
+ throw new UnreadableAgentConfigError(path, `it could not be read (${err instanceof Error ? err.message : String(err)})`);
95
+ }
96
+ if (raw.trim() === "") return {};
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ } catch (err) {
101
+ throw new UnreadableAgentConfigError(path, `it is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
102
+ }
103
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
104
+ throw new UnreadableAgentConfigError(path, "its top level is not a JSON object");
105
+ }
106
+ return parsed as Record<string, unknown>;
69
107
  }
70
108
 
71
109
  function writeJsonFile(path: string, data: Record<string, unknown>): void {
72
- mkdirSync(dirname(path), { recursive: true });
73
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
110
+ writeConfigFileAtomic(path, JSON.stringify(data, null, 2) + "\n");
74
111
  }
75
112
 
76
113
  /** Read a YAML file as a `Document` so writes round-trip the user's other keys +
77
- * comments (a plain parse→stringify would strip comments). Empty / missing /
78
- * corrupt → an empty Document. Used by the Hermes integration, whose config
79
- * lives in `~/.hermes/config.yaml`. */
114
+ * comments (a plain parse→stringify would strip comments). Missing or empty →
115
+ * an empty Document; unreadable or unparseable → {@link UnreadableAgentConfigError},
116
+ * never an empty Document that the next write would put in the user's place.
117
+ * Used by the Hermes integration, whose config lives in `~/.hermes/config.yaml`. */
80
118
  function readYamlDoc(path: string): Document {
119
+ if (!existsSync(path)) return parseDocument("");
120
+ let text: string;
121
+ try {
122
+ text = readFileSync(path, "utf8");
123
+ } catch (err) {
124
+ throw new UnreadableAgentConfigError(path, `it could not be read (${err instanceof Error ? err.message : String(err)})`);
125
+ }
126
+ const doc = parseDocument(text);
127
+ if (doc.errors.length > 0) {
128
+ const first = doc.errors[0]?.message.split("\n")[0] ?? "syntax error";
129
+ throw new UnreadableAgentConfigError(path, `it does not parse as YAML (${first})`);
130
+ }
131
+ return doc;
132
+ }
133
+
134
+ /** {@link readYamlDoc} for read-only callers (status, health): an unreadable file is `null`. */
135
+ function inspectYamlDoc(path: string): Document | null {
81
136
  try {
82
- return existsSync(path) ? parseDocument(readFileSync(path, "utf8")) : parseDocument("");
137
+ return readYamlDoc(path);
83
138
  } catch {
84
- return parseDocument("");
139
+ return null;
85
140
  }
86
141
  }
87
142
 
88
143
  function writeYamlDoc(path: string, doc: Document): void {
89
- mkdirSync(dirname(path), { recursive: true });
90
- writeFileSync(path, doc.toString(), "utf8");
144
+ writeConfigFileAtomic(path, doc.toString());
91
145
  }
92
146
 
93
147
  function isMarkedHook(hook: unknown): boolean {
@@ -1124,7 +1178,7 @@ export const opencode: Integration = {
1124
1178
  // (a) Write the shim file. mkdirSync is recursive so the plugins/ dir
1125
1179
  // is created on first install.
1126
1180
  mkdirSync(dirname(pluginPath), { recursive: true });
1127
- writeFileSync(pluginPath, buildOpenCodePluginShim(binaryPath, effectiveScope), "utf8");
1181
+ writeConfigFileAtomic(pluginPath, buildOpenCodePluginShim(binaryPath, effectiveScope));
1128
1182
 
1129
1183
  // (b) Merge our entry into the plugin array idempotently. Replace any
1130
1184
  // existing failproofai-marked entry; otherwise append.
@@ -1388,9 +1442,13 @@ function makePiProjectRelativeEntry(extPath: string): string {
1388
1442
  // Hermes loads trusted Python plugins from `<HERMES_HOME>/plugins/<name>/`.
1389
1443
  // The shipped plugin registers Hermes-native hooks and talks directly to the
1390
1444
  // local failproofaid socket, avoiding a fresh CLI process per event. Config is
1391
- // YAML and profile-scoped; install copies the plugin into every profile and
1445
+ // YAML and profile-scoped; install LINKS every profile's `plugins/failproofai`
1446
+ // to the package's `hermes-plugin/` (so npm upgrades apply with no reinstall,
1447
+ // like OpenClaw's `plugins.load.paths`), falling back to a marked copy, and
1392
1448
  // adds `failproofai` to `plugins.enabled`. Legacy shell-hook entries are removed
1393
- // during migration so one tool call is never evaluated twice.
1449
+ // during migration so one tool call is never evaluated twice — and because
1450
+ // config.yaml shell hooks never reach Hermes cron jobs: each cron fire builds
1451
+ // its own hook scope, which discovered plugins join and config hooks do not.
1394
1452
 
1395
1453
  /** One hook entry as stored under a `hooks:` event key in config.yaml. */
1396
1454
  interface HermesHookEntry {
@@ -1409,38 +1467,160 @@ interface HermesPluginsConfig {
1409
1467
  const HERMES_PLUGIN_ID = "failproofai";
1410
1468
  const HERMES_PLUGIN_MARKER = ".failproofai-managed";
1411
1469
  const HERMES_PLUGIN_FILES = ["plugin.yaml", "__init__.py", "client.py", "ledger.py"] as const;
1470
+ /** Basename of the plugin directory inside the npm package. */
1471
+ const HERMES_PLUGIN_SOURCE_DIR = "hermes-plugin";
1412
1472
 
1413
1473
  function getHermesPluginSourcePath(): string {
1414
1474
  const fromEnv = process.env.FAILPROOFAI_PACKAGE_ROOT;
1415
- if (fromEnv) return resolve(fromEnv, "hermes-plugin");
1416
- return resolve(fileURLToPath(import.meta.url), "..", "..", "..", "hermes-plugin");
1475
+ if (fromEnv) return resolve(fromEnv, HERMES_PLUGIN_SOURCE_DIR);
1476
+ return findHermesPluginSourcePath(dirname(fileURLToPath(import.meta.url)));
1477
+ }
1478
+
1479
+ /**
1480
+ * The package's `hermes-plugin/`, found by walking up from `startDir`.
1481
+ *
1482
+ * The CLI entry point sets `FAILPROOFAI_PACKAGE_ROOT`, so this is only the
1483
+ * fallback — but a fixed number of parents is right for exactly one layout:
1484
+ * three from `src/hooks/` is the package root, three from the bundled
1485
+ * `dist/cli.mjs` is the package's PARENT. Walking up to the first directory
1486
+ * that really holds the plugin's manifest works from source, from `dist/`, and
1487
+ * from the dashboard's standalone build alike. When none is found the old
1488
+ * answer is returned, so the install still fails loudly on a missing asset.
1489
+ */
1490
+ export function findHermesPluginSourcePath(startDir: string): string {
1491
+ let dir = resolve(startDir);
1492
+ for (let i = 0; i < 8; i++) {
1493
+ const candidate = resolve(dir, HERMES_PLUGIN_SOURCE_DIR);
1494
+ if (existsSync(resolve(candidate, "plugin.yaml"))) return candidate;
1495
+ const parent = dirname(dir);
1496
+ if (parent === dir) break;
1497
+ dir = parent;
1498
+ }
1499
+ return resolve(startDir, "..", "..", HERMES_PLUGIN_SOURCE_DIR);
1417
1500
  }
1418
1501
 
1419
1502
  export function hermesPluginPathForSettings(settingsPath: string): string {
1420
1503
  return resolve(dirname(settingsPath), "plugins", HERMES_PLUGIN_ID);
1421
1504
  }
1422
1505
 
1423
- function hasManagedHermesPlugin(settingsPath: string): boolean {
1424
- const pluginPath = hermesPluginPathForSettings(settingsPath);
1506
+ /** `existsSync` follows symlinks, so a dangling link reads as absent. This does not. */
1507
+ function pathPresent(path: string): boolean {
1425
1508
  try {
1426
- if (lstatSync(pluginPath).isSymbolicLink()) return false;
1427
- if (!existsSync(resolve(pluginPath, HERMES_PLUGIN_MARKER))) return false;
1428
- return HERMES_PLUGIN_FILES.every((file) => existsSync(resolve(pluginPath, file)));
1509
+ lstatSync(path);
1510
+ return true;
1429
1511
  } catch {
1430
1512
  return false;
1431
1513
  }
1432
1514
  }
1433
1515
 
1434
- function hasHermesPluginMarker(settingsPath: string): boolean {
1435
- const pluginPath = hermesPluginPathForSettings(settingsPath);
1516
+ function hasAllHermesPluginFiles(dir: string): boolean {
1517
+ return HERMES_PLUGIN_FILES.every((file) => existsSync(resolve(dir, file)));
1518
+ }
1519
+
1520
+ /**
1521
+ * Whether `dir` is a FailproofAI `hermes-plugin` directory (or was one: a link
1522
+ * whose target vanished during an npm upgrade or uninstall is still ours).
1523
+ * The basename is the package layout; the manifest name confirms it when the
1524
+ * target is readable, so a link to someone else's plugin is never claimed.
1525
+ */
1526
+ function isFailproofaiHermesPluginSource(dir: string): boolean {
1527
+ if (basename(dir) !== HERMES_PLUGIN_SOURCE_DIR) return false;
1528
+ if (!existsSync(dir)) return true;
1436
1529
  try {
1437
- return !lstatSync(pluginPath).isSymbolicLink() && existsSync(resolve(pluginPath, HERMES_PLUGIN_MARKER));
1530
+ const manifest = readFileSync(resolve(dir, "plugin.yaml"), "utf8");
1531
+ return /^name:\s*["']?failproofai["']?\s*$/m.test(manifest);
1438
1532
  } catch {
1439
1533
  return false;
1440
1534
  }
1441
1535
  }
1442
1536
 
1443
- function installHermesPlugin(settingsPath: string): void {
1537
+ function samePath(a: string, b: string): boolean {
1538
+ if (resolve(a) === resolve(b)) return true;
1539
+ try {
1540
+ return realpathSync(a) === realpathSync(b);
1541
+ } catch {
1542
+ return false;
1543
+ }
1544
+ }
1545
+
1546
+ /**
1547
+ * What occupies `<profile>/plugins/failproofai`, and whether FailproofAI owns it.
1548
+ *
1549
+ * • `link` — a symlink into a FailproofAI package's `hermes-plugin/` (the
1550
+ * current install shape: npm upgrades apply with no reinstall).
1551
+ * • `copy` — a copied directory carrying the `.failproofai-managed`
1552
+ * marker (1.0.6–1.0.8 installs, and the fallback where a
1553
+ * symlink cannot be created).
1554
+ * • `foreign` — anything else: an unmarked directory, a file, or a symlink
1555
+ * somewhere that is not a FailproofAI plugin. Never touched.
1556
+ */
1557
+ type HermesPluginState =
1558
+ | { kind: "absent" }
1559
+ | { kind: "link"; target: string; complete: boolean; current: boolean }
1560
+ | { kind: "copy"; complete: boolean }
1561
+ | { kind: "foreign" };
1562
+
1563
+ function hermesPluginState(settingsPath: string): HermesPluginState {
1564
+ const pluginPath = hermesPluginPathForSettings(settingsPath);
1565
+ let isLink: boolean;
1566
+ let isDirectory: boolean;
1567
+ try {
1568
+ const stat = lstatSync(pluginPath);
1569
+ isLink = stat.isSymbolicLink();
1570
+ isDirectory = stat.isDirectory();
1571
+ } catch {
1572
+ return { kind: "absent" };
1573
+ }
1574
+ if (isLink) {
1575
+ let target: string;
1576
+ try {
1577
+ target = resolve(dirname(pluginPath), readlinkSync(pluginPath));
1578
+ } catch {
1579
+ return { kind: "foreign" };
1580
+ }
1581
+ if (!isFailproofaiHermesPluginSource(target)) return { kind: "foreign" };
1582
+ return {
1583
+ kind: "link",
1584
+ target,
1585
+ complete: hasAllHermesPluginFiles(target),
1586
+ current: samePath(target, getHermesPluginSourcePath()),
1587
+ };
1588
+ }
1589
+ if (isDirectory && existsSync(resolve(pluginPath, HERMES_PLUGIN_MARKER))) {
1590
+ return { kind: "copy", complete: hasAllHermesPluginFiles(pluginPath) };
1591
+ }
1592
+ return { kind: "foreign" };
1593
+ }
1594
+
1595
+ function hasManagedHermesPlugin(settingsPath: string): boolean {
1596
+ const state = hermesPluginState(settingsPath);
1597
+ return (state.kind === "link" || state.kind === "copy") && state.complete;
1598
+ }
1599
+
1600
+ /** Test seam: lets a test make symlink creation fail to exercise the copy fallback. */
1601
+ export interface HermesPluginInstallDeps {
1602
+ symlink?: (target: string, path: string) => void;
1603
+ }
1604
+
1605
+ function defaultHermesSymlink(target: string, path: string): void {
1606
+ // A junction needs no privilege on Windows; the type is ignored elsewhere.
1607
+ symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
1608
+ }
1609
+
1610
+ /**
1611
+ * Put the plugin at `<profile>/plugins/failproofai`, preferring a symlink to
1612
+ * the package's own `hermes-plugin/` so every npm upgrade updates it in place —
1613
+ * the same reason OpenClaw's config points into the package. Hermes' plugin
1614
+ * scan uses `Path.is_dir()`, which follows symlinks, so the link is discovered
1615
+ * like a directory. Falls back to a marked copy when a link cannot be created.
1616
+ *
1617
+ * Only a path FailproofAI owns (a marked copy, or a link into a FailproofAI
1618
+ * `hermes-plugin/`) is ever replaced; anything else throws.
1619
+ */
1620
+ export function installHermesPlugin(
1621
+ settingsPath: string,
1622
+ deps: HermesPluginInstallDeps = {},
1623
+ ): "linked" | "copied" | "unchanged" {
1444
1624
  const source = getHermesPluginSourcePath();
1445
1625
  for (const file of HERMES_PLUGIN_FILES) {
1446
1626
  if (!existsSync(resolve(source, file))) {
@@ -1449,50 +1629,93 @@ function installHermesPlugin(settingsPath: string): void {
1449
1629
  }
1450
1630
 
1451
1631
  const destination = hermesPluginPathForSettings(settingsPath);
1452
- if (existsSync(destination)) {
1453
- if (lstatSync(destination).isSymbolicLink() || !existsSync(resolve(destination, HERMES_PLUGIN_MARKER))) {
1454
- throw new Error(
1455
- `Refusing to overwrite an unmanaged Hermes plugin at ${destination}. ` +
1456
- `Move it aside or install FailproofAI under a different Hermes profile.`,
1457
- );
1458
- }
1632
+ const existing = hermesPluginState(settingsPath);
1633
+ if (existing.kind === "foreign") {
1634
+ throw new Error(
1635
+ `Refusing to overwrite an unmanaged Hermes plugin at ${destination}. ` +
1636
+ `Move it aside or install FailproofAI under a different Hermes profile.`,
1637
+ );
1459
1638
  }
1639
+ if (existing.kind === "link" && existing.current) return "unchanged";
1460
1640
 
1461
1641
  mkdirSync(dirname(destination), { recursive: true });
1462
1642
  const suffix = `${process.pid}-${Date.now()}`;
1463
1643
  const temporary = `${destination}.install-${suffix}`;
1464
- const backup = `${destination}.backup-${suffix}`;
1465
- let movedExisting = false;
1644
+
1645
+ let linked = false;
1466
1646
  try {
1647
+ (deps.symlink ?? defaultHermesSymlink)(source, temporary);
1648
+ linked = true;
1649
+ } catch {
1650
+ rmSync(temporary, { recursive: true, force: true });
1651
+ }
1652
+
1653
+ if (!linked) {
1467
1654
  mkdirSync(temporary, { recursive: true });
1468
- for (const file of HERMES_PLUGIN_FILES) {
1469
- cpSync(resolve(source, file), resolve(temporary, file));
1655
+ try {
1656
+ for (const file of HERMES_PLUGIN_FILES) {
1657
+ cpSync(resolve(source, file), resolve(temporary, file));
1658
+ }
1659
+ writeFileSync(
1660
+ resolve(temporary, HERMES_PLUGIN_MARKER),
1661
+ "Managed by failproofai. Remove with `failproofai policies --uninstall --cli hermes`.\n",
1662
+ { encoding: "utf8", mode: 0o600 },
1663
+ );
1664
+ } catch (err) {
1665
+ rmSync(temporary, { recursive: true, force: true });
1666
+ throw err;
1470
1667
  }
1471
- writeFileSync(
1472
- resolve(temporary, HERMES_PLUGIN_MARKER),
1473
- "Managed by failproofai. Remove with `failproofai policies --uninstall --cli hermes`.\n",
1474
- { encoding: "utf8", mode: 0o600 },
1475
- );
1476
- if (existsSync(destination)) {
1668
+ }
1669
+
1670
+ // A link replacing a link is one atomic rename. Anything replacing a
1671
+ // directory (or a link replaced by a directory) cannot rename over it, so
1672
+ // the old entry is moved aside first and restored if the swap fails.
1673
+ if (linked && (existing.kind === "absent" || existing.kind === "link")) {
1674
+ try {
1675
+ renameSync(temporary, destination);
1676
+ return "linked";
1677
+ } catch (err) {
1678
+ // Windows cannot always rename a junction over another junction; the
1679
+ // backup swap below moves the old link aside first, so try that before
1680
+ // giving up. With nothing there to move aside, the rename is the error.
1681
+ if (existing.kind !== "link") {
1682
+ rmSync(temporary, { force: true });
1683
+ throw err;
1684
+ }
1685
+ }
1686
+ }
1687
+
1688
+ const backup = `${destination}.backup-${suffix}`;
1689
+ let movedExisting = false;
1690
+ try {
1691
+ if (pathPresent(destination)) {
1477
1692
  renameSync(destination, backup);
1478
1693
  movedExisting = true;
1479
1694
  }
1480
1695
  renameSync(temporary, destination);
1696
+ // fs.rm never follows a symlink, so a backed-up link removes only itself.
1481
1697
  if (movedExisting) rmSync(backup, { recursive: true, force: true });
1482
1698
  } catch (err) {
1483
1699
  rmSync(temporary, { recursive: true, force: true });
1484
- if (movedExisting && !existsSync(destination) && existsSync(backup)) {
1700
+ if (movedExisting && !pathPresent(destination) && pathPresent(backup)) {
1485
1701
  renameSync(backup, destination);
1486
1702
  }
1487
1703
  throw err;
1488
1704
  }
1705
+ return linked ? "linked" : "copied";
1489
1706
  }
1490
1707
 
1491
1708
  function removeManagedHermesPlugin(settingsPath: string): boolean {
1492
1709
  const pluginPath = hermesPluginPathForSettings(settingsPath);
1710
+ const state = hermesPluginState(settingsPath);
1711
+ if (state.kind === "link") {
1712
+ // Only the link goes; the package directory it points at is npm's.
1713
+ unlinkSync(pluginPath);
1714
+ return true;
1715
+ }
1493
1716
  // The marker is the ownership boundary. Remove even an incomplete managed
1494
- // install so an interrupted copy can always be repaired or uninstalled.
1495
- if (!hasHermesPluginMarker(settingsPath)) return false;
1717
+ // copy so an interrupted install can always be repaired or uninstalled.
1718
+ if (state.kind !== "copy") return false;
1496
1719
  rmSync(pluginPath, { recursive: true, force: true });
1497
1720
  return true;
1498
1721
  }
@@ -1527,11 +1750,13 @@ function removeLegacyHermesHooks(doc: Document): number {
1527
1750
  function hermesConfigState(settingsPath: string): {
1528
1751
  pluginEnabled: boolean;
1529
1752
  legacyShellHookPresent: boolean;
1753
+ unreadable?: boolean;
1530
1754
  } {
1531
1755
  if (!existsSync(settingsPath)) {
1532
1756
  return { pluginEnabled: false, legacyShellHookPresent: false };
1533
1757
  }
1534
- const doc = readYamlDoc(settingsPath);
1758
+ const doc = inspectYamlDoc(settingsPath);
1759
+ if (!doc) return { pluginEnabled: false, legacyShellHookPresent: false, unreadable: true };
1535
1760
  const js = (doc.toJS() ?? {}) as {
1536
1761
  hooks?: Record<string, HermesHookEntry[]>;
1537
1762
  plugins?: HermesPluginsConfig;
@@ -1549,13 +1774,188 @@ function hermesConfigState(settingsPath: string): {
1549
1774
  };
1550
1775
  }
1551
1776
 
1777
+ /**
1778
+ * Enable the native plugin in one profile's config and drop FailproofAI's
1779
+ * legacy shell hooks. A native plugin and the legacy shell bridge running
1780
+ * together would evaluate every event twice. Only failproofai-owned shell
1781
+ * entries are removed; unrelated operator hooks remain intact.
1782
+ */
1783
+ function enableHermesPluginInDoc(doc: Document): void {
1784
+ removeLegacyHermesHooks(doc);
1785
+
1786
+ const js = (doc.toJS() ?? {}) as { plugins?: HermesPluginsConfig };
1787
+ const plugins: HermesPluginsConfig =
1788
+ js.plugins && typeof js.plugins === "object" ? js.plugins : {};
1789
+ const enabled = Array.isArray(plugins.enabled) ? [...plugins.enabled] : [];
1790
+ if (!enabled.includes(HERMES_PLUGIN_ID)) enabled.push(HERMES_PLUGIN_ID);
1791
+ plugins.enabled = enabled;
1792
+ if (Array.isArray(plugins.disabled)) {
1793
+ plugins.disabled = plugins.disabled.filter((name) => name !== HERMES_PLUGIN_ID);
1794
+ if (plugins.disabled.length === 0) delete plugins.disabled;
1795
+ }
1796
+ doc.set("plugins", plugins);
1797
+ }
1798
+
1799
+ export type HermesMigrationStatus =
1800
+ /** Brought to the linked-plugin state by this run. */
1801
+ | "migrated"
1802
+ /** Already linked to this package, enabled, and free of shell hooks. */
1803
+ | "current"
1804
+ /** No FailproofAI integration in this profile — left alone. */
1805
+ | "untouched"
1806
+ /** Needs migrating, but the daemon cannot serve the native plugin. Nothing changed. */
1807
+ | "blocked"
1808
+ /** Could not be migrated (unmanaged plugin in the way, unreadable config, I/O error). */
1809
+ | "failed";
1810
+
1811
+ export interface HermesProfileMigration {
1812
+ name: string;
1813
+ home: string;
1814
+ status: HermesMigrationStatus;
1815
+ detail: string;
1816
+ /** Legacy config.yaml shell hooks are still in place after this run. */
1817
+ legacyShellHooksRemain: boolean;
1818
+ }
1819
+
1820
+ /**
1821
+ * The upgrade half `failproofai update` owes Hermes: bring every profile that
1822
+ * already uses FailproofAI (legacy shell hooks, a copied plugin, or a link) to
1823
+ * the linked-plugin state. Profiles with no FailproofAI integration are never
1824
+ * touched — an update must not opt a profile in.
1825
+ *
1826
+ * `daemonSupportsPolicyEvaluation` is asked at most once, and only when some
1827
+ * profile needs changing. When it says no, NOTHING is changed: removing the
1828
+ * shell hooks in favour of a plugin whose daemon cannot answer would fail every
1829
+ * tool call closed (the same gate `policies --install` applies).
1830
+ */
1831
+ export async function migrateHermesProfiles(opts: {
1832
+ daemonSupportsPolicyEvaluation: () => Promise<boolean>;
1833
+ installDeps?: HermesPluginInstallDeps;
1834
+ }): Promise<HermesProfileMigration[]> {
1835
+ const results: HermesProfileMigration[] = [];
1836
+ let daemonOk: boolean | undefined;
1837
+ for (const profile of listHermesProfiles()) {
1838
+ if (!existsSync(profile.home)) continue;
1839
+ const settingsPath = resolve(profile.home, "config.yaml");
1840
+ const pluginPath = hermesPluginPathForSettings(settingsPath);
1841
+ const config = hermesConfigState(settingsPath);
1842
+ const plugin = hermesPluginState(settingsPath);
1843
+ const ours = plugin.kind === "link" || plugin.kind === "copy";
1844
+ const base = { name: profile.name, home: profile.home };
1845
+ const legacy = config.legacyShellHookPresent;
1846
+
1847
+ // A config.yaml that does not parse is never edited. Whether it is ours to
1848
+ // report is read from the raw text: it cannot be parsed to find out.
1849
+ if (config.unreadable) {
1850
+ let raw = "";
1851
+ try {
1852
+ raw = readFileSync(settingsPath, "utf8");
1853
+ } catch {
1854
+ raw = "";
1855
+ }
1856
+ const mentionsUs = ours || raw.includes(FAILPROOFAI_HOOK_MARKER) || /failproofai/.test(raw);
1857
+ results.push(
1858
+ mentionsUs
1859
+ ? {
1860
+ ...base,
1861
+ status: "failed",
1862
+ detail: `${settingsPath} does not parse as YAML; left untouched`,
1863
+ legacyShellHooksRemain: /--hook\s+\S+\s+--cli\s+hermes|__failproofai_hook__/.test(raw),
1864
+ }
1865
+ : { ...base, status: "untouched", detail: "no failproofai integration", legacyShellHooksRemain: false },
1866
+ );
1867
+ continue;
1868
+ }
1869
+
1870
+ if (!legacy && !ours && !config.pluginEnabled) {
1871
+ results.push({ ...base, status: "untouched", detail: "no failproofai integration", legacyShellHooksRemain: false });
1872
+ continue;
1873
+ }
1874
+ if (plugin.kind === "link" && plugin.current && plugin.complete && config.pluginEnabled && !legacy) {
1875
+ results.push({ ...base, status: "current", detail: "already linked", legacyShellHooksRemain: false });
1876
+ continue;
1877
+ }
1878
+ if (plugin.kind === "foreign") {
1879
+ results.push({
1880
+ ...base,
1881
+ status: "failed",
1882
+ detail:
1883
+ `an unmanaged plugin occupies ${pluginPath}` +
1884
+ (legacy ? "; shell hooks left in place (cron jobs are not checked)" : ""),
1885
+ legacyShellHooksRemain: legacy,
1886
+ });
1887
+ continue;
1888
+ }
1889
+ if (existsSync(settingsPath)) {
1890
+ const errors = parseDocument(readFileSync(settingsPath, "utf8")).errors;
1891
+ if (errors.length > 0) {
1892
+ results.push({
1893
+ ...base,
1894
+ status: "failed",
1895
+ detail: `${settingsPath} does not parse as YAML; left untouched`,
1896
+ legacyShellHooksRemain: legacy,
1897
+ });
1898
+ continue;
1899
+ }
1900
+ }
1901
+
1902
+ if (daemonOk === undefined) daemonOk = await opts.daemonSupportsPolicyEvaluation();
1903
+ if (!daemonOk) {
1904
+ results.push({
1905
+ ...base,
1906
+ status: "blocked",
1907
+ detail: legacy
1908
+ ? "daemon lacks native policy evaluation; shell hooks left in place (cron jobs are not checked)"
1909
+ : "daemon lacks native policy evaluation; the plugin here cannot get verdicts",
1910
+ legacyShellHooksRemain: legacy,
1911
+ });
1912
+ continue;
1913
+ }
1914
+
1915
+ try {
1916
+ // Plugin first, config second: the shell hooks are only removed once the
1917
+ // plugin that replaces them is on disk.
1918
+ const mode = installHermesPlugin(settingsPath, opts.installDeps);
1919
+ const doc = readYamlDoc(settingsPath);
1920
+ enableHermesPluginInDoc(doc);
1921
+ writeYamlDoc(settingsPath, doc);
1922
+ const from = legacy
1923
+ ? "shell hooks"
1924
+ : plugin.kind === "copy"
1925
+ ? "copied plugin"
1926
+ : plugin.kind === "link"
1927
+ ? "old plugin link"
1928
+ : "enabled but missing plugin";
1929
+ const to = mode === "copied" ? "plugin copy (symlink not possible here)" : "linked plugin";
1930
+ results.push({ ...base, status: "migrated", detail: `${from} → ${to}`, legacyShellHooksRemain: false });
1931
+ } catch (err) {
1932
+ results.push({
1933
+ ...base,
1934
+ status: "failed",
1935
+ detail: err instanceof Error ? err.message : String(err),
1936
+ legacyShellHooksRemain: hermesConfigState(settingsPath).legacyShellHookPresent,
1937
+ });
1938
+ }
1939
+ }
1940
+ return results;
1941
+ }
1942
+
1552
1943
  export interface HermesProfileHealth {
1553
1944
  name: string;
1554
1945
  home: string;
1555
1946
  settingsPath: string;
1556
1947
  pluginInstalled: boolean;
1948
+ /** How the plugin is installed: a link into the package, a copy, or neither. */
1949
+ pluginMode: "link" | "copy" | null;
1557
1950
  pluginEnabled: boolean;
1558
1951
  legacyShellHookPresent: boolean;
1952
+ /**
1953
+ * Still enforced only by legacy config.yaml shell hooks. Hermes cron jobs run
1954
+ * with none of those, so their tool calls go unchecked.
1955
+ */
1956
+ cronUnchecked: boolean;
1957
+ /** config.yaml exists but does not parse; failproofai will not touch it. */
1958
+ configUnreadable: boolean;
1559
1959
  healthy: boolean;
1560
1960
  }
1561
1961
 
@@ -1564,15 +1964,20 @@ export function hermesProfileHealth(): HermesProfileHealth[] {
1564
1964
  return listHermesProfiles().map((profile) => {
1565
1965
  const settingsPath = resolve(profile.home, "config.yaml");
1566
1966
  const config = hermesConfigState(settingsPath);
1567
- const pluginInstalled = hasManagedHermesPlugin(settingsPath);
1967
+ const plugin = hermesPluginState(settingsPath);
1968
+ const pluginInstalled = (plugin.kind === "link" || plugin.kind === "copy") && plugin.complete;
1969
+ const pluginWorks = pluginInstalled && config.pluginEnabled;
1568
1970
  return {
1569
1971
  name: profile.name,
1570
1972
  home: profile.home,
1571
1973
  settingsPath,
1572
1974
  pluginInstalled,
1975
+ pluginMode: plugin.kind === "link" || plugin.kind === "copy" ? plugin.kind : null,
1573
1976
  pluginEnabled: config.pluginEnabled,
1574
1977
  legacyShellHookPresent: config.legacyShellHookPresent,
1575
- healthy: pluginInstalled && config.pluginEnabled && !config.legacyShellHookPresent,
1978
+ cronUnchecked: config.legacyShellHookPresent && !pluginWorks,
1979
+ configUnreadable: config.unreadable === true,
1980
+ healthy: pluginWorks && !config.legacyShellHookPresent && config.unreadable !== true,
1576
1981
  };
1577
1982
  });
1578
1983
  }
@@ -1582,13 +1987,28 @@ export function hermesProfileStatusRows(): Array<[string, string]> {
1582
1987
  return hermesProfileHealth()
1583
1988
  .filter((profile) => existsSync(profile.home))
1584
1989
  .map((profile) => {
1990
+ const label = "hermes/" + profile.name;
1991
+ if (profile.configUnreadable) {
1992
+ return [label, `UNHEALTHY — ${profile.settingsPath} does not parse; failproofai leaves it untouched until it is fixed`];
1993
+ }
1994
+ if (profile.cronUnchecked) {
1995
+ // Not a cosmetic leftover: the shell-hook integration does not reach
1996
+ // cron jobs at all, so this profile has an unenforced path.
1997
+ return [
1998
+ label,
1999
+ "UNHEALTHY — legacy shell hooks: Hermes cron jobs are not checked. Run `failproofai update`",
2000
+ ];
2001
+ }
1585
2002
  const problems: string[] = [];
1586
2003
  if (!profile.pluginInstalled) problems.push("plugin files missing or incomplete");
1587
2004
  if (!profile.pluginEnabled) problems.push("plugin not enabled");
1588
2005
  if (profile.legacyShellHookPresent) problems.push("legacy shell hook also present");
2006
+ if (problems.length > 0) return [label, "UNHEALTHY — " + problems.join("; ")];
1589
2007
  return [
1590
- "hermes/" + profile.name,
1591
- problems.length === 0 ? "native plugin enabled" : "UNHEALTHY — " + problems.join("; "),
2008
+ label,
2009
+ profile.pluginMode === "copy"
2010
+ ? "native plugin enabled (copied — `failproofai update` links it so upgrades apply)"
2011
+ : "native plugin enabled",
1592
2012
  ];
1593
2013
  });
1594
2014
  }
@@ -1641,23 +2061,7 @@ export const hermes: Integration = {
1641
2061
  isFailproofaiHook: isMarkedHook,
1642
2062
 
1643
2063
  writeHookEntries(settings) {
1644
- const doc = settings as unknown as Document;
1645
- // A native plugin and the legacy shell bridge running together would
1646
- // evaluate every event twice. Remove only failproofai-owned shell entries;
1647
- // unrelated operator hooks remain intact.
1648
- removeLegacyHermesHooks(doc);
1649
-
1650
- const js = (doc.toJS() ?? {}) as { plugins?: HermesPluginsConfig };
1651
- const plugins: HermesPluginsConfig =
1652
- js.plugins && typeof js.plugins === "object" ? js.plugins : {};
1653
- const enabled = Array.isArray(plugins.enabled) ? [...plugins.enabled] : [];
1654
- if (!enabled.includes(HERMES_PLUGIN_ID)) enabled.push(HERMES_PLUGIN_ID);
1655
- plugins.enabled = enabled;
1656
- if (Array.isArray(plugins.disabled)) {
1657
- plugins.disabled = plugins.disabled.filter((name) => name !== HERMES_PLUGIN_ID);
1658
- if (plugins.disabled.length === 0) delete plugins.disabled;
1659
- }
1660
- doc.set("plugins", plugins);
2064
+ enableHermesPluginInDoc(settings as unknown as Document);
1661
2065
  },
1662
2066
 
1663
2067
  removeHooksFromFile(settingsPath) {