@hiveku-apps/sync 0.3.3 → 0.3.6

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.
package/src/codex.mjs CHANGED
@@ -17,8 +17,11 @@
17
17
  * the OLYMPUS_API_KEY env var). git-ignored.
18
18
  * skill/command knowledge → .agents/skills/<name>/SKILL.md (repo-scoped; Codex
19
19
  * prompts are global-only + deprecated)
20
- * permissions → NO per-repo equivalent (Codex ignores approval/
21
- * sandbox keys in repo config) — stated in AGENTS.md.
20
+ * permissions → the hiveku server's per-tool approvals: a FIXED
21
+ * list of high-risk tools asks first
22
+ * (CODEX_PROMPT_TOOLS below), every other tool runs
23
+ * without asking. The safe-work rules are stated
24
+ * as instructions in AGENTS.md, for every tool.
22
25
  *
23
26
  * This complements the published Hiveku Codex plugin
24
27
  * (github.com/locus2012/hiveku-codex-plugin): the plugin ships the generic
@@ -29,27 +32,94 @@
29
32
  */
30
33
 
31
34
  import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
32
- import { existsSync } from 'node:fs';
35
+ import { existsSync, readFileSync } from 'node:fs';
33
36
  import { homedir } from 'node:os';
34
37
  import { join, resolve, dirname } from 'node:path';
35
38
 
39
+ import { formatKey, readTomlDeclarations } from './toml.mjs';
36
40
  import { loadDotenv, safeSlug } from './util.mjs';
37
41
 
42
+ /**
43
+ * The FIXED list of high-risk Hiveku tools that Codex asks about before
44
+ * running. It is the Hiveku Codex plugin's own prompt list, copied name for
45
+ * name (codex-prompt-tools.json says which release; 246 names at plugin
46
+ * 0.1.16). It includes hiveku_batch, because Codex asks by the name of the tool
47
+ * it calls and a batch carries other tools.
48
+ *
49
+ * It is a list of names, not a category. Every tool not on it runs without
50
+ * asking under the "approve" default, and that includes many deletes and sends
51
+ * (at MCP d4a73ba6d, for example crm_delete_contact, content_delete,
52
+ * assets_delete, survey_send and accounting_invoice_send). That is why
53
+ * AGENTS.md keeps the safe-work rules as instructions for every tool.
54
+ *
55
+ * A listed tool does not always ask either. Codex checks the run mode before
56
+ * it reads a tool's approval_mode (codex-mcp mcp_permission_prompt_is_auto_approved):
57
+ * - approval policy "never" (what `codex exec` sets) with a read-only or
58
+ * workspace-write sandbox: nobody can answer, so a listed call is
59
+ * cancelled and the rest run;
60
+ * - approval policy "never" with no sandbox or full disk write
61
+ * (--dangerously-bypass-approvals-and-sandbox, i.e. --yolo, or
62
+ * danger-full-access in `codex exec`): every prompt is skipped, these
63
+ * included, and every Hiveku tool runs unattended.
64
+ *
65
+ * Why this folder has to write it: the folder's .codex/config.toml defines
66
+ * [mcp_servers.hiveku]. Codex 0.131 then drops the plugin's server of the same
67
+ * name (core/src/config/mod.rs: configured servers win, plugin servers are
68
+ * only `or_insert`ed) and takes each tool's approval mode from the config
69
+ * layers alone (core/src/mcp_tool_call.rs custom_mcp_tool_approval_mode:
70
+ * tools.<tool>.approval_mode, else default_tools_approval_mode; the plugin is
71
+ * read only when no config layer names the server). With only
72
+ * default_tools_approval_mode = "approve" here, every Hiveku tool ran without
73
+ * a prompt, with or without the plugin installed.
74
+ *
75
+ * Adding another prompt list (the unmerged Versions Wave 2 branch has
76
+ * RESTORE_TOOLS, for example): put its names into this one Set, so each tool
77
+ * still gets exactly one table. A second block of tables would define some
78
+ * tables twice (four of Wave 2's five names are already here), and that makes
79
+ * the whole config.toml invalid TOML.
80
+ *
81
+ * The user's own table wins: a listed tool that already has its own
82
+ * [mcp_servers.hiveku.tools.<tool>] table outside the managed block gets no
83
+ * table from us (userToolTables below), and writeCodexScaffold writes nothing
84
+ * when the block would still declare a table the file declares (blockConflict).
85
+ */
86
+ export const CODEX_PROMPT_TOOLS = [
87
+ ...new Set(JSON.parse(readFileSync(new URL('./codex-prompt-tools.json', import.meta.url), 'utf8')).tools),
88
+ ];
89
+
90
+ /** A TOML bare key when the name allows it, a quoted key otherwise. */
91
+ function tomlKey(name) {
92
+ return /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
93
+ }
94
+
38
95
  const AGENTS_BEGIN = '<!-- hiveku:begin -->';
