fuli-context 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-ByCUor-j.js → AboutPage-BcZeFcHe.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-DkdkZ40Q.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CYAjAb9J.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-CV4F54R_.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-z4u5mXOG.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-BWOfeS6k.js → OverviewPage-Xyfm5xkZ.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-c3w4-h7a.js → PersonalProfilePage-DB9mByB3.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-DdH0oGB3.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-BYIpcIHG.js → ReviewPage-CQYGwFBH.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
  23. package/dist/web/assets/{SettingsPage-2VkbXgZi.js → SettingsPage-BlEZ03Yi.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-BOPQZuva.js → WritingTastePage-DvRt_nmP.js} +1 -1
  25. package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
  26. package/dist/web/assets/{console-BlAhujsH.js → console-tOxOl8Vq.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
  28. package/dist/web/assets/{i18n-Ci3heuiL.js → i18n-CgBHZthI.js} +3 -3
  29. package/dist/web/assets/{identity-4_-v6cB7.js → identity-CFo_Uw1H.js} +1 -1
  30. package/dist/web/assets/index-CJc5ppXH.css +1 -0
  31. package/dist/web/assets/index-yCi3cz2l.js +2 -0
  32. package/dist/web/assets/{model-yG0YqUyZ.js → model-BtnVr7fX.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-twhdZArA.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
  34. package/dist/web/assets/{vue-router-C-wot_Di.js → vue-router-CzHm-y-x.js} +1 -1
  35. package/dist/web/index.html +7 -6
  36. package/docs/employee-agents.md +99 -0
  37. package/docs/project-agent-memory.md +479 -0
  38. package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
  39. package/graph-provider/fuli_graph/model_base.py +5 -0
  40. package/graph-provider/fuli_graph/model_validation.py +32 -0
  41. package/graph-provider/fuli_graph/models.py +26 -170
  42. package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
  43. package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -0
  44. package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
  45. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  46. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  47. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  48. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  49. package/graph-provider/fuli_graph/project_agent_routes.py +50 -1
  50. package/graph-provider/fuli_graph/project_agent_task_models.py +220 -29
  51. package/graph-provider/fuli_graph/runtime.py +37 -1
  52. package/graph-provider/fuli_graph/store.py +6 -0
  53. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  54. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +122 -0
  55. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  56. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  57. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  58. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  60. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  61. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  62. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +756 -164
  63. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +396 -7
  64. package/graph-provider/fuli_graph/store_project_agent_tasks.py +265 -465
  65. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  66. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  67. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  68. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  69. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  70. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  71. package/graph-provider/pyproject.toml +1 -1
  72. package/npm-shrinkwrap.json +8 -8
  73. package/package.json +5 -2
  74. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  75. package/src/adaptive-runtime/executor-pool.js +46 -5
  76. package/src/adaptive-runtime/graph-services.js +10 -3
  77. package/src/adaptive-runtime/lease-client.js +66 -14
  78. package/src/adaptive-runtime/runtime-broker.js +24 -9
  79. package/src/agent-tools/employee-definitions.js +43 -0
  80. package/src/agent-tools/graph-definitions.js +48 -6
  81. package/src/agent-tools/graph-handlers.js +15 -0
  82. package/src/agent-tools/project-agent-definitions.js +35 -4
  83. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  84. package/src/agent-tools.js +6 -2
  85. package/src/agents/codex/lifecycle-hook.js +107 -0
  86. package/src/agents/codex/lifecycle-hooks.js +231 -0
  87. package/src/agents/cursor/lifecycle-hook.js +133 -0
  88. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  89. package/src/agents/lifecycle-message.js +19 -0
  90. package/src/app/agent-request-context.js +23 -0
  91. package/src/cli/command-registry.js +2 -0
  92. package/src/cli/employee-command.js +17 -0
  93. package/src/cli/remote-mcp-command.js +282 -0
  94. package/src/cli/setup-command.js +5 -0
  95. package/src/cli.js +10 -0
  96. package/src/employees/catalog/jefa.json +21 -0
  97. package/src/employees/install-package.js +77 -0
  98. package/src/employees/management-policy.js +88 -0
  99. package/src/employees/manifest.js +53 -0
  100. package/src/employees/package-registry.js +107 -0
  101. package/src/employees/project-selection.js +50 -0
  102. package/src/employees/recruitment.js +199 -0
  103. package/src/employees/service.js +83 -0
  104. package/src/employees/task-entry.js +39 -0
  105. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  106. package/src/graphiti/federated-application.js +78 -35
  107. package/src/graphiti/project-agent-control-plane.js +32 -3
  108. package/src/graphiti/project-agent-mapping.js +175 -4
  109. package/src/graphiti/project-agent-memory.js +115 -0
  110. package/src/graphiti/project-agent-task-entry.js +126 -0
  111. package/src/graphiti/project-agent-workflows.js +49 -14
  112. package/src/graphiti/provider-client.js +78 -4
  113. package/src/http/api-router.js +25 -5
  114. package/src/http/employee-api-router.js +64 -0
  115. package/src/http/error-mapping.js +4 -0
  116. package/src/http/graph-api-router.js +14 -0
  117. package/src/http/request-policy.js +6 -3
  118. package/src/http/static-handler.js +4 -3
  119. package/src/mcp/create-mcp-server.js +98 -37
  120. package/src/mcp/direct-call.js +38 -0
  121. package/src/mcp/instructions.js +8 -7
  122. package/src/mcp/provider-task-context-registry.js +74 -0
  123. package/src/mcp/remote-http-server.js +761 -0
  124. package/src/mcp/session-id.js +29 -3
  125. package/src/mcp/task-context-registry.js +88 -29
  126. package/src/mcp/tool-annotations.js +12 -2
  127. package/src/mcp/tool-result.js +8 -3
  128. package/src/mcp-server.js +20 -8
  129. package/src/native-runtime/runtime.js +58 -8
  130. package/src/server.js +76 -28
  131. package/src/setup/agent-installation-status.js +38 -6
  132. package/src/setup/agents.js +20 -8
  133. package/src/setup/claude-code-config.js +55 -13
  134. package/src/setup/codex-bootstrap.js +11 -10
  135. package/src/setup/codex-config.js +74 -7
  136. package/src/setup/config-backup.js +18 -2
  137. package/src/setup/cursor-config.js +22 -5
  138. package/src/setup/setup.js +30 -22
  139. package/src/storage/json-file.js +11 -3
  140. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +0 -1
  141. package/dist/web/assets/KnowledgePage-vYYXipOi.js +0 -1
  142. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +0 -1
  143. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +0 -1
  144. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +0 -11
  145. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +0 -1
  146. package/dist/web/assets/conversation-launcher-settings-DTYxiLJt.js +0 -1
  147. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  148. package/dist/web/assets/index-C67Q0JY2.js +0 -2
package/src/server.js CHANGED
@@ -51,22 +51,52 @@ export async function createServer(options = {}) {
51
51
  closeApplicationOnShutdown
52
52
  });
53
53
  const application = runtime.application;
54
- const externalKnowledge = options.externalKnowledge ??
55
- application.externalKnowledge ?? null;
56
- const connectedKnowledge = options.connectedKnowledge ??
57
- application.connectedKnowledge ?? null;
58
- const system = options.system ?? (!app
59
- ? createSystemService({
60
- paths: resolveSetupPaths({
61
- dataDir: dirname(localOptions.runtimeConfigPath),
62
- packageRoot: PACKAGE_ROOT
63
- }),
64
- packageRoot: PACKAGE_ROOT,
65
- activePort: port,
66
- activeLan: lanEnabled,
67
- executorAdapters: options.executorAdapters
68
- })
69
- : null);
54
+ let externalKnowledge;
55
+ let connectedKnowledge;
56
+ let system = null;
57
+ let resourcesClosing = null;
58
+ function closeResources() {
59
+ resourcesClosing ??= (async () => {
60
+ const failures = [];
61
+ for (const close of [() => system?.close?.(), () => runtime.close()]) {
62
+ try { await close(); } catch (error) { failures.push(error); }
63
+ }
64
+ if (failures.length === 1) throw failures[0];
65
+ if (failures.length > 1) {
66
+ throw new AggregateError(failures, 'Server resource shutdown failed');
67
+ }
68
+ })();
69
+ return resourcesClosing;
70
+ }
71
+ async function failStartup(error) {
72
+ try {
73
+ await closeResources();
74
+ } catch (cleanupError) {
75
+ throw new AggregateError([error, cleanupError],
76
+ `Server startup failed: ${error.message}; resource shutdown also failed`, { cause: error });
77
+ }
78
+ throw error;
79
+ }
80
+ try {
81
+ externalKnowledge = options.externalKnowledge ?? application.externalKnowledge ?? null;
82
+ connectedKnowledge = options.connectedKnowledge ?? application.connectedKnowledge ?? null;
83
+ system = options.system ?? (!app
84
+ ? createSystemService({
85
+ paths: resolveSetupPaths({
86
+ dataDir: dirname(localOptions.runtimeConfigPath),
87
+ packageRoot: PACKAGE_ROOT
88
+ }),
89
+ packageRoot: PACKAGE_ROOT,
90
+ activePort: port,
91
+ activeLan: lanEnabled,
92
+ executorAdapters: options.executorAdapters
93
+ })
94
+ : null);
95
+ } catch (error) {
96
+ // The owned application can already hold local databases before system
97
+ // construction or configuration validation fails, even without a listener.
98
+ await failStartup(error);
99
+ }
70
100
  for (let attempt = 0; attempt < 20; attempt += 1) {
71
101
  let authority = null;
72
102
  let allowedLanAuthorities = [];
@@ -82,6 +112,12 @@ export async function createServer(options = {}) {
82
112
  lanAuthorities: allowedLanAuthorities,
83
113
  lanAccessToken: lanToken
84
114
  }).catch((error) => {
115
+ if (response.headersSent || response.destroyed) {
116
+ // A post-response lease/storage failure cannot be converted into a second response.
117
+ if (!response.writableEnded) response.destroy();
118
+ console.error('Request failed after its response was committed or disconnected');
119
+ return;
120
+ }
85
121
  const mapped = mapHttpError(error);
86
122
  sendJson(response, mapped.status, mapped.body);
87
123
  });
@@ -90,15 +126,13 @@ export async function createServer(options = {}) {
90
126
  try {
91
127
  await listenServer(server, port, lanEnabled ? '0.0.0.0' : '127.0.0.1');
92
128
  } catch (error) {
93
- runtime.close();
94
- throw error;
129
+ await failStartup(error);
95
130
  }
96
131
  const address = server.address();
97
132
  if (isBlockedPort(address.port)) {
98
133
  await new Promise((resolveClose) => server.close(resolveClose));
99
134
  if (Number(port) === 0) continue;
100
- runtime.close();
101
- throw new Error(`Port ${address.port} is blocked for local fetch`);
135
+ await failStartup(new Error(`Port ${address.port} is blocked for local fetch`));
102
136
  }
103
137
  authority = localServerAuthority(address);
104
138
  allowedLanAuthorities = lanEnabled
@@ -106,24 +140,33 @@ export async function createServer(options = {}) {
106
140
  : [];
107
141
  if (!authority) {
108
142
  await new Promise((resolveClose) => server.close(resolveClose));
109
- runtime.close();
110
- throw new Error('Server did not bind to a valid local authority');
143
+ await failStartup(new Error('Server did not bind to a valid local authority'));
111
144
  }
112
145
 
146
+ let closing = null;
147
+ const close = () => {
148
+ closing ??= new Promise((resolve, reject) => {
149
+ server.close((error) => error && error.code !== 'ERR_SERVER_NOT_RUNNING'
150
+ ? reject(error) : resolve());
151
+ }).then(closeResources);
152
+ return closing;
153
+ };
113
154
  server.once('close', () => {
114
- system?.close?.();
115
- runtime.close();
155
+ // Legacy callers may close the raw HTTP server; still consume/report asynchronous failures.
156
+ void closeResources().catch((error) => {
157
+ if (!closing) console.error(`Server shutdown failed: ${error.message}`);
158
+ });
116
159
  });
117
160
  return {
118
161
  server,
162
+ close,
119
163
  url: `http://${authority}`,
120
164
  lanUrls: lanEnabled ? lanConsoleUrls(lanAddresses, address.port) : [],
121
165
  app: application
122
166
  };
123
167
  }
124
168
 
125
- runtime.close();
126
- throw new Error('Could not find a fetchable local port');
169
+ await failStartup(new Error('Could not find a fetchable local port'));
127
170
  }
128
171
 
129
172
  function resolveServerGraphOptions(options) {
@@ -178,14 +221,19 @@ async function runProgram(args) {
178
221
  const runtimeOptions = resolveGraphRuntimeOptions(args, process.env);
179
222
  const port = numberOption(args, '--port', DEFAULT_FULI_PORT);
180
223
  const lan = args.includes('--lan');
181
- const { server, url, lanUrls } = await createServer({
224
+ const { server, close: closeRuntime, url, lanUrls } = await createServer({
182
225
  ...runtimeOptions,
183
226
  port,
184
227
  lan,
185
228
  lanAccessToken: lan ? process.env.FULI_LAN_ACCESS_TOKEN : null
186
229
  });
187
230
  const handlers = new Map();
188
- const close = () => server.close();
231
+ const close = () => {
232
+ void closeRuntime().catch((error) => {
233
+ console.error(`Server shutdown failed: ${error.message}`);
234
+ process.exitCode = 1;
235
+ });
236
+ };
189
237
  for (const signal of ['SIGINT', 'SIGTERM']) {
190
238
  const handler = close;
191
239
  handlers.set(signal, handler);
@@ -3,6 +3,13 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { replaceFuliTable } from './codex-config.js';
4
4
  import { isCodexBootstrapCurrent } from './codex-bootstrap.js';
5
5
  import { hasCurrentClaudeCodeHooks } from './claude-code-config.js';
6
+ import {
7
+ hasCodexTomlHookDefinitions,
8
+ hasCurrentCodexLifecycleHooks,
9
+ hasCurrentCodexTomlLifecycleHooks,
10
+ withCodexTomlLifecycleHooks
11
+ } from '../agents/codex/lifecycle-hooks.js';
12
+ import { hasCurrentCursorLifecycleHooks } from '../agents/cursor/lifecycle-hooks.js';
6
13
  import { isSessionSkillCurrent } from './session-skill.js';
7
14
  import { readJsonFile } from '../storage/json-file.js';
8
15
 
@@ -30,7 +37,19 @@ export function inspectAgentInstallations(agents, context, {
30
37
  { fileExists, readText }
31
38
  );
32
39
  const claudeLifecycleCurrent = agent.id !== 'claude-code' ||
33
- hasCurrentClaudeCodeHooks(readJson(agent.settingsPath));
40
+ hasCurrentClaudeCodeHooks(readJson(agent.settingsPath), {
41
+ hookTimeoutSec: context.hookTimeoutSec ?? 30
42
+ });
43
+ const codexConfig = agent.id === 'codex' && agent.hooksPath
44
+ ? readText(agent.configPath)
45
+ : '';
46
+ const lifecycleCurrent = agent.id === 'codex' && agent.hooksPath
47
+ ? hasCodexTomlHookDefinitions(codexConfig)
48
+ ? hasCurrentCodexTomlLifecycleHooks(codexConfig, { context })
49
+ : hasCurrentCodexLifecycleHooks(readJson(agent.hooksPath), { context })
50
+ : agent.id === 'cursor' && agent.hooksPath
51
+ ? hasCurrentCursorLifecycleHooks(readJson(agent.hooksPath), context)
52
+ : claudeLifecycleCurrent;
34
53
 
35
54
  let integrationStatus = 'not_connected';
36
55
  if (
@@ -38,6 +57,7 @@ export function inspectAgentInstallations(agents, context, {
38
57
  && skillsCurrent
39
58
  && codexBootstrapCurrent
40
59
  && claudeLifecycleCurrent
60
+ && lifecycleCurrent
41
61
  ) {
42
62
  integrationStatus = 'connected';
43
63
  }
@@ -52,8 +72,8 @@ export function inspectAgentInstallations(agents, context, {
52
72
  ...(agent.id === 'codex'
53
73
  ? { bootstrap: codexBootstrapCurrent ? 'current' : 'outdated' }
54
74
  : {}),
55
- ...(agent.id === 'claude-code'
56
- ? { lifecycleHooks: claudeLifecycleCurrent ? 'current' : 'outdated' }
75
+ ...(agent.id === 'claude-code' || agent.hooksPath
76
+ ? { lifecycleHooks: lifecycleCurrent ? 'current' : 'outdated' }
57
77
  : {})
58
78
  }
59
79
  };
@@ -75,9 +95,13 @@ function inspectRegistration(agent, context, readers) {
75
95
  if (agent.id === 'codex') {
76
96
  const current = readers.readText(agent.configPath);
77
97
  const present = /^\s*\[mcp_servers\.(?:fuli|"fuli"|'fuli')]/m.test(current);
98
+ const expectedConfig = replaceFuliTable(current, expected);
99
+ const normalizedExpected = hasCodexTomlHookDefinitions(current)
100
+ ? withCodexTomlLifecycleHooks(expectedConfig, { context })
101
+ : expectedConfig;
78
102
  return {
79
103
  present,
80
- current: present && normalizeText(replaceFuliTable(current, expected)) ===
104
+ current: present && normalizeText(normalizedExpected) ===
81
105
  normalizeText(current)
82
106
  };
83
107
  }
@@ -108,9 +132,17 @@ function normalizeText(value) {
108
132
  }
109
133
 
110
134
  function defaultReadText(path) {
111
- return existsSync(path) ? readFileSync(path, 'utf8') : '';
135
+ try {
136
+ return existsSync(path) ? readFileSync(path, 'utf8') : '';
137
+ } catch {
138
+ return '';
139
+ }
112
140
  }
113
141
 
114
142
  function defaultReadJson(path) {
115
- return readJsonFile(path, {});
143
+ try {
144
+ return readJsonFile(path, {});
145
+ } catch {
146
+ return {};
147
+ }
116
148
  }
@@ -20,6 +20,8 @@ export function discoverAgents({
20
20
  } = {}) {
21
21
  const pathApi = platform === 'win32' ? path.win32 : path.posix;
22
22
  const codexHome = nonEmpty(env.CODEX_HOME) ?? pathApi.join(homeDir, '.codex');
23
+ const claudeHome = pathApi.join(homeDir, '.claude');
24
+ const cursorHome = pathApi.join(homeDir, '.cursor');
23
25
  const skillName = 'capturing-session-knowledge';
24
26
  const projectSkillName = 'grilling-project';
25
27
  return [
@@ -28,6 +30,7 @@ export function discoverAgents({
28
30
  label: 'Codex',
29
31
  command: 'codex',
30
32
  configPath: pathApi.join(codexHome, 'config.toml'),
33
+ hooksPath: pathApi.join(codexHome, 'hooks.json'),
31
34
  globalInstructionsPath: pathApi.join(codexHome, 'AGENTS.md'),
32
35
  globalInstructionsOverridePath: pathApi.join(codexHome, 'AGENTS.override.md'),
33
36
  skillPath: pathApi.join(homeDir, '.agents', 'skills', skillName),
@@ -39,19 +42,28 @@ export function discoverAgents({
39
42
  label: 'Claude Code',
40
43
  command: 'claude',
41
44
  configPath: pathApi.join(homeDir, '.claude.json'),
42
- settingsPath: pathApi.join(homeDir, '.claude', 'settings.json'),
43
- skillPath: pathApi.join(homeDir, '.claude', 'skills', skillName),
44
- projectSkillPath: pathApi.join(homeDir, '.claude', 'skills', projectSkillName),
45
- available: Boolean(commandExists('claude'))
45
+ settingsPath: pathApi.join(claudeHome, 'settings.json'),
46
+ skillPath: pathApi.join(claudeHome, 'skills', skillName),
47
+ projectSkillPath: pathApi.join(claudeHome, 'skills', projectSkillName),
48
+ available: Boolean(
49
+ commandExists('claude') ||
50
+ fileExists(pathApi.join(homeDir, '.claude.json')) ||
51
+ fileExists(claudeHome)
52
+ )
46
53
  },
47
54
  {
48
55
  id: 'cursor',
49
56
  label: 'Cursor',
50
57
  command: 'cursor',
51
- configPath: pathApi.join(homeDir, '.cursor', 'mcp.json'),
52
- skillPath: pathApi.join(homeDir, '.cursor', 'skills', skillName),
53
- projectSkillPath: pathApi.join(homeDir, '.cursor', 'skills', projectSkillName),
54
- available: Boolean(commandExists('cursor'))
58
+ configPath: pathApi.join(cursorHome, 'mcp.json'),
59
+ hooksPath: pathApi.join(cursorHome, 'hooks.json'),
60
+ skillPath: pathApi.join(cursorHome, 'skills', skillName),
61
+ projectSkillPath: pathApi.join(cursorHome, 'skills', projectSkillName),
62
+ available: Boolean(
63
+ commandExists('cursor') ||
64
+ fileExists(pathApi.join(cursorHome, 'mcp.json')) ||
65
+ fileExists(cursorHome)
66
+ )
55
67
  }
56
68
  ].map((agent) => adaptAgentForReviewSkill(agent, { homeDir, pathApi }));
57
69
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
 
3
4
  import {
4
5
  readJsonFile,
@@ -10,6 +11,12 @@ const MANAGED_HOOK_TOOLS = new Set([
10
11
  'begin_task_context',
11
12
  'verify_task_checkpoint'
12
13
  ]);
14
+ const MANAGED_PERMISSION_RULES = Object.freeze([
15
+ // These allow model-initiated fallback restore/final checkpoint calls. Claude
16
+ // Code executes MCP lifecycle hooks separately from model tool permissions.
17
+ 'mcp__fuli__get_collaboration_preferences',
18
+ 'mcp__fuli__checkpoint_task_knowledge'
19
+ ]);
13
20
 
14
21
  export function connectClaudeCode(agent, context, {
15
22
  readConfig = readJsonFile,
@@ -21,6 +28,7 @@ export function connectClaudeCode(agent, context, {
21
28
  assertObject(settings, 'Claude Code settings');
22
29
  assertObject(current.mcpServers ?? {}, 'Claude Code mcpServers');
23
30
  assertHookSettings(settings.hooks ?? {});
31
+ assertPermissionSettings(settings.permissions ?? {});
24
32
 
25
33
  const next = {
26
34
  ...current,
@@ -81,7 +89,8 @@ export function disconnectClaudeCode(agent, {
81
89
  const settings = readConfig(agent.settingsPath, {});
82
90
  assertObject(settings, 'Claude Code settings');
83
91
  assertHookSettings(settings.hooks ?? {});
84
- const nextSettings = withoutManagedHooks(settings);
92
+ assertPermissionSettings(settings.permissions ?? {});
93
+ const nextSettings = withoutManagedSettings(settings);
85
94
  if (!sameJson(settings, nextSettings)) {
86
95
  writeConfig(agent.settingsPath, nextSettings);
87
96
  changed = true;
@@ -95,26 +104,23 @@ export function disconnectClaudeCode(agent, {
95
104
  };
96
105
  }
97
106
 
98
- export function hasCurrentClaudeCodeHooks(settings) {
107
+ export function hasCurrentClaudeCodeHooks(settings, { hookTimeoutSec = 30 } = {}) {
99
108
  try {
100
109
  assertObject(settings, 'Claude Code settings');
101
110
  assertHookSettings(settings.hooks ?? {});
111
+ assertPermissionSettings(settings.permissions ?? {});
112
+ return sameJson(
113
+ withManagedHooks(settings, { hookTimeoutSec }),
114
+ settings
115
+ );
102
116
  } catch {
103
117
  return false;
104
118
  }
105
- return [
106
- ['UserPromptSubmit', 'begin_task_context'],
107
- ['Stop', 'verify_task_checkpoint']
108
- ].every(([event, tool]) => (
109
- (settings.hooks?.[event] ?? []).some((group) =>
110
- group.hooks.some((hook) => isManagedHook(hook, tool))
111
- )
112
- ));
113
119
  }
114
120
 
115
121
  function withManagedHooks(settings, { hookTimeoutSec = 30 } = {}) {
116
122
  const normalizedHookTimeoutSec = positiveHookTimeout(hookTimeoutSec);
117
- const clean = withoutManagedHooks(settings);
123
+ const clean = withoutManagedSettings(settings);
118
124
  const hooks = { ...(clean.hooks ?? {}) };
119
125
  hooks.UserPromptSubmit = [
120
126
  ...(hooks.UserPromptSubmit ?? []),
@@ -133,7 +139,32 @@ function withManagedHooks(settings, { hookTimeoutSec = 30 } = {}) {
133
139
  normalizedHookTimeoutSec
134
140
  )
135
141
  ];
136
- return { ...clean, hooks };
142
+ return withManagedPermissions({ ...clean, hooks });
143
+ }
144
+
145
+ function withManagedPermissions(settings) {
146
+ const permissions = { ...(settings.permissions ?? {}) };
147
+ permissions.allow = [
148
+ ...(permissions.allow ?? []),
149
+ ...MANAGED_PERMISSION_RULES
150
+ ];
151
+ return { ...settings, permissions };
152
+ }
153
+
154
+ function withoutManagedSettings(settings) {
155
+ return withoutManagedPermissions(withoutManagedHooks(settings));
156
+ }
157
+
158
+ function withoutManagedPermissions(settings) {
159
+ if (!settings.permissions) return settings;
160
+ const permissions = { ...settings.permissions };
161
+ const remainingAllow = (permissions.allow ?? [])
162
+ .filter(rule => !MANAGED_PERMISSION_RULES.includes(rule));
163
+ if (remainingAllow.length) permissions.allow = remainingAllow;
164
+ else delete permissions.allow;
165
+ if (Object.keys(permissions).length) return { ...settings, permissions };
166
+ const { permissions: _removed, ...rest } = settings;
167
+ return rest;
137
168
  }
138
169
 
139
170
  function withoutManagedHooks(settings) {
@@ -197,6 +228,17 @@ function assertHookSettings(value) {
197
228
  }
198
229
  }
199
230
 
231
+ function assertPermissionSettings(value) {
232
+ assertObject(value, 'Claude Code permissions');
233
+ for (const field of ['allow', 'ask', 'deny']) {
234
+ if (value[field] !== undefined && (
235
+ !Array.isArray(value[field]) || !value[field].every(rule => typeof rule === 'string')
236
+ )) {
237
+ throw new TypeError(`Claude Code permissions ${field} must be an array of strings`);
238
+ }
239
+ }
240
+ }
241
+
200
242
  function assertObject(value, label) {
201
243
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
202
244
  throw new TypeError(`${label} must be a JSON object`);
@@ -204,5 +246,5 @@ function assertObject(value, label) {
204
246
  }
205
247
 
206
248
  function sameJson(left, right) {
207
- return JSON.stringify(left) === JSON.stringify(right);
249
+ return isDeepStrictEqual(left, right);
208
250
  }
@@ -15,17 +15,18 @@ const BLOCK_START = '<!-- BEGIN FULI MANAGED INSTRUCTIONS -->';
15
15
  const BLOCK_END = '<!-- END FULI MANAGED INSTRUCTIONS -->';
16
16
 
17
17
  const GLOBAL_INSTRUCTIONS = `${BLOCK_START}
18
- At start of every user task, before any other tool or answer, call exactly
18
+ At start of every user task, use \`begin_task_context\` hook context if supplied;
19
+ do not repeat the fallback. Otherwise before any other tool or answer, call exactly
19
20
  \`get_collaboration_preferences\` with \`projectPath\`=current working directory,
20
- \`taskPrompt\`=current user request. Never substitute a project action/Fuli tool.
21
- Fuli never stores or returns them; inputs are transient. Apply all returned \`effective_preferences\`.
22
- Before asking user to repeat a stable project fact or method, inspect \`task_knowledge_recall\`.
23
- On miss call \`search_current_project_knowledge\` with focused action/artifact/target/identifier
24
- queries; never use the full request as the only query.
25
- Write tools enforce preferences in actual payload; mentioning later is not compliance.
26
- Resolve relevant \`deferred_conflict\` first; ignore others.
27
- Team: \`coordinate_project_agent_task\`; host \`acquire_runtime_lease\`, start workers, report
28
- execution, \`release_runtime_lease\` in finally.
21
+ \`taskPrompt\`=current user request. Never substitute a project action.
22
+ Fuli never stores or returns them. Apply all returned \`effective_preferences\`.
23
+ Before reasking a stable project fact or method, inspect \`task_knowledge_recall\`.
24
+ On miss: \`search_current_project_knowledge\`; focused action/artifact/target/identifier;
25
+ never use the full request as the only query.
26
+ Write tools: preferences in actual payload; mentioning later is not compliance.
27
+ Resolve relevant \`deferred_conflict\`; ignore others.
28
+ Team: \`coordinate_project_agent_task\`; \`acquire_runtime_lease\`, run/report workers,
29
+ \`release_runtime_lease\` in finally.
29
30
  ${codexReviewCommandBridge()}
30
31
  ${BLOCK_END}`;
31
32
 
@@ -13,14 +13,27 @@ import {
13
13
  installCodexBootstrap,
14
14
  removeCodexBootstrap
15
15
  } from './codex-bootstrap.js';
16
+ import {
17
+ hasCodexTomlHookDefinitions,
18
+ hasCurrentCodexTomlLifecycleHooks,
19
+ installCodexLifecycleHooks,
20
+ removeCodexLifecycleHooks,
21
+ withCodexTomlLifecycleHooks,
22
+ withoutCodexLifecycleHooks,
23
+ withoutCodexTomlLifecycleHooks
24
+ } from '../agents/codex/lifecycle-hooks.js';
25
+ import { readJsonFile, writeJsonFileAtomic } from '../storage/json-file.js';
16
26
 
17
27
  export function connectCodex(agent, context, {
18
28
  readConfig = readText,
19
29
  writeConfig = writeTextAtomic,
20
- installBootstrap = installCodexBootstrap
30
+ readHooks = readJsonFile,
31
+ writeHooks = writeJsonFileAtomic,
32
+ installBootstrap = installCodexBootstrap,
33
+ installHooks = installCodexLifecycleHooks
21
34
  } = {}) {
22
35
  const current = readConfig(agent.configPath);
23
- const next = replaceFuliTable(current, {
36
+ let next = replaceFuliTable(current, {
24
37
  command: context.nodePath,
25
38
  args: [
26
39
  context.mcpServerPath,
@@ -30,28 +43,75 @@ export function connectCodex(agent, context, {
30
43
  'codex'
31
44
  ]
32
45
  });
46
+ const tomlHooksPresent = Boolean(
47
+ agent.hooksPath && hasCodexTomlHookDefinitions(current)
48
+ );
49
+ const currentHooks = agent.hooksPath ? readHooks(agent.hooksPath, {}) : null;
50
+ const remainingHooks = currentHooks === null
51
+ ? null
52
+ : withoutCodexLifecycleHooks(currentHooks);
53
+ if (tomlHooksPresent && hasHookDefinitions(remainingHooks)) {
54
+ throw new Error(
55
+ 'Codex has unrelated hooks in both config.toml and hooks.json; ' +
56
+ 'Fuli cannot safely choose one representation automatically.'
57
+ );
58
+ }
59
+
33
60
  const bootstrap = installBootstrap(agent, context);
61
+ let hooks;
62
+ let hooksJsonWrite = null;
63
+ if (tomlHooksPresent) {
64
+ const withHooks = withCodexTomlLifecycleHooks(next, { context });
65
+ const jsonChanged = JSON.stringify(currentHooks) !== JSON.stringify(remainingHooks);
66
+ const tomlHooksChanged = !hasCurrentCodexTomlLifecycleHooks(current, {
67
+ context
68
+ });
69
+ hooks = {
70
+ changed: tomlHooksChanged || jsonChanged,
71
+ trustReviewRequired: tomlHooksChanged || jsonChanged
72
+ };
73
+ next = withHooks;
74
+ if (jsonChanged) hooksJsonWrite = remainingHooks;
75
+ } else {
76
+ hooks = installHooks(agent, { context });
77
+ }
34
78
  const registrationChanged = next !== current;
35
79
  if (registrationChanged) writeConfig(agent.configPath, next);
80
+ if (hooksJsonWrite !== null) writeHooks(agent.hooksPath, hooksJsonWrite);
36
81
  return {
37
82
  id: agent.id,
38
83
  label: agent.label,
39
84
  status: 'connected',
40
- newTaskRequired: registrationChanged || bootstrap.changed === true
85
+ newTaskRequired: registrationChanged || bootstrap.changed === true || hooks.changed === true,
86
+ ...(hooks.trustReviewRequired === true ? { trustReviewRequired: true } : {}),
87
+ ...(agent.hooksPath ? {
88
+ nextSteps: [hooks.trustReviewRequired === true
89
+ ? 'Review and trust Fuli hooks in the Codex CLI with /hooks ' +
90
+ 'before relying on automatic task entry.'
91
+ : 'Check Fuli hooks in the Codex CLI with /hooks; setup does not verify hook trust.']
92
+ } : {})
41
93
  };
42
94
  }
43
95
 
96
+ function hasHookDefinitions(value) {
97
+ if (!value || Array.isArray(value) || typeof value !== 'object') return false;
98
+ if (!value.hooks || Array.isArray(value.hooks) || typeof value.hooks !== 'object') return false;
99
+ return Object.values(value.hooks).some((groups) => Array.isArray(groups) && groups.length > 0);
100
+ }
101
+
44
102
  export function disconnectCodex(agent, {
45
103
  fileExists = existsSync,
46
104
  readConfig = readText,
47
105
  writeConfig = writeTextAtomic,
48
- removeBootstrap = removeCodexBootstrap
106
+ removeBootstrap = removeCodexBootstrap,
107
+ removeHooks = removeCodexLifecycleHooks
49
108
  } = {}) {
50
109
  const bootstrap = removeBootstrap(agent);
110
+ const hooks = removeHooks(agent);
51
111
  let registrationChanged = false;
52
112
  if (fileExists(agent.configPath)) {
53
113
  const current = readConfig(agent.configPath);
54
- const next = removeFuliTables(current);
114
+ const next = removeFuliTables(withoutCodexTomlLifecycleHooks(current));
55
115
  if (next !== current) {
56
116
  writeConfig(agent.configPath, next);
57
117
  registrationChanged = true;
@@ -60,7 +120,7 @@ export function disconnectCodex(agent, {
60
120
  return {
61
121
  id: agent.id,
62
122
  label: agent.label,
63
- status: registrationChanged || bootstrap.changed ? 'disconnected' : 'not_connected'
123
+ status: registrationChanged || bootstrap.changed || hooks.changed ? 'disconnected' : 'not_connected'
64
124
  };
65
125
  }
66
126
 
@@ -88,7 +148,7 @@ export function removeFuliTables(source) {
88
148
  let removed = false;
89
149
 
90
150
  for (const line of lines) {
91
- const header = /^\s*\[([^\]]+)]\s*(?:#.*)?$/.exec(line)?.[1]?.trim();
151
+ const header = tomlHeaderName(line);
92
152
  if (header) {
93
153
  skipping = isFuliTable(header);
94
154
  if (skipping) removed = true;
@@ -101,6 +161,13 @@ export function removeFuliTables(source) {
101
161
  return kept.length ? `${kept.join('\n')}\n` : '';
102
162
  }
103
163
 
164
+ function tomlHeaderName(line) {
165
+ return (
166
+ /^\s*\[\[([^\]]+)]]\s*(?:#.*)?$/.exec(line)?.[1] ??
167
+ /^\s*\[([^\]]+)]\s*(?:#.*)?$/.exec(line)?.[1]
168
+ )?.trim();
169
+ }
170
+
104
171
  function isFuliTable(header) {
105
172
  return /^mcp_servers\.(?:fuli|"fuli"|'fuli')(?:\.|$)/.test(header);
106
173
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ chmodSync,
2
3
  copyFileSync,
3
4
  existsSync,
4
5
  mkdirSync
@@ -10,16 +11,30 @@ export function backupAgentConfig(agent, {
10
11
  now = () => new Date(),
11
12
  fileExists = existsSync,
12
13
  makeDirectory = mkdirSync,
13
- copyFile = copyFileSync
14
+ copyFile = copyFileSync,
15
+ setMode = chmodSync
14
16
  }) {
15
17
  const sources = [
16
18
  { path: agent.configPath, suffix: '' },
17
19
  ...(agent.settingsPath
18
20
  ? [{ path: agent.settingsPath, suffix: '-settings' }]
21
+ : []),
22
+ ...(agent.hooksPath
23
+ ? [{ path: agent.hooksPath, suffix: '-hooks' }]
24
+ : []),
25
+ ...(agent.globalInstructionsPath
26
+ ? [{ path: agent.globalInstructionsPath, suffix: '-instructions' }]
27
+ : []),
28
+ ...(agent.globalInstructionsOverridePath
29
+ ? [{
30
+ path: agent.globalInstructionsOverridePath,
31
+ suffix: '-instructions-override'
32
+ }]
19
33
  : [])
20
34
  ].filter(({ path }) => path && fileExists(path));
21
35
  if (sources.length === 0) return null;
22
- makeDirectory(backupDir, { recursive: true });
36
+ makeDirectory(backupDir, { recursive: true, mode: 0o700 });
37
+ setMode(backupDir, 0o700);
23
38
  const timestamp = now().toISOString().replaceAll(':', '-').replaceAll('.', '-');
24
39
  const backups = sources.map(({ path, suffix }) => {
25
40
  const extension = extname(path) || '.bak';
@@ -28,6 +43,7 @@ export function backupAgentConfig(agent, {
28
43
  `${agent.id}${suffix}-${timestamp}${extension}`
29
44
  );
30
45
  copyFile(path, backupPath);
46
+ setMode(backupPath, 0o600);
31
47
  return { path, backupPath };
32
48
  });
33
49
  return (