@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/README.md +70 -3
- package/bin/hiveku-sync.mjs +33 -0
- package/package.json +1 -1
- package/src/account-memory.mjs +240 -0
- package/src/codex-prompt-tools.json +252 -0
- package/src/codex.mjs +255 -28
- package/src/init.mjs +21 -0
- package/src/projects.mjs +8 -0
- package/src/pull.mjs +55 -7
- package/src/toml.mjs +666 -0
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 →
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
45
|
-
|
|
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
|
|
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
|
|
89
|
-
|
|
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" =
|
|
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
|
|
105
|
-
// every tool call in headless `codex exec` (CI/cron
|
|
106
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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,
|