39
96
  const AGENTS_END = '<!-- hiveku:end -->';
40
97
  const TOML_BEGIN = '# hiveku:begin (managed by hiveku-sync — do not edit inside)';
41
98
  const TOML_END = '# hiveku:end';
42
99
  const SKILL_STAMP = '<!-- generated-by: hiveku-sync (mirrored from account knowledge) -->';
43
100
 
44
- /** Replace (or append) the marked region inside existing content. */
45
- function upsertMarkedRegion(existing, begin, end, region) {
101
+ /**
102
+ * Replace (or append) the marked region inside existing content. Also says
103
+ * where: `start` is the region's offset in `text`, and `replaced` the old
104
+ * region it replaced (null when it was appended).
105
+ */
106
+ function placeMarkedRegion(existing, begin, end, region) {
46
107
  const b = existing.indexOf(begin);
47
108
  const e = existing.indexOf(end);
48
109
  if (b !== -1 && e !== -1 && e > b) {
49
- return existing.slice(0, b) + region + existing.slice(e + end.length);
110
+ return {
111
+ text: existing.slice(0, b) + region + existing.slice(e + end.length),
112
+ start: b,
113
+ replaced: existing.slice(b, e + end.length),
114
+ };
50
115
  }
51
116
  const sep = existing.length && !existing.endsWith('\n\n') ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
52
- return existing + sep + region + '\n';
117
+ return { text: existing + sep + region + '\n', start: existing.length + sep.length, replaced: null };
118
+ }
119
+
120
+ /** Replace (or append) the marked region inside existing content. */
121
+ function upsertMarkedRegion(existing, begin, end, region) {
122
+ return placeMarkedRegion(existing, begin, end, region).text;
53
123
  }
54
124
 
55
125
  /** AGENTS.md — lean on purpose (Codex caps the instruction chain at ~32KiB). */
@@ -68,6 +138,11 @@ invoked by name) and this file.
68
138
 
69
139
  Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
70
140
  - **Verify identity before ANY write:** \`get_account_info\` must return THIS account.
141
+ - **Context and memory.** Call \`account_context_get({ domain })\` first, before any copy, plan or analysis.
142
+ The account memory is read-only to you: owners edit it on the Hiveku dashboard, the local copy is
143
+ \`hiveku-data/account/ACCOUNT_MEMORY.md\` (never edit or upload it); suggest one line with \`account_memory_append\`.
144
+ Department memory is ONE document per department: read it (\`memory_list({ domain })\`), merge, then send the
145
+ whole document with \`memory_update({ memory_id, content })\`. Never create-then-overwrite on a 409.
71
146
  - **You are NOT the only writer.** Others push to these same projects while you work. Check what is
72
147
  current BEFORE you start (\`project_version_log\`) and AGAIN before you push (\`project_files_status\`
73
148
  — \`changed\` = they edited it, \`only_remote\` = they added files you lack). Before any tree-replace
@@ -82,34 +157,168 @@ Non-negotiables (also in CLAUDE.md, restated because they are load-bearing):
82
157
  attributed to the authenticated user (\`crm_list_users\`).
83
158
  - **Every completed task ends with an "Owner update"** — 2–4 calm, plain-language sentences a busy owner
84
159
  can skim: benefit first, no alarm vocabulary, accurate.
160
+ - **Hiveku itself in your way?** A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`;
161
+ a capability no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
162
+ - **Tell the user only if it changes what they get:** one or two calm sentences — flagged to the Hiveku team (give the
163
+ ref), the team is quick to fix these, you'll let them know when it's sorted. No error codes, blame or promised times.
85
164
  - Video generation is paid + capped — \`marketing_generate_video\` with \`dry_run: true\` first.
86
165
 
87
166
  Discover tools with \`hiveku_docs_search\` / \`hiveku_docs_get\` and \`hiveku_playbooks_list\` /
88
- \`hiveku_playbook_get\`. Codex ignores repo-level approval/sandbox config, so the rules above are
89
- instructions, not enforced permissions — follow them.
167
+ \`hiveku_playbook_get\`. Codex asks the user first only for a fixed list of ${CODEX_PROMPT_TOOLS.length} high-risk Hiveku
168
+ tools (hiveku-sync's \`codex-prompt-tools.json\`, the Hiveku Codex plugin's list, written as per-tool
169
+ prompts in \`.codex/config.toml\`). Every other Hiveku tool, including many deletes and sends, runs
170
+ without asking, and some Codex modes (\`--yolo\`, \`danger-full-access\`) skip even those prompts. So
171
+ never count on a prompt: the rules above are instructions, not enforced permissions — follow them for
172
+ every tool.
90
173
  ${AGENTS_END}`;
