engineering-memory 1.11.8 → 1.11.10
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/dispatcher/sections.mjs +7 -3
- package/install/codex-approval.mjs +457 -0
- package/install/files.mjs +119 -9
- package/install/installer.mjs +44 -5
- package/install/launcher.mjs +95 -0
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +4 -0
- package/runtime/dist/src/git/git-inspector.js +82 -3
- package/runtime/dist/src/git/verification-gate.js +30 -17
- package/runtime/dist/src/index.js +25 -81
- package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
- package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
- package/runtime/dist/src/mcp/server.js +14 -7
- package/runtime/dist/src/mcp/tool-annotations.js +111 -0
- package/runtime/dist/src/mcp/tool-definitions.js +148 -28
- package/runtime/dist/src/mcp/tool-host.js +12 -0
- package/runtime/dist/src/mcp/worktree-tools.js +323 -236
- package/runtime/dist/src/project/repository.js +11 -5
- package/runtime/dist/src/runtime/active-context-store.js +3 -0
- package/runtime/dist/src/runtime/branch-preferences.js +0 -19
- package/runtime/dist/src/runtime/bridge-service.js +1163 -231
- package/runtime/dist/src/runtime/create-bridge-service.js +82 -0
- package/runtime/dist/src/runtime/offline-outbox.js +33 -158
- package/runtime/dist/src/runtime/phase-timer.js +26 -0
- package/runtime/dist/src/runtime/privacy-detector.js +263 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
- package/runtime/dist/src/runtime/recovery-error.js +3 -1
- package/runtime/dist/src/runtime/runtime-entry.js +24 -0
- package/runtime/dist/src/runtime/runtime-host.js +263 -0
- package/runtime/dist/src/runtime/task-branch-store.js +17 -1
- package/runtime/dist/src/runtime/task-start.js +281 -0
- package/runtime/dist/src/runtime/worktree-pool.js +340 -24
- package/runtime/dist/src/utilities/process.js +1 -0
- package/skill/SKILL.md +3 -3
- package/skill/references/lifecycle.md +44 -19
- package/skill/references/memory-updates.md +9 -3
- package/skill/references/project-onboarding.md +19 -2
- package/skill/references/questionnaires.md +77 -15
package/dispatcher/sections.mjs
CHANGED
|
@@ -9,7 +9,7 @@ Call \`session.entry\` before you answer anything in a repository, whatever the
|
|
|
9
9
|
- Switched off: say nothing about Engineering Memory, offer nothing, hint at nothing, however useful it would be for what was asked. They turned it off; raising it again is what this prevents. Record it back on only when they ask.
|
|
10
10
|
- Bound: continue into the lifecycle.
|
|
11
11
|
|
|
12
|
-
\`session.entry\` also reports this client's version. When it says an update is available, offer once to run \`npx engineering-memory@latest install\` and tell the user the client
|
|
12
|
+
\`session.entry\` also reports this client's version. When it says an update is available, offer once to run \`npx engineering-memory@latest install\` and tell the user that the running bridge switches to the new release by itself on its next call, so nothing needs restarting; only a bridge older than 1.11.9 needs the client restarted once after that install. If they decline, record it with \`session.decline_update\` and never raise that version again. When it says an update is required, there is no choice to offer: say that this client is older than the backend supports, give the command, and do not carry on as though the lifecycle works.
|
|
13
13
|
|
|
14
14
|
The user may say at any point that they want a different organization, a different project, Engineering Memory switched off, or switched back on. Recognise what they mean rather than matching words — the request arrives in whatever language and phrasing they happen to use — and act on it. Changing the organization always asks for the project again.
|
|
15
15
|
|
|
@@ -19,12 +19,14 @@ Never answer a question about a bound repository from the working tree alone. Wh
|
|
|
19
19
|
|
|
20
20
|
After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
|
|
21
21
|
|
|
22
|
-
Before a new write/scaffold task,
|
|
22
|
+
Before a new write/scaffold task, call \`task.branch\` directly: do not ask the user where, from which branch or under which name first, and do not call \`worktree.list\` for it. Its one native form asks where the task works, which branch it starts from and the branch name, and nothing is created until the user answers. The pool reuses a safely available worktree slot before creating another; never pass or invent a worktree path. Reconcile protected legacy entries through the native recovery flow; clean files alone do not establish that the old agent and delivery have finished. Run all commands from its returned \`repoRoot\`. Renew \`task.heartbeat\` during actual work, use \`task.pause\` on handoff, and resume before writing again. Inactivity never permits force checkout or cleanup. Keep closed worktrees until authorized delivery completes. When \`session.entry\` says the user can manage them, unanswered shared branch preferences are asked together in one native form via \`project.set_git_preferences\`; other members choose only their own task base in \`task.branch\`. Answering "Not now" returns \`deferred\` with nothing allocated and a \`reconsider\` call; say so in one line, keep working on anything that does not depend on it, and make that \`reconsider\` call only when the user actually asks to start that task.
|
|
23
23
|
|
|
24
24
|
Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
|
|
25
25
|
|
|
26
26
|
Do not block independent task work on \`memory.propose_revision\` drafting, submission or approval. Follow the skill's memory-updates reference: use background agents or concurrent tools when the host permits them, and continue useful work instead of immediately waiting. Without concurrency, checkpoint the pending draft and defer submission until needed. Collect the result only at the operation that depends on it; native approval and required task verification still apply.
|
|
27
27
|
|
|
28
|
+
A check whose answer decides the next action — delivery preconditions such as the commit hook, the remote and the branch; whether verification or close can pass; what a waiver allows — is run by the agent that takes that action, in its own turn. Hand work to another agent only when you have other real work to do while it runs, and never take the action a delegated check gates before its answer is back. Work another agent checked is neither the user's approval nor task verification. Size delegation to the task — a one- or two-file change needs no multi-agent workflow — and once work is delegated, wait for its real completion signal, never a sleep loop polling its output.
|
|
29
|
+
|
|
28
30
|
Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. For a required decision, use \`questionnaire.ask\` to open a durable native MCP form and \`questionnaire.resume\` to return to the same unanswered question. Read the skill's questionnaires reference first. Never use request_user_input_async for a required decision: it does not wait for an answer. When the durable form is unavailable, use a blocking native control only where the host permits it: request_user_input in Codex or AskUserQuestion in Claude. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. A timeout, dismissed form, empty response or ended turn is not an answer. Keep the decision pending and resume it; do not start dependent work or report it as resolved. Existing answers remain valid through retries and handoffs.
|
|
29
31
|
|
|
30
32
|
When a durable MCP form cannot be displayed, read its \`hostFallback\` or call \`questionnaire.resume\` with \`presentation: host_native\` to get the original question without reopening the MCP form. Display the same question, all choices and notices through a blocking native control only if the host permits that control for this decision. After an actual native answer, call \`questionnaire.answer_from_host\` with the unchanged questionnaireId, requestKey and contentHash, the hostTool name and the returned choice/text. This is an agent-reported relay, not MCP transport attestation. Then retry the owning operation; its authority, content and version checks still apply. Never relay prose consent, a default, an asynchronous response or a cancelled/declined/missing answer. Decline alone is not proof that the host cannot display forms. Keep the decision pending if no permitted native control can represent it.
|
|
@@ -33,7 +35,9 @@ When the repository is unbound, use the native questionnaire required by the ski
|
|
|
33
35
|
|
|
34
36
|
For project creation or adoption, read the skill's project-onboarding reference. Use read-only maturity evidence, then native selection of greenfield, exhaustive adoption or phased rework. A missing folder or Git repository uses preparation:true questionnaires before project.initialize. Recognize later memory-refresh intent semantically and confirm its scope natively; never trigger it from a fixed word list. Adoption and refresh cover architecture, state, services, network, data, screens and cross-module flows, not just page logic.`;
|
|
35
37
|
|
|
38
|
+
const codexApproval = `Codex's auto-review cannot see an answer given in an Engineering Memory form: to the reviewer it is tool output, and it trusts only the user's own messages, this file and Codex's own approval controls. When a push or pull request needs approval, its justification quotes what the user said and names the exact remote, URL and branch from the delivery destination \`task.close\` returned. When auto-review denies a push, a pull request or any other action the user chose, never open an Engineering Memory form and never ask the user to type or repeat an approval to get past it, and never reword the command to get it through. Quote the denial reason in one sentence and ask for Codex's own approval once — Approve on the denied action, or its \`/approve\` command — and run the identical command once, only after the user has actually given that approval. If this Codex offers no approval for the denial, explain what was denied and why, and wait for the user.`;
|
|
39
|
+
|
|
36
40
|
export const dispatcherSections = Object.freeze({
|
|
37
|
-
codex: `${shared}\n\nInvoke the installed skill as \`$engineering-memory\` when an explicit skill invocation is needed.`,
|
|
41
|
+
codex: `${shared}\n\n${codexApproval}\n\nInvoke the installed skill as \`$engineering-memory\` when an explicit skill invocation is needed.`,
|
|
38
42
|
claude: `${shared}\n\nInvoke the installed skill as \`/engineering-memory\` when an explicit skill invocation is needed.`,
|
|
39
43
|
});
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { readText, replaceFile } from './files.mjs';
|
|
3
|
+
import { mcpServerName } from './mcp-registration.mjs';
|
|
4
|
+
|
|
5
|
+
export const codexDefaultToolsApprovalMode = 'approve';
|
|
6
|
+
export const codexReviewedToolApprovalMode = 'prompt';
|
|
7
|
+
export const codexReviewedTools = Object.freeze([]);
|
|
8
|
+
|
|
9
|
+
const serverPath = ['mcp_servers', mcpServerName];
|
|
10
|
+
|
|
11
|
+
class UnmanagedCodexConfig extends Error {}
|
|
12
|
+
|
|
13
|
+
export function codexHome(homeDir, environment = process.env) {
|
|
14
|
+
return environment.CODEX_HOME
|
|
15
|
+
? resolve(environment.CODEX_HOME)
|
|
16
|
+
: join(homeDir, '.codex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function planCodexApproval({
|
|
20
|
+
codexHome,
|
|
21
|
+
state,
|
|
22
|
+
reviewedTools = codexReviewedTools,
|
|
23
|
+
}) {
|
|
24
|
+
const path = join(codexHome, 'config.toml');
|
|
25
|
+
const lines = splitLines(await readText(path));
|
|
26
|
+
const recorded = state ?? {};
|
|
27
|
+
const intended = {
|
|
28
|
+
defaultToolsApprovalMode: codexDefaultToolsApprovalMode,
|
|
29
|
+
tools: Object.fromEntries(
|
|
30
|
+
reviewedTools.map((tool) => [tool, codexReviewedToolApprovalMode]),
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
const userOverrides = [];
|
|
34
|
+
const keepUserValue = (key, found, previous, wanted) => {
|
|
35
|
+
if (found === undefined || found === wanted || found === previous) {
|
|
36
|
+
return wanted;
|
|
37
|
+
}
|
|
38
|
+
userOverrides.push(key);
|
|
39
|
+
return found;
|
|
40
|
+
};
|
|
41
|
+
const server = findSection(lines, serverPath);
|
|
42
|
+
const toolMode = (tool) =>
|
|
43
|
+
readKey(lines, findSection(lines, toolPath(tool)), 'approval_mode');
|
|
44
|
+
const desired = {
|
|
45
|
+
defaultToolsApprovalMode: keepUserValue(
|
|
46
|
+
'default_tools_approval_mode',
|
|
47
|
+
readKey(lines, server, 'default_tools_approval_mode'),
|
|
48
|
+
recorded.defaultToolsApprovalMode,
|
|
49
|
+
intended.defaultToolsApprovalMode,
|
|
50
|
+
),
|
|
51
|
+
tools: Object.fromEntries(
|
|
52
|
+
Object.entries(intended.tools).map(([tool, mode]) => [
|
|
53
|
+
tool,
|
|
54
|
+
keepUserValue(
|
|
55
|
+
`tools.${tomlKey(tool)}.approval_mode`,
|
|
56
|
+
toolMode(tool),
|
|
57
|
+
recorded.tools?.[tool],
|
|
58
|
+
mode,
|
|
59
|
+
),
|
|
60
|
+
]),
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
const released = Object.entries(recorded.tools ?? {})
|
|
64
|
+
.filter(
|
|
65
|
+
([tool, mode]) => !(tool in intended.tools) && toolMode(tool) === mode,
|
|
66
|
+
)
|
|
67
|
+
.map(([tool]) => tool);
|
|
68
|
+
const preserved = sections(lines)
|
|
69
|
+
.filter((section) => isToolPath(section.path))
|
|
70
|
+
.map((section) => lines.slice(section.start, contentEnd(lines, section)));
|
|
71
|
+
return {
|
|
72
|
+
path,
|
|
73
|
+
intended,
|
|
74
|
+
desired,
|
|
75
|
+
released,
|
|
76
|
+
preserved,
|
|
77
|
+
userOverrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function applyCodexApproval(plan, transaction) {
|
|
82
|
+
try {
|
|
83
|
+
return await writeCodexApproval(plan, transaction);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!(error instanceof UnmanagedCodexConfig)) throw error;
|
|
86
|
+
return {
|
|
87
|
+
path: plan.path,
|
|
88
|
+
skipped: `${error.message} Nothing in it was changed, so Codex keeps reviewing Engineering Memory tools itself; the rest of the installation is complete.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeCodexApproval(plan, transaction) {
|
|
94
|
+
const text = await readText(plan.path);
|
|
95
|
+
let lines = splitLines(text);
|
|
96
|
+
if (!findSection(lines, serverPath)) {
|
|
97
|
+
throw new UnmanagedCodexConfig(
|
|
98
|
+
`${plan.path} has no [mcp_servers.${mcpServerName}] table to hold the approval settings: the server is written as an inline table or dotted keys, or CODEX_HOME names a different Codex home from the one the codex CLI uses. Write the server as that table in the same Codex home, then rerun the installer to let its tools run without Codex review.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
for (const block of plan.preserved) {
|
|
102
|
+
const header = sections(block)[1];
|
|
103
|
+
if (!findSection(lines, header.path)) lines = appendToServer(lines, block);
|
|
104
|
+
}
|
|
105
|
+
for (const tool of plan.released) {
|
|
106
|
+
lines = removeKey(lines, toolPath(tool), 'approval_mode');
|
|
107
|
+
}
|
|
108
|
+
lines = setKey(
|
|
109
|
+
lines,
|
|
110
|
+
serverPath,
|
|
111
|
+
'default_tools_approval_mode',
|
|
112
|
+
plan.desired.defaultToolsApprovalMode,
|
|
113
|
+
);
|
|
114
|
+
for (const [tool, mode] of Object.entries(plan.desired.tools)) {
|
|
115
|
+
if (!findSection(lines, toolPath(tool))) {
|
|
116
|
+
const toolsSection = findSection(lines, [...serverPath, 'tools']);
|
|
117
|
+
const inlineTools = toolsSection
|
|
118
|
+
? undefined
|
|
119
|
+
: findInlineToolsAssignment(lines, findSection(lines, serverPath));
|
|
120
|
+
const existing = toolsSection
|
|
121
|
+
? readToolBodyMode(lines, toolsSection, tool)
|
|
122
|
+
: inlineTools !== undefined
|
|
123
|
+
? readToolFromBlock(inlineTools, tool)
|
|
124
|
+
: undefined;
|
|
125
|
+
if (existing !== undefined) {
|
|
126
|
+
if (existing === mode) continue;
|
|
127
|
+
throw new UnmanagedCodexConfig(
|
|
128
|
+
`Codex approval for ${tool} is already set to "${existing}" as a dotted key or inline table inside [mcp_servers.${mcpServerName}.tools]; the installer only manages a dedicated [mcp_servers.${mcpServerName}.tools.${tomlKey(tool)}] table for it. Change or remove that entry yourself, then rerun the installer.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (inlineTools !== undefined) {
|
|
132
|
+
throw new UnmanagedCodexConfig(
|
|
133
|
+
`Codex approval for ${tool} cannot be added: [mcp_servers.${mcpServerName}].tools is already set as an inline table (tools = { ... }), and Codex does not allow extending an inline table with a separate [mcp_servers.${mcpServerName}.tools.${tomlKey(tool)}] table. Add "${tool}" inside that inline table yourself (approval_mode = "${mode}"), or move tools into an explicit [mcp_servers.${mcpServerName}.tools] table, then rerun the installer.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
lines = appendToServer(lines, [
|
|
137
|
+
`[mcp_servers.${mcpServerName}.tools.${tomlKey(tool)}]`,
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
lines = setKey(lines, toolPath(tool), 'approval_mode', mode);
|
|
141
|
+
}
|
|
142
|
+
const updated = lines.join(text.includes('\r\n') ? '\r\n' : '\n');
|
|
143
|
+
if (updated !== text) await replaceFile(plan.path, updated, transaction);
|
|
144
|
+
return {
|
|
145
|
+
path: plan.path,
|
|
146
|
+
defaultToolsApprovalMode: plan.desired.defaultToolsApprovalMode,
|
|
147
|
+
reviewedTools: plan.desired.tools,
|
|
148
|
+
userOverrides: plan.userOverrides,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toolPath(tool) {
|
|
153
|
+
return [...serverPath, 'tools', tool];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isToolPath(path) {
|
|
157
|
+
return (
|
|
158
|
+
path?.length === 4 &&
|
|
159
|
+
path[0] === serverPath[0] &&
|
|
160
|
+
path[1] === serverPath[1] &&
|
|
161
|
+
path[2] === 'tools'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function tomlKey(key) {
|
|
166
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readToolBodyMode(lines, section, tool) {
|
|
170
|
+
const pattern = keyPattern(tool);
|
|
171
|
+
const dotted = new RegExp(
|
|
172
|
+
`^\\s*${pattern}\\.approval_mode\\s*=\\s*(?:"([^"]*)"|'([^']*)')`,
|
|
173
|
+
);
|
|
174
|
+
const inline = new RegExp(`^\\s*${pattern}\\s*=\\s*\\{([^}]*)\\}`);
|
|
175
|
+
for (const line of lines.slice(section.start + 1, section.end)) {
|
|
176
|
+
const dottedMatch = dotted.exec(line);
|
|
177
|
+
if (dottedMatch) return dottedMatch[1] ?? dottedMatch[2];
|
|
178
|
+
const inlineMatch = inline.exec(line);
|
|
179
|
+
if (inlineMatch) {
|
|
180
|
+
const valueMatch = /approval_mode\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(
|
|
181
|
+
inlineMatch[1],
|
|
182
|
+
);
|
|
183
|
+
if (valueMatch) return valueMatch[1] ?? valueMatch[2];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function findInlineToolsAssignment(lines, section) {
|
|
190
|
+
if (!section) return undefined;
|
|
191
|
+
const opens = /^\s*tools\s*=\s*\{/;
|
|
192
|
+
for (const line of lines.slice(section.start + 1, section.end)) {
|
|
193
|
+
if (!opens.test(line)) continue;
|
|
194
|
+
const openIndex = line.indexOf('{');
|
|
195
|
+
return extractBalancedBraces(line, openIndex) ?? line.slice(openIndex);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractBalancedBraces(line, openIndex) {
|
|
201
|
+
let depth = 0;
|
|
202
|
+
let index = openIndex;
|
|
203
|
+
while (index < line.length) {
|
|
204
|
+
const char = line[index];
|
|
205
|
+
if (char === '"' || char === "'") {
|
|
206
|
+
if (line.slice(index, index + 3) === char.repeat(3)) {
|
|
207
|
+
const delimiter = char.repeat(3);
|
|
208
|
+
const closeIndex = line.indexOf(delimiter, index + 3);
|
|
209
|
+
if (closeIndex === -1) return undefined;
|
|
210
|
+
index = closeIndex + 3;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const quote = char;
|
|
214
|
+
let k = index + 1;
|
|
215
|
+
while (k < line.length && line[k] !== quote) {
|
|
216
|
+
k += quote === '"' && line[k] === '\\' ? 2 : 1;
|
|
217
|
+
}
|
|
218
|
+
index = Math.min(k + 1, line.length);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (char === '{') depth += 1;
|
|
222
|
+
if (char === '}') {
|
|
223
|
+
depth -= 1;
|
|
224
|
+
if (depth === 0) return line.slice(openIndex, index + 1);
|
|
225
|
+
}
|
|
226
|
+
index += 1;
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readToolFromBlock(block, tool) {
|
|
232
|
+
const pattern = keyPattern(tool);
|
|
233
|
+
const dotted = new RegExp(
|
|
234
|
+
`${pattern}\\.approval_mode\\s*=\\s*(?:"([^"]*)"|'([^']*)')`,
|
|
235
|
+
);
|
|
236
|
+
const inline = new RegExp(`${pattern}\\s*=\\s*\\{([^{}]*)\\}`);
|
|
237
|
+
const dottedMatch = dotted.exec(block);
|
|
238
|
+
if (dottedMatch) return dottedMatch[1] ?? dottedMatch[2];
|
|
239
|
+
const inlineMatch = inline.exec(block);
|
|
240
|
+
if (inlineMatch) {
|
|
241
|
+
const valueMatch = /approval_mode\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(
|
|
242
|
+
inlineMatch[1],
|
|
243
|
+
);
|
|
244
|
+
if (valueMatch) return valueMatch[1] ?? valueMatch[2];
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function keyPattern(name) {
|
|
250
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
251
|
+
return /^[A-Za-z0-9_-]+$/.test(name)
|
|
252
|
+
? `(?:${escaped}|"${escaped}"|'${escaped}')`
|
|
253
|
+
: `(?:"${escaped}"|'${escaped}')`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function splitLines(text) {
|
|
257
|
+
return text === '' ? [] : text.split(/\r?\n/);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sections(lines) {
|
|
261
|
+
const found = [{ path: [], start: -1 }];
|
|
262
|
+
let state = { openString: null, depth: 0 };
|
|
263
|
+
lines.forEach((line, index) => {
|
|
264
|
+
if (state.openString === null && state.depth === 0) {
|
|
265
|
+
const path = headerPath(line);
|
|
266
|
+
if (path) {
|
|
267
|
+
found.push({ path, start: index });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
state = scanLine(line, state);
|
|
272
|
+
});
|
|
273
|
+
return found.map((section, index) => ({
|
|
274
|
+
...section,
|
|
275
|
+
end: found[index + 1]?.start ?? lines.length,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scanLine(line, state) {
|
|
280
|
+
let openString = state.openString;
|
|
281
|
+
let depth = state.depth;
|
|
282
|
+
let index = 0;
|
|
283
|
+
if (openString) {
|
|
284
|
+
const closeIndex = line.indexOf(openString, index);
|
|
285
|
+
if (closeIndex === -1) return { openString, depth };
|
|
286
|
+
index = closeIndex + openString.length;
|
|
287
|
+
openString = null;
|
|
288
|
+
}
|
|
289
|
+
while (index < line.length) {
|
|
290
|
+
const char = line[index];
|
|
291
|
+
if (char === '#') break;
|
|
292
|
+
if (
|
|
293
|
+
(char === '"' || char === "'") &&
|
|
294
|
+
line.slice(index, index + 3) === char.repeat(3)
|
|
295
|
+
) {
|
|
296
|
+
const delimiter = char.repeat(3);
|
|
297
|
+
const closeIndex = line.indexOf(delimiter, index + 3);
|
|
298
|
+
if (closeIndex === -1) {
|
|
299
|
+
openString = delimiter;
|
|
300
|
+
index = line.length;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
index = closeIndex + 3;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (char === '"') {
|
|
307
|
+
index += 1;
|
|
308
|
+
while (index < line.length && line[index] !== '"') {
|
|
309
|
+
index += line[index] === '\\' ? 2 : 1;
|
|
310
|
+
}
|
|
311
|
+
index += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (char === "'") {
|
|
315
|
+
const closeIndex = line.indexOf("'", index + 1);
|
|
316
|
+
index = closeIndex === -1 ? line.length : closeIndex + 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (char === '[' || char === '{') {
|
|
320
|
+
depth += 1;
|
|
321
|
+
index += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (char === ']' || char === '}') {
|
|
325
|
+
depth = Math.max(0, depth - 1);
|
|
326
|
+
index += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
index += 1;
|
|
330
|
+
}
|
|
331
|
+
return { openString, depth };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function headerPath(line) {
|
|
335
|
+
const match = /^\s*\[(?!\[)(.*)\]\s*(?:#.*)?$/.exec(line);
|
|
336
|
+
if (!match) return null;
|
|
337
|
+
const path = [];
|
|
338
|
+
const segment =
|
|
339
|
+
/\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(?:\.|$)/y;
|
|
340
|
+
while (segment.lastIndex < match[1].length) {
|
|
341
|
+
const found = segment.exec(match[1]);
|
|
342
|
+
if (!found) return null;
|
|
343
|
+
try {
|
|
344
|
+
path.push(
|
|
345
|
+
found[1] !== undefined
|
|
346
|
+
? JSON.parse(`"${found[1]}"`)
|
|
347
|
+
: (found[2] ?? found[3]),
|
|
348
|
+
);
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return path;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function findSection(lines, path) {
|
|
357
|
+
return sections(lines).find((section) => samePath(section.path, path));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function samePath(left, right) {
|
|
361
|
+
return (
|
|
362
|
+
left?.length === right.length &&
|
|
363
|
+
left.every((part, index) => part === right[index])
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readKey(lines, section, key) {
|
|
368
|
+
if (!section) return undefined;
|
|
369
|
+
const line = lines
|
|
370
|
+
.slice(section.start + 1, section.end)
|
|
371
|
+
.find((candidate) => assigns(candidate, key));
|
|
372
|
+
return line ? assignedValue(line, key) : undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function assigns(line, key) {
|
|
376
|
+
return new RegExp(`^\\s*${key}\\s*=`).test(line);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function assignedValue(line, key) {
|
|
380
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(
|
|
381
|
+
line,
|
|
382
|
+
);
|
|
383
|
+
return match ? (match[1] ?? match[2]) : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function replaceAssignedValue(line, key, value) {
|
|
387
|
+
const match = new RegExp(
|
|
388
|
+
`^(\\s*${key}\\s*=\\s*)(?:"[^"]*"|'[^']*')(.*)$`,
|
|
389
|
+
).exec(line);
|
|
390
|
+
if (!match) return `${key} = ${JSON.stringify(value)}`;
|
|
391
|
+
return `${match[1]}${JSON.stringify(value)}${match[2]}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function setKey(lines, path, key, value) {
|
|
395
|
+
const matches = sections(lines).filter((section) =>
|
|
396
|
+
samePath(section.path, path),
|
|
397
|
+
);
|
|
398
|
+
const located = matches
|
|
399
|
+
.map((section) => ({
|
|
400
|
+
section,
|
|
401
|
+
at: lines.findIndex(
|
|
402
|
+
(candidate, index) =>
|
|
403
|
+
index > section.start &&
|
|
404
|
+
index < section.end &&
|
|
405
|
+
assigns(candidate, key),
|
|
406
|
+
),
|
|
407
|
+
}))
|
|
408
|
+
.filter((entry) => entry.at >= 0);
|
|
409
|
+
if (located.length > 1) {
|
|
410
|
+
throw new UnmanagedCodexConfig(
|
|
411
|
+
`Refusing to set ${key} in [${path.join('.')}]: it is already set more than once across the tables Codex would load there. Remove the duplicate ${key} lines yourself, then rerun the installer.`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (located.length === 1) {
|
|
415
|
+
const { at } = located[0];
|
|
416
|
+
if (assignedValue(lines[at], key) === value) return lines;
|
|
417
|
+
return lines.with(at, replaceAssignedValue(lines[at], key, value));
|
|
418
|
+
}
|
|
419
|
+
const section = matches[0];
|
|
420
|
+
return lines.toSpliced(
|
|
421
|
+
section.start + 1,
|
|
422
|
+
0,
|
|
423
|
+
`${key} = ${JSON.stringify(value)}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function removeKey(lines, path, key) {
|
|
428
|
+
const section = findSection(lines, path);
|
|
429
|
+
if (!section) return lines;
|
|
430
|
+
const remaining = lines.filter(
|
|
431
|
+
(line, index) =>
|
|
432
|
+
!(index > section.start && index < section.end && assigns(line, key)),
|
|
433
|
+
);
|
|
434
|
+
const shrunk = findSection(remaining, path);
|
|
435
|
+
const body = remaining.slice(shrunk.start + 1, shrunk.end);
|
|
436
|
+
if (body.some((line) => line.trim() && !line.trim().startsWith('#'))) {
|
|
437
|
+
return remaining;
|
|
438
|
+
}
|
|
439
|
+
return remaining.toSpliced(shrunk.start, shrunk.end - shrunk.start);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function appendToServer(lines, block) {
|
|
443
|
+
const owned = sections(lines).filter(
|
|
444
|
+
(section) =>
|
|
445
|
+
section.path?.[0] === serverPath[0] &&
|
|
446
|
+
section.path?.[1] === serverPath[1],
|
|
447
|
+
);
|
|
448
|
+
const at = contentEnd(lines, owned.at(-1));
|
|
449
|
+
const gap = lines[at]?.trim() ? [''] : [];
|
|
450
|
+
return lines.toSpliced(at, 0, '', ...block, ...gap);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function contentEnd(lines, section) {
|
|
454
|
+
let end = section.end;
|
|
455
|
+
while (end > section.start + 1 && !lines[end - 1].trim()) end -= 1;
|
|
456
|
+
return end;
|
|
457
|
+
}
|
package/install/files.mjs
CHANGED
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
rm,
|
|
12
12
|
writeFile,
|
|
13
13
|
} from 'node:fs/promises';
|
|
14
|
-
import { basename, dirname, join } from 'node:path';
|
|
14
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
launcherPackageJson,
|
|
17
|
+
launcherSource,
|
|
18
|
+
normalizeRelativeEntry,
|
|
19
|
+
releasePointerName,
|
|
20
|
+
} from './launcher.mjs';
|
|
15
21
|
|
|
16
22
|
export class InstallTransaction {
|
|
17
23
|
constructor() {
|
|
@@ -90,8 +96,12 @@ export async function readJson(path) {
|
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
export async function replaceFile(path, content, transaction, mode) {
|
|
93
|
-
|
|
99
|
+
await rememberFile(path, transaction, mode);
|
|
94
100
|
await writeAtomic(path, content, mode);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function rememberFile(path, transaction, mode) {
|
|
104
|
+
const previous = (await exists(path)) ? await readFile(path) : null;
|
|
95
105
|
transaction.add(async () => {
|
|
96
106
|
if (previous === null) await rm(path, { force: true });
|
|
97
107
|
else await writeAtomic(path, previous, mode);
|
|
@@ -110,6 +120,7 @@ export async function installManagedDirectory(
|
|
|
110
120
|
) {
|
|
111
121
|
const artifact = options.artifact ?? 'skill';
|
|
112
122
|
const artifactLabel = artifact === 'skill' ? 'skill' : 'bridge runtime';
|
|
123
|
+
const accepted = new Set([artifact, ...(options.replaces ?? [])]);
|
|
113
124
|
const sourceHash = await hashDirectory(source);
|
|
114
125
|
const manifestPath = join(destination, '.engineering-memory-install.json');
|
|
115
126
|
if (await exists(destination)) {
|
|
@@ -117,7 +128,7 @@ export async function installManagedDirectory(
|
|
|
117
128
|
if (
|
|
118
129
|
manifest?.product !== 'engineering-memory' ||
|
|
119
130
|
manifest?.schemaVersion !== 1 ||
|
|
120
|
-
(manifest.artifact ?? 'skill')
|
|
131
|
+
!accepted.has(manifest.artifact ?? 'skill')
|
|
121
132
|
) {
|
|
122
133
|
throw new Error(
|
|
123
134
|
`Refusing to overwrite an unmanaged ${artifactLabel} directory: ${destination}`,
|
|
@@ -151,6 +162,7 @@ export async function installManagedDirectory(
|
|
|
151
162
|
schemaVersion: 1,
|
|
152
163
|
artifact,
|
|
153
164
|
sourceHash,
|
|
165
|
+
...(options.manifest ?? {}),
|
|
154
166
|
},
|
|
155
167
|
null,
|
|
156
168
|
2,
|
|
@@ -181,9 +193,22 @@ export async function installManagedDirectory(
|
|
|
181
193
|
|
|
182
194
|
export async function installManagedBridgeRuntime(
|
|
183
195
|
source,
|
|
184
|
-
|
|
196
|
+
runtimeRoot,
|
|
185
197
|
transaction,
|
|
198
|
+
options = {},
|
|
186
199
|
) {
|
|
200
|
+
const bridgeEntry = normalizeRelativeEntry(
|
|
201
|
+
options.bridgeEntry ?? 'dist/src/index.js',
|
|
202
|
+
'Bridge entry',
|
|
203
|
+
);
|
|
204
|
+
const gateEntry = normalizeRelativeEntry(
|
|
205
|
+
options.gateEntry ?? 'dist/src/git/pre-commit.js',
|
|
206
|
+
'Git gate entry',
|
|
207
|
+
);
|
|
208
|
+
const clientVersion =
|
|
209
|
+
typeof options.clientVersion === 'string' && options.clientVersion
|
|
210
|
+
? options.clientVersion
|
|
211
|
+
: null;
|
|
187
212
|
const packageJsonPath = join(source, 'package.json');
|
|
188
213
|
const packageLockPath = join(source, 'package-lock.json');
|
|
189
214
|
const nodeModulesPath = join(source, 'node_modules');
|
|
@@ -209,10 +234,8 @@ export async function installManagedBridgeRuntime(
|
|
|
209
234
|
packageJson,
|
|
210
235
|
packageLock,
|
|
211
236
|
);
|
|
212
|
-
await mkdir(
|
|
213
|
-
const bundle = await mkdtemp(
|
|
214
|
-
join(dirname(destination), '.bridge-runtime-source-'),
|
|
215
|
-
);
|
|
237
|
+
await mkdir(runtimeRoot, { recursive: true });
|
|
238
|
+
const bundle = await mkdtemp(join(runtimeRoot, '.bridge-runtime-source-'));
|
|
216
239
|
try {
|
|
217
240
|
await copyDirectory(join(source, 'dist'), join(bundle, 'dist'));
|
|
218
241
|
await copyFile(packageJsonPath, join(bundle, 'package.json'));
|
|
@@ -230,14 +253,101 @@ export async function installManagedBridgeRuntime(
|
|
|
230
253
|
join(bundle, ...packageEntry.path.split('/')),
|
|
231
254
|
);
|
|
232
255
|
}
|
|
233
|
-
|
|
256
|
+
for (const [entry, label] of [
|
|
257
|
+
[bridgeEntry, 'Bridge entry'],
|
|
258
|
+
[gateEntry, 'Git gate entry'],
|
|
259
|
+
]) {
|
|
260
|
+
if (!(await exists(join(bundle, ...entry.split('/'))))) {
|
|
261
|
+
throw new Error(`${label} is missing from the built bridge: ${entry}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const sourceHash = await hashDirectory(bundle);
|
|
265
|
+
const releasePath = join(runtimeRoot, 'releases', sourceHash.slice(0, 16));
|
|
266
|
+
const installedAt = new Date().toISOString();
|
|
267
|
+
await installManagedDirectory(bundle, releasePath, transaction, {
|
|
234
268
|
artifact: 'bridge-runtime',
|
|
269
|
+
manifest: { clientVersion, installedAt },
|
|
235
270
|
});
|
|
271
|
+
const launcherPath = join(runtimeRoot, 'bridge');
|
|
272
|
+
const launcherStage = await mkdtemp(join(runtimeRoot, '.bridge-launcher-'));
|
|
273
|
+
try {
|
|
274
|
+
await writeAtomic(
|
|
275
|
+
join(launcherStage, 'package.json'),
|
|
276
|
+
launcherPackageJson,
|
|
277
|
+
);
|
|
278
|
+
await writeAtomic(
|
|
279
|
+
join(launcherStage, ...bridgeEntry.split('/')),
|
|
280
|
+
launcherSource('bridgeEntry', bridgeEntry),
|
|
281
|
+
);
|
|
282
|
+
await writeAtomic(
|
|
283
|
+
join(launcherStage, ...gateEntry.split('/')),
|
|
284
|
+
launcherSource('gateEntry', gateEntry),
|
|
285
|
+
);
|
|
286
|
+
await installManagedDirectory(launcherStage, launcherPath, transaction, {
|
|
287
|
+
artifact: 'bridge-launcher',
|
|
288
|
+
replaces: ['bridge-runtime'],
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
await rm(launcherStage, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
await writeJson(
|
|
294
|
+
join(runtimeRoot, releasePointerName),
|
|
295
|
+
{
|
|
296
|
+
product: 'engineering-memory',
|
|
297
|
+
schemaVersion: 1,
|
|
298
|
+
version: clientVersion,
|
|
299
|
+
sourceHash,
|
|
300
|
+
path: releasePath,
|
|
301
|
+
bridgeEntry,
|
|
302
|
+
gateEntry,
|
|
303
|
+
installedAt,
|
|
304
|
+
},
|
|
305
|
+
transaction,
|
|
306
|
+
);
|
|
307
|
+
return { sourceHash, releasePath, launcherPath, bridgeEntry, gateEntry };
|
|
236
308
|
} finally {
|
|
237
309
|
await rm(bundle, { recursive: true, force: true });
|
|
238
310
|
}
|
|
239
311
|
}
|
|
240
312
|
|
|
313
|
+
export async function pruneReleases(runtimeRoot, options = {}) {
|
|
314
|
+
const keep = Math.max(1, options.keep ?? 3);
|
|
315
|
+
const releasesPath = join(runtimeRoot, 'releases');
|
|
316
|
+
if (!(await exists(releasesPath))) return [];
|
|
317
|
+
const pointer = await readJson(join(runtimeRoot, releasePointerName));
|
|
318
|
+
const currentPath =
|
|
319
|
+
pointer && typeof pointer.path === 'string' ? resolve(pointer.path) : null;
|
|
320
|
+
const candidates = [];
|
|
321
|
+
for (const entry of await readdir(releasesPath, { withFileTypes: true })) {
|
|
322
|
+
if (!entry.isDirectory()) continue;
|
|
323
|
+
const path = join(releasesPath, entry.name);
|
|
324
|
+
if (currentPath && resolve(path) === currentPath) continue;
|
|
325
|
+
const manifest = await readJson(
|
|
326
|
+
join(path, '.engineering-memory-install.json'),
|
|
327
|
+
);
|
|
328
|
+
if (
|
|
329
|
+
manifest?.product !== 'engineering-memory' ||
|
|
330
|
+
manifest.artifact !== 'bridge-runtime'
|
|
331
|
+
) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const installedAt = Date.parse(String(manifest.installedAt ?? ''));
|
|
335
|
+
candidates.push({
|
|
336
|
+
path,
|
|
337
|
+
installedAt: Number.isNaN(installedAt)
|
|
338
|
+
? (await lstat(path)).mtimeMs
|
|
339
|
+
: installedAt,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
candidates.sort((left, right) => right.installedAt - left.installedAt);
|
|
343
|
+
const removed = [];
|
|
344
|
+
for (const candidate of candidates.slice(keep - 1)) {
|
|
345
|
+
await rm(candidate.path, { recursive: true, force: true });
|
|
346
|
+
removed.push(candidate.path);
|
|
347
|
+
}
|
|
348
|
+
return removed;
|
|
349
|
+
}
|
|
350
|
+
|
|
241
351
|
export async function hashDirectory(root, options = {}) {
|
|
242
352
|
const hash = createHash('sha256');
|
|
243
353
|
const excluded = new Set(options.exclude ?? []);
|