91
174
  }
92
175
 
176
+ /**
177
+ * The listed tools whose table the user's own settings already declare
178
+ * outside the managed block, in list order. Writing ours as well would
179
+ * declare that table twice, which TOML does not allow, so theirs wins.
180
+ *
181
+ * A tool's table is declared by a [mcp_servers.hiveku.tools.<tool>] or
182
+ * [[...]] header, however it is spelled (quoted parts, spaces), or by a key
183
+ * that sets it from a table above it: `<tool> = { ... }` or
184
+ * `<tool>.approval_mode = ...` under [mcp_servers.hiveku.tools]. A header one
185
+ * level deeper ([...tools.<tool>.x]) does not declare it, so it does not count.
186
+ * The file is read as Codex reads it (TOML 1.1 syntax, a byte-order mark at
187
+ * the start); a line that cannot be read is skipped.
188
+ *
189
+ * `withoutApprovalMode`: those whose own table sets no approval_mode, so
190
+ * Codex uses the server default ("approve") for them.
191
+ */
192
+ function userToolTables(existing) {
193
+ const text = existing.replace(/\r\n/g, '\n');
194
+ const begin = text.indexOf(TOML_BEGIN);
195
+ const end = text.indexOf(TOML_END);
196
+ const blockEnd = begin !== -1 && end > begin ? end + TOML_END.length : -1;
197
+ const found = new Map(); // tool -> { declared, setsMode, notATable }
198
+ for (const statement of readTomlDeclarations(text).statements) {
199
+ if (blockEnd !== -1 && statement.pos >= begin && statement.pos < blockEnd) continue; // ours
200
+ const keyval = statement.type === 'keyval';
201
+ const key = keyval ? [...statement.header, ...statement.key] : statement.key;
202
+ if (key.length < 4 || key[0] !== 'mcp_servers' || key[1] !== 'hiveku' || key[2] !== 'tools') continue;
203
+ const entry = found.get(key[3]) ?? { declared: false, setsMode: false, notATable: false };
204
+ if (keyval ? statement.header.length < 4 : key.length === 4) entry.declared = true;
205
+ if (key[4] === 'approval_mode') entry.setsMode = true;
206
+ if (keyval && key.length === 4) {
207
+ if (statement.value !== 'table') entry.notATable = true;
208
+ else if (statement.tableKeys.includes('approval_mode')) entry.setsMode = true;
209
+ }
210
+ if (statement.type === 'arrayTable' && key.length === 4) entry.notATable = true;
211
+ found.set(key[3], entry);
212
+ }
213
+ const tools = CODEX_PROMPT_TOOLS.filter((tool) => found.get(tool)?.declared);
214
+ const withoutApprovalMode = tools.filter((tool) => !found.get(tool).setsMode && !found.get(tool).notATable);
215
+ return { tools, withoutApprovalMode };
216
+ }
217
+
218
+ /**
219
+ * Where the update would declare a table or key twice with one of the two in
220
+ * hiveku-sync's own block: the one way writing the block can break a file.
221
+ * Returns the first such place as { ours, line }, `ours` being the block's
222
+ * statement and `line` the other declaration's line in `existing` (the file
223
+ * as it is now), or null. A problem in the rest of the file is the user's own
224
+ * and not a reason to stop: the block is written as before.
225
+ *
226
+ * `placed` is placeMarkedRegion's result for `region` in `existing`.
227
+ */
228
+ function blockConflict(existing, placed, region) {
229
+ const normalize = (text) => text.replace(/\r\n/g, '\n');
230
+ const start = normalize(placed.text.slice(0, placed.start)).length;
231
+ const end = start + region.length;
232
+ const inBlock = (statement) => statement !== null && statement.pos >= start && statement.pos < end;
233
+ // Text before the block is as it was; text after it moved by the old block's length less the new one's.
234
+ const before = normalize(existing);
235
+ const oldLength = placed.replaced === null ? 0 : normalize(placed.replaced).length;
236
+ const lineNow = (pos) => before.slice(0, pos < start ? pos : pos - end + start + oldLength).split('\n').length;
237
+ for (const { statement, prior } of readTomlDeclarations(normalize(placed.text)).conflicts) {
238
+ if (!inBlock(statement) && !inBlock(prior)) continue;
239
+ const theirs = inBlock(statement) ? prior : statement;
240
+ return { ours: inBlock(statement) ? statement : prior, line: theirs && !inBlock(theirs) ? lineNow(theirs.pos) : null };
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * The error for an update blockConflict refused. It names the block's own
247
+ * table or key (hiveku-sync's names) and only the line number of the user's,
248
+ * so nothing the user typed is repeated (a pasted secret, say).
249
+ */
250
+ function refusalMessage(tomlPath, existing, { ours, line }) {
251
+ const name =
252
+ ours.type === 'keyval' ? `the key ${formatKey([...ours.header, ...ours.key])}` : `the table [${formatKey(ours.key)}]`;
253
+ const where = line ? `line ${line} of it` : 'a line of it';
254
+ const lines = [
255
+ `${tomlPath} was not updated: ${where} conflicts with ${name} in the hiveku-sync block, and a TOML file may declare each table and key only once. Nothing in the folder was changed.`,
256
+ ];
257
+ const begin = existing.indexOf(TOML_BEGIN);
258
+ if (begin !== -1 && existing.indexOf(TOML_BEGIN, begin + 1) !== -1) {
259
+ lines.push('The file has more than one "# hiveku:begin" line, so hiveku-sync could not tell which block is its own. Keep one block, then run `hiveku-sync codex` again.');
260
+ } else if (begin !== -1 && !(existing.indexOf(TOML_END) > begin)) {
261
+ lines.push('hiveku-sync could not find its own block to replace: the "# hiveku:begin" line has no "# hiveku:end" line after it. Put "# hiveku:end" back at the end of the block, then run `hiveku-sync codex` again.');
262
+ } else {
263
+ lines.push(`Remove or change ${line ? `line ${line}` : 'that line'}, then run \`hiveku-sync codex\` again.`);
264
+ }
265
+ return lines.join(' ');
266
+ }
267
+
268
+ /** A TOML basic string holding `value`: quotes, backslashes and control characters other than tab escaped. */
269
+ function tomlString(value) {
270
+ const escaped = value.replace(/["\\\x00-\x08\x0a-\x1f\x7f]/g, (ch) =>
271
+ ch === '"' || ch === '\\' ? `\\${ch}` : `\\u${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`,
272
+ );
273
+ return `"${escaped}"`;
274
+ }
275
+
93
276
  /** The [mcp_servers.*] region for <folder>/.codex/config.toml. */
94
- function codexTomlRegion({ baseUrl, profile, apiKey }) {
277
+ function codexTomlRegion({ baseUrl, profile, apiKey, userTables = [] }) {
95
278
  const base = baseUrl.replace(/\/+$/, '');
96
279
  const url = profile === 'full' ? `${base}/mcp` : `${base}/mcp?profile=${encodeURIComponent(profile)}`;
97
280
  // Prefer an inlined key (self-contained, no env setup needed; the file is
98
281
  // git-ignored). Fall back to the OLYMPUS_API_KEY env var when we can't
99
282
  // resolve a real key at scaffold time (e.g. `init` before the user pastes it).
283
+ //
284
+ // http_headers is Codex's map of static HTTP headers for a streamable HTTP
285
+ // MCP server (Codex config reference, "mcp_servers.<id>.http_headers"; the
286
+ // docs' example is `http_headers = { "X-Figma-Region" = "us-east-1" }`, next
287
+ // to bearer_token_env_var). TOML allows the key once per table, so the
288
+ // client label rides in the same inline table as an inlined key.
289
+ // X-Hiveku-Client: codex is what the MCP server's memory log shows as
290
+ // "via Codex"; it is a label, never authentication. The key and the URL come
291
+ // from .env, so they are escaped: a quote in either would otherwise end the
292
+ // string early and leave the block itself unreadable.
100
293
  const auth = apiKey
101
- ? `http_headers = { "Authorization" = "Bearer ${apiKey}" }`
102
- : `bearer_token_env_var = "OLYMPUS_API_KEY"`;
294
+ ? `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)}, "X-Hiveku-Client" = "codex" }`
295
+ : `bearer_token_env_var = "OLYMPUS_API_KEY"\nhttp_headers = { "X-Hiveku-Client" = "codex" }`;
103
296
  // default_tools_approval_mode = "approve": this folder exists so the agent
104
- // can operate the account — per-call approval prompts would also auto-cancel
105
- // every tool call in headless `codex exec` (CI/cron), this lane's core use.
106
- // (Enum is approve|prompt; the docs' "auto" is not accepted by codex 0.131.)
297
+ // can operate the account — per-call approval prompts would also cancel
298
+ // every tool call in headless `codex exec` (CI/cron, this lane's core use)
299
+ // under a read-only or workspace-write sandbox.
300
+ // The per-tool tables after it override that default for CODEX_PROMPT_TOOLS
301
+ // (a tool's own approval_mode wins over the server default), so those ask
302
+ // first, exactly as they do under the Hiveku Codex plugin, in the modes where
303
+ // Codex asks at all (see CODEX_PROMPT_TOOLS). Each tool gets ONE table: a
304
+ // table defined twice makes the whole file invalid TOML, so merge any other
305
+ // prompt list into CODEX_PROMPT_TOOLS instead of adding tables here, and
306
+ // leave out the tools whose table the user declares outside this block
307
+ // (userTables): their own setting wins.
308
+ const skip = new Set(userTables);
309
+ const promptTables = CODEX_PROMPT_TOOLS.filter((tool) => !skip.has(tool))
310
+ .map((tool) => `[mcp_servers.hiveku.tools.${tomlKey(tool)}]\napproval_mode = "prompt"`)
311
+ .join('\n');
107
312
  return `${TOML_BEGIN}
108
313
  [mcp_servers.hiveku]
109
- url = "${url}"
314
+ url = ${tomlString(url)}
110
315
  ${auth}
111
316
  default_tools_approval_mode = "approve"
112
317
 
318
+ # Ask first: a fixed list of high-risk tools (the Hiveku Codex plugin's list).
319
+ # Every other hiveku tool runs without asking.
320
+ ${promptTables}
321
+
113
322
  [mcp_servers.playwright]
114
323
  command = "npx"
115
324
  args = ["-y", "@playwright/mcp@latest"]
@@ -194,6 +403,10 @@ export async function mirrorKnowledgeToSkills(folder) {
194
403
  * .codex/config.toml (git-ignored) + mirror the account's knowledge to skills.
195
404
  * Resolves OLYMPUS_API_KEY from the folder's .env / process env to inline the
196
405
  * MCP auth when possible.
406
+ *
407
+ * Throws, with nothing in the folder written, when the hiveku-sync block would
408
+ * declare a table or key that the rest of config.toml also declares (see
409
+ * blockConflict). The updated file is checked in memory, before any write.
197
410
  */
198
411
  export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
199
412
  // The FOLDER's .env wins over a process-level export: folder = tenant
@@ -205,6 +418,24 @@ export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
205
418
  dotenv.OLYMPUS_API_KEY && dotenv.OLYMPUS_API_KEY !== 'olp_replace_me' ? dotenv.OLYMPUS_API_KEY : '';
206
419
  const apiKey = dotenvKey || (existsSync(join(folder, '.env')) ? '' : process.env.OLYMPUS_API_KEY || '');
207
420
 
421
+ // .codex/config.toml (marker-managed region; rest of user's config untouched).
422
+ // First, and checked in memory before anything is written, so that a refused
423
+ // update leaves the whole folder as it was (no .codex/ folder created either).
424
+ const tomlPath = join(folder, '.codex', 'config.toml');
425
+ let toml = '';
426
+ try {
427
+ toml = await readFile(tomlPath, 'utf8');
428
+ } catch (err) {
429
+ if (err.code !== 'ENOENT') throw err;
430
+ }
431
+ const userTables = userToolTables(toml);
432
+ const region = codexTomlRegion({ baseUrl, profile, apiKey, userTables: userTables.tools });
433
+ const placed = placeMarkedRegion(toml, TOML_BEGIN, TOML_END, region);
434
+ const conflict = blockConflict(toml, placed, region);
435
+ if (conflict) throw new Error(refusalMessage(tomlPath, toml, conflict));
436
+ await mkdir(dirname(tomlPath), { recursive: true });
437
+ await writeFile(tomlPath, placed.text, 'utf8');
438
+
208
439
  // AGENTS.md (marker-managed)
209
440
  const agentsPath = join(folder, 'AGENTS.md');
210
441
  let agents = '';
@@ -215,17 +446,6 @@ export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
215
446
  }
216
447
  await writeFile(agentsPath, upsertMarkedRegion(agents, AGENTS_BEGIN, AGENTS_END, agentsMdRegion({ slug })), 'utf8');
217
448
 
218
- // .codex/config.toml (marker-managed region; rest of user's config untouched)
219
- const tomlPath = join(folder, '.codex', 'config.toml');
220
- let toml = '';
221
- try {
222
- toml = await readFile(tomlPath, 'utf8');
223
- } catch {
224
- /* new */
225
- }
226
- await mkdir(dirname(tomlPath), { recursive: true });
227
- await writeFile(tomlPath, upsertMarkedRegion(toml, TOML_BEGIN, TOML_END, codexTomlRegion({ baseUrl, profile, apiKey })), 'utf8');
228
-
229
449
  // Gitignore EVERY credential-bearing file this lane depends on — not just the
230
450
  // one we write. The retrofit reads .env for OLYMPUS_API_KEY to inline it, so a
231
451
  // folder that had no .gitignore would otherwise end up with one that ignores
@@ -236,7 +456,14 @@ export async function writeCodexScaffold({ folder, slug, baseUrl, profile }) {
236
456
  await appendGitignoreLines(folder, ['.codex/config.toml', '.env', '.env.local', '.hiveku/']);
237
457
 
238
458
  const skills = await mirrorKnowledgeToSkills(folder);
239
- return { inlinedKey: !!apiKey, skillsMirrored: skills };
459
+ // userToolTables: listed tools left to the user's own table (the CLI names
460
+ // them); userTablesWithoutApprovalMode: those whose table sets no approval_mode.
461
+ return {
462
+ inlinedKey: !!apiKey,
463
+ skillsMirrored: skills,
464
+ userToolTables: userTables.tools,
465
+ userTablesWithoutApprovalMode: userTables.withoutApprovalMode,
466
+ };
240
467
  }
241
468
 
242
469
  /**
package/src/init.mjs CHANGED
@@ -64,6 +64,9 @@ exposes the **${profile}** profile of Olympus tools.
64
64
  - \`rules/\` — synced agent rules
65
65
  - \`skills/\` — synced agent skills
66
66
  - \`commands/\`, \`agents/\`, \`identity/\` — other memory types
67
+ - \`hiveku-data/account/ACCOUNT_MEMORY.md\` — the account memory (the business
68
+ facts every department agent reads), **read-only**: owners and admins edit it on
69
+ the Hiveku dashboard under Account memory, and nothing uploads this file
67
70
  - \`projects/<slug>/\` — coder project source pulled via \`hiveku-sync pull-project\`
68
71
  - \`.hiveku/manifest.json\` — memory last-sync record
69
72
  - \`.hiveku/projects.json\` — project ID ↔ folder mapping
@@ -81,6 +84,16 @@ changes (new skill, updated rule, etc.), call the corresponding MCP tool:
81
84
  commands, agents, identity entries
82
85
  - \`memory_delete\` — to remove an entry
83
86
 
87
+ \`memory_update\` replaces the whole entry. Each department's memory is ONE
88
+ document, so read the current one with \`memory_list({ domain })\`, merge your
89
+ change into its full text, and send the whole document. Use \`memory_create\`
90
+ only when none exists; a 409 means one does, so read and merge, never overwrite.
91
+
92
+ The account memory is the exception: no tool sets it. Read it with
93
+ \`account_memory_get\`, suggest one line with \`account_memory_append\` (an owner
94
+ keeps or removes it), and tell the user that owners and admins edit it on the
95
+ Hiveku dashboard under Account memory.
96
+
84
97
  Then run \`hiveku-sync pull\` to refresh the local copies.
85
98
 
86
99
  ## Coder projects (pull / status / push)
@@ -112,6 +125,14 @@ You can also ask Claude in this workspace to:
112
125
  Claude has the \`project_files_list\`, \`project_file_get\`, \`project_files_status\`,
113
126
  and \`project_commit\` MCP tools available under the **${profile}** profile.
114
127
 
128
+ ## When Hiveku itself gets in your way
129
+
130
+ - A Hiveku tool still failing after one sensible retry → \`hiveku_report_issue\`; a capability
131
+ no tool offers (search \`hiveku_docs_search\` first) → \`hiveku_request_feature\`. No secrets or personal data.
132
+ - Tell the user only if it changes what they get: one or two calm sentences — flagged to the Hiveku
133
+ team (give the ref), the team is quick to fix these, you'll let them know when it's sorted. No
134
+ error codes, blame or promised times.
135
+
115
136
  ## Tenant scope
116
137
 
117
138
  The API key in \`.env\` is **pinned to one Hiveku account**. You cannot switch
package/src/projects.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  writeFileAtomic,
20
20
  readJson,
21
21
  } from './util.mjs';
22
+ import { isAccountMemoryPath } from './account-memory.mjs';
22
23
  // NOTE: push.mjs imports helpers from this module; the cycle is safe because
23
24
  // both sides reference the imports only inside functions (never at module eval).
24
25
  import { computePushPlan } from './push.mjs';
@@ -118,6 +119,12 @@ async function listAllRemoteFiles(client, projectId) {
118
119
  return all;
119
120
  }
120
121
 
122
+ /**
123
+ * Every local file push, commit and status consider. The pulled account memory
124
+ * (hiveku-data/account/) is left out wherever it sits: it is a read-only copy
125
+ * that owners edit on the dashboard, and nothing may upload it, not even when
126
+ * an account folder and a project folder are the same folder.
127
+ */
121
128
  export async function walkLocalFiles(rootDir) {
122
129
  const out = [];
123
130
  async function recurse(dir) {
@@ -131,6 +138,7 @@ export async function walkLocalFiles(rootDir) {
131
138
  if (entry.name.startsWith('.')) continue;
132
139
  if (IGNORE_DIRS.has(entry.name)) continue;
133
140
  const full = join(dir, entry.name);
141
+ if (isAccountMemoryPath(relative(rootDir, full).split(sep).join('/'))) continue;
134
142
  if (entry.isDirectory()) {
135
143
  await recurse(full);
136
144
  } else if (entry.isFile()) {
package/src/pull.mjs CHANGED
@@ -2,6 +2,11 @@
2
2
  * `hiveku-sync pull` — fetches memory/rules/skills via the MCP memory_list tool
3
3
  * and writes them as local .md files under the current folder.
4
4
  *
5
+ * The account memory (domains `account` and `account-suggestions`) is not a
6
+ * knowledge entry: it is classified explicitly, never written under memory/,
7
+ * and pulled once from account_memory_get into a read-only
8
+ * hiveku-data/account/ACCOUNT_MEMORY.md (see account-memory.mjs).
9
+ *
5
10
  * Safety: refuses to overwrite when the .env API key no longer matches the
6
11
  * manifest's recorded key preview (prevents pulling Account A's data into a
7
12
  * folder previously synced for Account B — the 2026-05-11 cross-tenant bug
@@ -22,6 +27,15 @@ import {
22
27
  TYPE_TO_FOLDER,
23
28
  SUPPORTED_TYPES,
24
29
  } from './util.mjs';
30
+ import {
31
+ ACCOUNT_MEMORY_FILE,
32
+ classifyMemoryEntry,
33
+ ACCOUNT_MEMORY_TOOL,
34
+ normalizeAccountMemory,
35
+ renderAccountMemoryFile,
36
+ resolveAccountId,
37
+ writeReadOnly,
38
+ } from './account-memory.mjs';
25
39
 
26
40
  const MANIFEST_PATH = '.hiveku/manifest.json';
27
41
 
@@ -58,7 +72,19 @@ function renderEntry(entry, type) {
58
72
  return `${frontmatter}${body}`;
59
73
  }
60
74
 
61
- export async function runPull({ baseDir = process.cwd(), profile } = {}) {
75
+ /**
76
+ * Write hiveku-data/account/ACCOUNT_MEMORY.md from account_memory_get, read-only.
77
+ * Returns { file, version, suggestions, accountId }, or throws (the caller
78
+ * records the error and keeps the previous copy).
79
+ */
80
+ export async function pullAccountMemory({ client, baseDir, fetchedAt = new Date().toISOString() }) {
81
+ const memory = normalizeAccountMemory(await client.callToolJson(ACCOUNT_MEMORY_TOOL, {}));
82
+ const accountId = await resolveAccountId(client);
83
+ await writeReadOnly(join(baseDir, ACCOUNT_MEMORY_FILE), renderAccountMemoryFile({ memory, accountId, fetchedAt }));
84
+ return { file: ACCOUNT_MEMORY_FILE, version: memory.version, suggestions: memory.suggestions.length, accountId };
85
+ }
86
+
87
+ export async function runPull({ baseDir = process.cwd(), profile, client: injectedClient } = {}) {
62
88
  const envPath = join(baseDir, '.env');
63
89
  const dotenv = await loadDotenv(envPath);
64
90
  const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
@@ -88,10 +114,10 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
88
114
  );
89
115
  }
90
116
 
91
- const client = new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
92
- await client.initialize();
117
+ const client = injectedClient || new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
118
+ if (!injectedClient) await client.initialize();
93
119
 
94
- const summary = { total: 0, by_type: {}, errors: [] };
120
+ const summary = { total: 0, by_type: {}, errors: [], account_memory: null, account_rows_skipped: 0 };
95
121
  const writtenFiles = new Set();
96
122
 
97
123
  for (const type of SUPPORTED_TYPES) {
@@ -103,10 +129,19 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
103
129
  summary.errors.push({ type, message: err.message });
104
130
  continue;
105
131
  }
106
- summary.by_type[type] = entries.length;
107
- summary.total += entries.length;
108
-
132
+ // The two account-memory domains are not knowledge entries: an older
133
+ // server still lists them, and they would land as memory/account.md and
134
+ // memory/account-suggestions.md (raw JSON Lines) looking editable. They
135
+ // are written once, read-only, from account_memory_get below.
136
+ const knowledge = [];
109
137
  for (const entry of entries) {
138
+ if (classifyMemoryEntry(entry, folder).kind === 'knowledge') knowledge.push(entry);
139
+ else summary.account_rows_skipped += 1;
140
+ }
141
+ summary.by_type[type] = knowledge.length;
142
+ summary.total += knowledge.length;
143
+
144
+ for (const entry of knowledge) {
110
145
  const filename = entryFilename(entry);
111
146
  const fullPath = join(baseDir, folder, filename);
112
147
  await writeFileAtomic(fullPath, renderEntry(entry, type));
@@ -114,6 +149,17 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
114
149
  }
115
150
  }
116
151
 
152
+ // The account memory: one read-only file, never uploaded by anything here.
153
+ // A failure keeps the previous copy (and its manifest line), so it is not
154
+ // reported as removed upstream.
155
+ try {
156
+ summary.account_memory = await pullAccountMemory({ client, baseDir });
157
+ writtenFiles.add(ACCOUNT_MEMORY_FILE);
158
+ } catch (err) {
159
+ summary.errors.push({ type: 'account memory', message: err.message });
160
+ if ((manifest?.files ?? []).includes(ACCOUNT_MEMORY_FILE)) writtenFiles.add(ACCOUNT_MEMORY_FILE);
161
+ }
162
+
117
163
  // v1: don't delete stale files; just record them in the manifest so the
118
164
  // user can prune manually if they want. Avoids losing local edits.
119
165
  const previouslySeen = new Set(manifest?.files ?? []);
@@ -129,6 +175,8 @@ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
129
175
  last_sync_at: new Date().toISOString(),
130
176
  counts: summary.by_type,
131
177
  files: [...writtenFiles].sort(),
178
+ // Pulled for reading only: edited on the dashboard, never uploaded.
179
+ read_only: [ACCOUNT_MEMORY_FILE],
132
180
  removed_remotely_since_last_sync: removedRemotely,
133
181
  },
134
182
  null,