engineering-memory 0.1.0
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/bin/engineering-memory.mjs +120 -0
- package/dispatcher/managed-section.mjs +59 -0
- package/dispatcher/sections.mjs +14 -0
- package/install/api-url.mjs +39 -0
- package/install/cli.mjs +93 -0
- package/install/commands.mjs +140 -0
- package/install/files.mjs +416 -0
- package/install/git-hook.mjs +270 -0
- package/install/installer.mjs +279 -0
- package/install/mcp-registration.mjs +457 -0
- package/package.json +28 -0
- package/runtime/dist/src/auth/browser-auth.js +184 -0
- package/runtime/dist/src/auth/credential-store.js +181 -0
- package/runtime/dist/src/cache/etag-cache.js +123 -0
- package/runtime/dist/src/config.js +59 -0
- package/runtime/dist/src/git/git-inspector.js +375 -0
- package/runtime/dist/src/git/pre-commit.js +44 -0
- package/runtime/dist/src/git/verification-gate.js +221 -0
- package/runtime/dist/src/index.js +60 -0
- package/runtime/dist/src/journal/journal-store.js +1300 -0
- package/runtime/dist/src/mcp/server.js +11 -0
- package/runtime/dist/src/mcp/tool-definitions.js +405 -0
- package/runtime/dist/src/project/repository.js +79 -0
- package/runtime/dist/src/runtime/active-context-store.js +356 -0
- package/runtime/dist/src/runtime/api-client.js +229 -0
- package/runtime/dist/src/runtime/bridge-service.js +2226 -0
- package/runtime/dist/src/runtime/offline-outbox.js +274 -0
- package/runtime/dist/src/runtime/principal-state.js +97 -0
- package/runtime/dist/src/types.js +2 -0
- package/runtime/dist/src/utilities/files.js +189 -0
- package/runtime/dist/src/utilities/hash.js +19 -0
- package/runtime/dist/src/utilities/process.js +32 -0
- package/runtime/package-lock.json +137 -0
- package/runtime/package.json +32 -0
- package/skill/SKILL.md +29 -0
- package/skill/agents/openai.yaml +6 -0
- package/skill/references/lifecycle.md +102 -0
- package/skill/references/memory-updates.md +25 -0
- package/skill/references/questionnaires.md +98 -0
- package/skill/references/scaffolding.md +38 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { assertManagedPath, canonicalPath } from '../utilities/files.js';
|
|
4
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
5
|
+
import { NativeCommandRunner } from '../utilities/process.js';
|
|
6
|
+
export class GitInspector {
|
|
7
|
+
runner;
|
|
8
|
+
constructor(runner = new NativeCommandRunner()) {
|
|
9
|
+
this.runner = runner;
|
|
10
|
+
}
|
|
11
|
+
async findRoot(startPath) {
|
|
12
|
+
const result = await this.runner.run('git', ['rev-parse', '--show-toplevel'], {
|
|
13
|
+
cwd: startPath,
|
|
14
|
+
});
|
|
15
|
+
if (result.exitCode !== 0) {
|
|
16
|
+
throw new Error(`Not a Git repository: ${startPath}`);
|
|
17
|
+
}
|
|
18
|
+
return await canonicalPath(result.stdout.trim());
|
|
19
|
+
}
|
|
20
|
+
async fingerprint(repoRoot) {
|
|
21
|
+
const [remote, firstCommit] = await Promise.all([
|
|
22
|
+
this.gitValue(repoRoot, ['config', '--get', 'remote.origin.url']),
|
|
23
|
+
this.gitValue(repoRoot, ['rev-list', '--max-parents=0', 'HEAD']),
|
|
24
|
+
]);
|
|
25
|
+
const canonicalRoot = await canonicalPath(repoRoot);
|
|
26
|
+
const identity = remote
|
|
27
|
+
? `${canonicalRemoteIdentity(remote)}\n${firstCommit ?? ''}`
|
|
28
|
+
: `${canonicalRoot.toLowerCase()}\n${firstCommit ?? ''}`;
|
|
29
|
+
return sha256(identity);
|
|
30
|
+
}
|
|
31
|
+
async manifest(repoRoot) {
|
|
32
|
+
const root = await this.findRoot(repoRoot);
|
|
33
|
+
const head = await this.gitValue(root, ['rev-parse', 'HEAD']);
|
|
34
|
+
const changedPaths = head
|
|
35
|
+
? await this.changedPathsAgainstHead(root)
|
|
36
|
+
: await this.unbornChangedPaths(root);
|
|
37
|
+
const normalizedManifest = changedPaths.map(({ path, originalPath, status, contentHash, size, mode }) => ({
|
|
38
|
+
path,
|
|
39
|
+
originalPath: originalPath ?? null,
|
|
40
|
+
status: semanticStatus(status),
|
|
41
|
+
contentHash,
|
|
42
|
+
size,
|
|
43
|
+
mode: mode ?? null,
|
|
44
|
+
}));
|
|
45
|
+
return {
|
|
46
|
+
repoRoot: root,
|
|
47
|
+
head,
|
|
48
|
+
changedPaths,
|
|
49
|
+
diffHash: sha256(`${head ?? '<unborn>'}\n${stableStringify(normalizedManifest)}\n`),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async stagedManifest(repoRoot) {
|
|
53
|
+
const root = await this.findRoot(repoRoot);
|
|
54
|
+
let result = await this.runner.run('git', ['diff', '--cached', '--name-status', '-z', '--find-renames', '--find-copies', 'HEAD', '--'], { cwd: root });
|
|
55
|
+
if (result.exitCode !== 0) {
|
|
56
|
+
result = await this.runner.run('git', ['diff', '--cached', '--name-status', '-z', '--find-renames', '--find-copies', '--'], { cwd: root });
|
|
57
|
+
}
|
|
58
|
+
if (result.exitCode !== 0) {
|
|
59
|
+
throw new Error(`Git staged diff failed: ${result.stderr.trim()}`);
|
|
60
|
+
}
|
|
61
|
+
const tokens = result.stdout.split('\0').filter(Boolean);
|
|
62
|
+
const stagedPaths = [];
|
|
63
|
+
for (let index = 0; index < tokens.length;) {
|
|
64
|
+
const status = tokens[index++];
|
|
65
|
+
if (!status || !/^(?:[ACDMRTUXB]|[RC]\d{1,3})$/.test(status)) {
|
|
66
|
+
throw new Error('Git returned an invalid staged status');
|
|
67
|
+
}
|
|
68
|
+
let originalPath;
|
|
69
|
+
if (/^[RC]/.test(status)) {
|
|
70
|
+
const original = tokens[index++];
|
|
71
|
+
if (!original) {
|
|
72
|
+
throw new Error('Git staged rename or copy is missing its original path');
|
|
73
|
+
}
|
|
74
|
+
originalPath = normalizeGitPath(original);
|
|
75
|
+
}
|
|
76
|
+
const stagedPath = tokens[index++];
|
|
77
|
+
if (!stagedPath) {
|
|
78
|
+
throw new Error('Git staged change is missing its destination path');
|
|
79
|
+
}
|
|
80
|
+
const path = normalizeGitPath(stagedPath);
|
|
81
|
+
const indexEntry = status === 'D' ? null : await this.indexEntry(root, path);
|
|
82
|
+
stagedPaths.push({
|
|
83
|
+
path,
|
|
84
|
+
...(originalPath ? { originalPath } : {}),
|
|
85
|
+
status,
|
|
86
|
+
blobOid: indexEntry?.blobOid ?? null,
|
|
87
|
+
mode: indexEntry?.mode ?? null,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return stagedPaths.sort((left, right) => left.path.localeCompare(right.path));
|
|
91
|
+
}
|
|
92
|
+
async taskManifest(repoRoot, changes) {
|
|
93
|
+
const root = await this.findRoot(repoRoot);
|
|
94
|
+
const result = [];
|
|
95
|
+
for (const change of changes) {
|
|
96
|
+
const path = normalizeGitPath(change.path);
|
|
97
|
+
const worktreeEntry = semanticStatus(change.status) === 'D' ? null : await this.worktreeEntry(root, path);
|
|
98
|
+
result.push({
|
|
99
|
+
...change,
|
|
100
|
+
path,
|
|
101
|
+
...(change.originalPath ? { originalPath: normalizeGitPath(change.originalPath) } : {}),
|
|
102
|
+
blobOid: worktreeEntry?.blobOid ?? null,
|
|
103
|
+
mode: change.mode ?? worktreeEntry?.mode ?? null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return result.sort((left, right) => left.path.localeCompare(right.path));
|
|
107
|
+
}
|
|
108
|
+
async changedPathsAgainstHead(repoRoot) {
|
|
109
|
+
const [tracked, raw, untracked] = await Promise.all([
|
|
110
|
+
this.runner.run('git', ['diff', 'HEAD', '--name-status', '-z', '--find-renames', '--find-copies', '--'], { cwd: repoRoot }),
|
|
111
|
+
this.runner.run('git', ['diff', 'HEAD', '--raw', '-z', '--no-abbrev', '--'], {
|
|
112
|
+
cwd: repoRoot,
|
|
113
|
+
}),
|
|
114
|
+
this.runner.run('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
|
|
115
|
+
cwd: repoRoot,
|
|
116
|
+
}),
|
|
117
|
+
]);
|
|
118
|
+
if (tracked.exitCode !== 0) {
|
|
119
|
+
throw new Error(`Git diff failed: ${tracked.stderr.trim()}`);
|
|
120
|
+
}
|
|
121
|
+
if (raw.exitCode !== 0) {
|
|
122
|
+
throw new Error(`Git raw diff failed: ${raw.stderr.trim()}`);
|
|
123
|
+
}
|
|
124
|
+
if (untracked.exitCode !== 0) {
|
|
125
|
+
throw new Error(`Git untracked file lookup failed: ${untracked.stderr.trim()}`);
|
|
126
|
+
}
|
|
127
|
+
const result = await this.parseNameStatus(repoRoot, tracked.stdout, parseRawModes(raw.stdout));
|
|
128
|
+
for (const token of untracked.stdout.split('\0').filter(Boolean)) {
|
|
129
|
+
const path = normalizeGitPath(token);
|
|
130
|
+
const metadata = await this.fileMetadata(repoRoot, path);
|
|
131
|
+
result.push({
|
|
132
|
+
path,
|
|
133
|
+
status: '??',
|
|
134
|
+
contentHash: metadata.contentHash,
|
|
135
|
+
size: metadata.size,
|
|
136
|
+
mode: metadata.mode,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return deduplicateChangedPaths(result);
|
|
140
|
+
}
|
|
141
|
+
async unbornChangedPaths(repoRoot) {
|
|
142
|
+
const result = await this.runner.run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: repoRoot });
|
|
143
|
+
if (result.exitCode !== 0) {
|
|
144
|
+
throw new Error(`Git unborn repository lookup failed: ${result.stderr.trim()}`);
|
|
145
|
+
}
|
|
146
|
+
const changedPaths = [];
|
|
147
|
+
for (const token of result.stdout.split('\0').filter(Boolean)) {
|
|
148
|
+
const path = normalizeGitPath(token);
|
|
149
|
+
const metadata = await this.fileMetadata(repoRoot, path);
|
|
150
|
+
changedPaths.push({
|
|
151
|
+
path,
|
|
152
|
+
status: 'A ',
|
|
153
|
+
contentHash: metadata.contentHash,
|
|
154
|
+
size: metadata.size,
|
|
155
|
+
mode: metadata.mode,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return deduplicateChangedPaths(changedPaths);
|
|
159
|
+
}
|
|
160
|
+
async parseNameStatus(repoRoot, output, modes) {
|
|
161
|
+
const tokens = output.split('\0').filter(Boolean);
|
|
162
|
+
const result = [];
|
|
163
|
+
for (let index = 0; index < tokens.length;) {
|
|
164
|
+
const statusToken = tokens[index++];
|
|
165
|
+
if (!statusToken || !/^(?:[ACDMRTUXB]|[RC]\d{1,3})$/.test(statusToken)) {
|
|
166
|
+
throw new Error('Git returned an invalid changed-path status');
|
|
167
|
+
}
|
|
168
|
+
let originalPath;
|
|
169
|
+
if (/^[RC]/.test(statusToken)) {
|
|
170
|
+
const original = tokens[index++];
|
|
171
|
+
if (!original) {
|
|
172
|
+
throw new Error('Git rename or copy is missing its original path');
|
|
173
|
+
}
|
|
174
|
+
originalPath = normalizeGitPath(original);
|
|
175
|
+
}
|
|
176
|
+
const destination = tokens[index++];
|
|
177
|
+
if (!destination) {
|
|
178
|
+
throw new Error('Git changed path is missing its destination');
|
|
179
|
+
}
|
|
180
|
+
const path = normalizeGitPath(destination);
|
|
181
|
+
const metadata = await this.fileMetadata(repoRoot, path);
|
|
182
|
+
result.push({
|
|
183
|
+
path,
|
|
184
|
+
...(originalPath ? { originalPath } : {}),
|
|
185
|
+
status: `${statusToken[0]} `,
|
|
186
|
+
contentHash: metadata.contentHash,
|
|
187
|
+
size: metadata.size,
|
|
188
|
+
mode: modes.get(path) ?? metadata.mode,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
async fileMetadata(repoRoot, gitPath) {
|
|
194
|
+
const absolutePath = resolve(repoRoot, gitPath.split('/').join(sep));
|
|
195
|
+
const fromRoot = relative(repoRoot, absolutePath);
|
|
196
|
+
if (fromRoot.startsWith('..') || isAbsolute(fromRoot)) {
|
|
197
|
+
throw new Error(`Git changed path escapes the repository: ${gitPath}`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const safePath = await assertManagedPath(repoRoot, absolutePath, false);
|
|
201
|
+
const fileStat = await lstat(safePath);
|
|
202
|
+
if (fileStat.isSymbolicLink()) {
|
|
203
|
+
throw new Error(`Git changed path is a symbolic link: ${gitPath}`);
|
|
204
|
+
}
|
|
205
|
+
if (fileStat.isFile()) {
|
|
206
|
+
return {
|
|
207
|
+
contentHash: sha256(await readFile(safePath)),
|
|
208
|
+
size: fileStat.size,
|
|
209
|
+
mode: fileStat.mode & 0o111 ? '100755' : '100644',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { contentHash: null, size: null, mode: null };
|
|
219
|
+
}
|
|
220
|
+
async indexEntry(repoRoot, path) {
|
|
221
|
+
const result = await this.runner.run('git', ['ls-files', '--stage', '-z', '--', path], {
|
|
222
|
+
cwd: repoRoot,
|
|
223
|
+
});
|
|
224
|
+
if (result.exitCode !== 0) {
|
|
225
|
+
throw new Error(`Git index lookup failed: ${result.stderr.trim()}`);
|
|
226
|
+
}
|
|
227
|
+
const entries = result.stdout.split('\0').filter(Boolean);
|
|
228
|
+
const matches = entries.flatMap((entry) => {
|
|
229
|
+
const match = /^(\d{6}) ([0-9a-f]{40,64}) ([0-3])\t(.+)$/.exec(entry);
|
|
230
|
+
if (!match || normalizeGitPath(match[4]) !== path) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
return [{ mode: match[1], oid: match[2], stage: match[3] }];
|
|
234
|
+
});
|
|
235
|
+
if (matches.length !== 1 ||
|
|
236
|
+
matches[0].stage !== '0' ||
|
|
237
|
+
!['100644', '100755'].includes(matches[0].mode)) {
|
|
238
|
+
throw new Error(`Git staged path is not a regular stage-zero file: ${path}`);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
blobOid: matches[0].oid,
|
|
242
|
+
mode: matches[0].mode,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async worktreeEntry(repoRoot, path) {
|
|
246
|
+
const absolutePath = resolve(repoRoot, path.split('/').join(sep));
|
|
247
|
+
const safePath = await assertManagedPath(repoRoot, absolutePath, false);
|
|
248
|
+
const fileStat = await lstat(safePath);
|
|
249
|
+
if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
|
|
250
|
+
throw new Error(`Git task path is not a regular file: ${path}`);
|
|
251
|
+
}
|
|
252
|
+
const result = await this.runner.run('git', ['hash-object', `--path=${path}`, '--', path], {
|
|
253
|
+
cwd: repoRoot,
|
|
254
|
+
});
|
|
255
|
+
const oid = result.stdout.trim().split(/\r?\n/)[0];
|
|
256
|
+
if (result.exitCode !== 0 || !oid || !/^[0-9a-f]{40,64}$/.test(oid)) {
|
|
257
|
+
throw new Error(`Git worktree blob lookup failed: ${path}`);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
blobOid: oid,
|
|
261
|
+
mode: fileStat.mode & 0o111 ? '100755' : '100644',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async pathHistorySubjects(repoRoot, paths) {
|
|
265
|
+
if (paths.length === 0)
|
|
266
|
+
return [];
|
|
267
|
+
const root = await this.findRoot(repoRoot);
|
|
268
|
+
const result = await this.runner.run('git', ['log', '--max-count=100', '--pretty=%s', '--', ...paths], { cwd: root });
|
|
269
|
+
if (result.exitCode !== 0)
|
|
270
|
+
return [];
|
|
271
|
+
return result.stdout
|
|
272
|
+
.split(/\r?\n/)
|
|
273
|
+
.map((line) => line.trim())
|
|
274
|
+
.filter(Boolean);
|
|
275
|
+
}
|
|
276
|
+
async gitValue(repoRoot, args) {
|
|
277
|
+
const result = await this.runner.run('git', args, { cwd: repoRoot });
|
|
278
|
+
const value = result.stdout.trim().split(/\r?\n/)[0];
|
|
279
|
+
return result.exitCode === 0 && value ? value : null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function normalizeGitPath(path) {
|
|
283
|
+
const normalized = path.replace(/\\/g, '/');
|
|
284
|
+
if (!normalized ||
|
|
285
|
+
normalized.startsWith('/') ||
|
|
286
|
+
/^[A-Za-z]:\//.test(normalized) ||
|
|
287
|
+
normalized.split('/').includes('..') ||
|
|
288
|
+
normalized.includes('\0')) {
|
|
289
|
+
throw new Error(`Git returned an unsafe changed path: ${normalized}`);
|
|
290
|
+
}
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
function semanticStatus(status) {
|
|
294
|
+
if (status === '??') {
|
|
295
|
+
return 'A';
|
|
296
|
+
}
|
|
297
|
+
for (const candidate of ['R', 'C', 'A', 'D', 'T', 'U', 'M']) {
|
|
298
|
+
if (status.includes(candidate)) {
|
|
299
|
+
return candidate;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return status.trim() || status;
|
|
303
|
+
}
|
|
304
|
+
function deduplicateChangedPaths(entries) {
|
|
305
|
+
const byPath = new Map();
|
|
306
|
+
for (const entry of entries) {
|
|
307
|
+
if (byPath.has(entry.path)) {
|
|
308
|
+
throw new Error(`Git returned duplicate changed path metadata: ${entry.path}`);
|
|
309
|
+
}
|
|
310
|
+
byPath.set(entry.path, entry);
|
|
311
|
+
}
|
|
312
|
+
return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
313
|
+
}
|
|
314
|
+
function parseRawModes(output) {
|
|
315
|
+
const tokens = output.split('\0').filter(Boolean);
|
|
316
|
+
const modes = new Map();
|
|
317
|
+
for (let index = 0; index < tokens.length;) {
|
|
318
|
+
const header = tokens[index++];
|
|
319
|
+
const match = header ? /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])\d*$/.exec(header) : null;
|
|
320
|
+
if (!match) {
|
|
321
|
+
throw new Error('Git returned invalid raw mode metadata');
|
|
322
|
+
}
|
|
323
|
+
if (match[3] === 'R' || match[3] === 'C') {
|
|
324
|
+
if (!tokens[index++]) {
|
|
325
|
+
throw new Error('Git raw rename or copy is missing its original path');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const destination = tokens[index++];
|
|
329
|
+
if (!destination) {
|
|
330
|
+
throw new Error('Git raw mode metadata is missing its destination path');
|
|
331
|
+
}
|
|
332
|
+
const path = normalizeGitPath(destination);
|
|
333
|
+
const mode = match[2] === '000000' ? null : match[2];
|
|
334
|
+
if (mode !== null && mode !== '100644' && mode !== '100755') {
|
|
335
|
+
throw new Error(`Git changed path does not have a regular file mode: ${path}`);
|
|
336
|
+
}
|
|
337
|
+
modes.set(path, mode);
|
|
338
|
+
}
|
|
339
|
+
return modes;
|
|
340
|
+
}
|
|
341
|
+
function canonicalRemoteIdentity(remote) {
|
|
342
|
+
const trimmed = remote.trim();
|
|
343
|
+
const scpLike = /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(trimmed);
|
|
344
|
+
if (scpLike &&
|
|
345
|
+
!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) &&
|
|
346
|
+
!/^[A-Za-z]:[\\/]/.test(trimmed)) {
|
|
347
|
+
return `${scpLike[1].toLowerCase()}/${normalizeRemotePath(scpLike[2])}`;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const url = new URL(trimmed);
|
|
351
|
+
const protocol = url.protocol.toLowerCase();
|
|
352
|
+
const defaultPort = (protocol === 'https:' && url.port === '443') ||
|
|
353
|
+
(protocol === 'http:' && url.port === '80') ||
|
|
354
|
+
(protocol === 'ssh:' && url.port === '22');
|
|
355
|
+
const authority = `${url.hostname.toLowerCase()}${url.port && !defaultPort ? `:${url.port}` : ''}`;
|
|
356
|
+
if (authority) {
|
|
357
|
+
return `${authority}/${normalizeRemotePath(url.pathname)}`;
|
|
358
|
+
}
|
|
359
|
+
return `local/${normalizeRemotePath(url.pathname)}`;
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return `local/${normalizeRemotePath(trimmed.replace(/[?#].*$/, ''))}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function normalizeRemotePath(path) {
|
|
366
|
+
const normalized = path
|
|
367
|
+
.replace(/\\/g, '/')
|
|
368
|
+
.replace(/^\/+|\/+$/g, '')
|
|
369
|
+
.replace(/\.git$/i, '');
|
|
370
|
+
if (!normalized || normalized.split('/').includes('..')) {
|
|
371
|
+
throw new Error('Git remote path is invalid');
|
|
372
|
+
}
|
|
373
|
+
return normalized;
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=git-inspector.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createCredentialStore } from '../auth/credential-store.js';
|
|
3
|
+
import { EtagCache } from '../cache/etag-cache.js';
|
|
4
|
+
import { apiNamespaceKey, apiStateRoot, endpoints, loadBridgeConfig } from '../config.js';
|
|
5
|
+
import { ApiClient } from '../runtime/api-client.js';
|
|
6
|
+
import { OfflineOutbox } from '../runtime/offline-outbox.js';
|
|
7
|
+
import { GitInspector } from './git-inspector.js';
|
|
8
|
+
import { VerificationGate } from './verification-gate.js';
|
|
9
|
+
async function run() {
|
|
10
|
+
const config = loadBridgeConfig();
|
|
11
|
+
const stateRoot = apiStateRoot(config);
|
|
12
|
+
const credentials = createCredentialStore({
|
|
13
|
+
service: config.credentialService,
|
|
14
|
+
account: `${config.credentialAccount}:${apiNamespaceKey(config.apiBaseUrl)}`,
|
|
15
|
+
stateRoot,
|
|
16
|
+
});
|
|
17
|
+
const client = new ApiClient({
|
|
18
|
+
baseUrl: config.apiBaseUrl,
|
|
19
|
+
timeoutMs: config.requestTimeoutMs,
|
|
20
|
+
credentials,
|
|
21
|
+
cache: new EtagCache({
|
|
22
|
+
stateRoot,
|
|
23
|
+
maxEntries: config.cacheMaxEntries,
|
|
24
|
+
maxBytes: config.cacheMaxBytes,
|
|
25
|
+
maxAgeMs: config.cacheMaxAgeMs,
|
|
26
|
+
}),
|
|
27
|
+
refreshPath: endpoints.authRefresh,
|
|
28
|
+
});
|
|
29
|
+
const git = new GitInspector();
|
|
30
|
+
const repoRoot = await git.findRoot(process.argv[2] ?? process.cwd());
|
|
31
|
+
const gate = new VerificationGate(stateRoot, git, new OfflineOutbox(stateRoot), async (body) => (await client.request(endpoints.taskCommitGate, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body,
|
|
34
|
+
retryRefresh: true,
|
|
35
|
+
})).data);
|
|
36
|
+
const result = await gate.verify(repoRoot);
|
|
37
|
+
process.stderr.write(`${JSON.stringify(result)}\n`);
|
|
38
|
+
process.exitCode = result.allowed ? 0 : 1;
|
|
39
|
+
}
|
|
40
|
+
void run().catch(() => {
|
|
41
|
+
process.stderr.write('Engineering Memory Git gate failed\n');
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
});
|
|
44
|
+
//# sourceMappingURL=pre-commit.js.map
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureManagedDirectory, readJson, removeFile, safeSegment, writeJson, } from '../utilities/files.js';
|
|
4
|
+
export class VerificationGate {
|
|
5
|
+
git;
|
|
6
|
+
outbox;
|
|
7
|
+
commitGateAttestor;
|
|
8
|
+
root;
|
|
9
|
+
constructor(stateRoot, git, outbox, commitGateAttestor) {
|
|
10
|
+
this.git = git;
|
|
11
|
+
this.outbox = outbox;
|
|
12
|
+
this.commitGateAttestor = commitGateAttestor;
|
|
13
|
+
this.root = join(stateRoot, 'verification-gates');
|
|
14
|
+
}
|
|
15
|
+
async record(input) {
|
|
16
|
+
if (input.taskClosed &&
|
|
17
|
+
(!Number.isSafeInteger(input.taskVersion) || (input.taskVersion ?? 0) < 1)) {
|
|
18
|
+
throw new Error('Closed task verification receipt requires its backend task version');
|
|
19
|
+
}
|
|
20
|
+
const verifiedAt = new Date();
|
|
21
|
+
const taskChanges = await this.git.taskManifest(input.repoRoot, input.taskChanges);
|
|
22
|
+
await writeJson(this.pathFor(input.repoFingerprint), {
|
|
23
|
+
schemaVersion: 4,
|
|
24
|
+
taskId: input.taskId,
|
|
25
|
+
projectId: input.projectId,
|
|
26
|
+
repoFingerprint: input.repoFingerprint,
|
|
27
|
+
diffHash: input.diffHash,
|
|
28
|
+
verifiedAt: verifiedAt.toISOString(),
|
|
29
|
+
expiresAt: input.taskClosed
|
|
30
|
+
? null
|
|
31
|
+
: new Date(verifiedAt.getTime() + (input.ttlMs ?? 15 * 60_000)).toISOString(),
|
|
32
|
+
taskClosed: input.taskClosed ?? false,
|
|
33
|
+
taskVersion: input.taskVersion ?? null,
|
|
34
|
+
taskChanges,
|
|
35
|
+
}, this.root);
|
|
36
|
+
}
|
|
37
|
+
async verify(repoRoot) {
|
|
38
|
+
const [fingerprint, manifest, stagedChanges, pending] = await Promise.all([
|
|
39
|
+
this.git.fingerprint(repoRoot),
|
|
40
|
+
this.git.manifest(repoRoot),
|
|
41
|
+
this.git.stagedManifest(repoRoot),
|
|
42
|
+
this.outbox.list(),
|
|
43
|
+
]);
|
|
44
|
+
if (pending.length > 0) {
|
|
45
|
+
return { allowed: false, reason: 'offline_outbox_pending', diffHash: manifest.diffHash };
|
|
46
|
+
}
|
|
47
|
+
const receipt = await readJson(this.pathFor(fingerprint), this.root);
|
|
48
|
+
if (!receipt) {
|
|
49
|
+
return { allowed: false, reason: 'task_verify_required', diffHash: manifest.diffHash };
|
|
50
|
+
}
|
|
51
|
+
if (!receipt.taskClosed) {
|
|
52
|
+
return {
|
|
53
|
+
allowed: false,
|
|
54
|
+
reason: 'task_close_required',
|
|
55
|
+
diffHash: manifest.diffHash,
|
|
56
|
+
taskId: receipt.taskId,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (receipt.schemaVersion !== 4 ||
|
|
60
|
+
!receipt.projectId ||
|
|
61
|
+
typeof receipt.taskVersion !== 'number' ||
|
|
62
|
+
!Number.isSafeInteger(receipt.taskVersion) ||
|
|
63
|
+
receipt.taskVersion < 1 ||
|
|
64
|
+
!isTaskManifest(receipt.taskChanges)) {
|
|
65
|
+
return {
|
|
66
|
+
allowed: false,
|
|
67
|
+
reason: 'closed_receipt_invalid',
|
|
68
|
+
diffHash: manifest.diffHash,
|
|
69
|
+
taskId: receipt.taskId,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (receipt.diffHash !== manifest.diffHash) {
|
|
73
|
+
return {
|
|
74
|
+
allowed: false,
|
|
75
|
+
reason: 'git_diff_changed_after_verify',
|
|
76
|
+
diffHash: manifest.diffHash,
|
|
77
|
+
taskId: receipt.taskId,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (stagedChanges.length === 0) {
|
|
81
|
+
return {
|
|
82
|
+
allowed: false,
|
|
83
|
+
reason: 'no_task_changes_staged',
|
|
84
|
+
diffHash: manifest.diffHash,
|
|
85
|
+
taskId: receipt.taskId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (stagedChanges.some((entry) => !belongsToTask(entry, receipt.taskChanges))) {
|
|
89
|
+
return {
|
|
90
|
+
allowed: false,
|
|
91
|
+
reason: 'staged_change_outside_task',
|
|
92
|
+
diffHash: manifest.diffHash,
|
|
93
|
+
taskId: receipt.taskId,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!this.commitGateAttestor) {
|
|
97
|
+
return {
|
|
98
|
+
allowed: false,
|
|
99
|
+
reason: 'online_commit_gate_unavailable',
|
|
100
|
+
diffHash: manifest.diffHash,
|
|
101
|
+
taskId: receipt.taskId,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const attestation = await this.commitGateAttestor({
|
|
106
|
+
taskId: receipt.taskId,
|
|
107
|
+
projectId: receipt.projectId,
|
|
108
|
+
repoFingerprint: receipt.repoFingerprint,
|
|
109
|
+
diffHash: manifest.diffHash,
|
|
110
|
+
expectedTaskVersion: receipt.taskVersion,
|
|
111
|
+
});
|
|
112
|
+
const result = asObject(attestation);
|
|
113
|
+
if (result?.allowed !== true ||
|
|
114
|
+
result.taskId !== receipt.taskId ||
|
|
115
|
+
result.taskVersion !== receipt.taskVersion ||
|
|
116
|
+
result.diffHash !== manifest.diffHash) {
|
|
117
|
+
return {
|
|
118
|
+
allowed: false,
|
|
119
|
+
reason: 'online_commit_gate_mismatch',
|
|
120
|
+
diffHash: manifest.diffHash,
|
|
121
|
+
taskId: receipt.taskId,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return {
|
|
127
|
+
allowed: false,
|
|
128
|
+
reason: 'online_commit_gate_rejected',
|
|
129
|
+
diffHash: manifest.diffHash,
|
|
130
|
+
taskId: receipt.taskId,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
allowed: true,
|
|
135
|
+
reason: 'verified',
|
|
136
|
+
diffHash: manifest.diffHash,
|
|
137
|
+
taskId: receipt.taskId,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async invalidateTask(taskId) {
|
|
141
|
+
await ensureManagedDirectory(this.root, this.root);
|
|
142
|
+
const entries = await readdir(this.root, { withFileTypes: true });
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
|
|
145
|
+
throw new Error(`Unsafe verification gate entry: ${entry.name}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await Promise.all(entries
|
|
149
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
150
|
+
.map(async (entry) => {
|
|
151
|
+
const path = join(this.root, entry.name);
|
|
152
|
+
const receipt = await readJson(path, this.root);
|
|
153
|
+
if (receipt?.taskId === taskId) {
|
|
154
|
+
await removeFile(path, this.root);
|
|
155
|
+
}
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
async clear() {
|
|
159
|
+
await ensureManagedDirectory(this.root, this.root);
|
|
160
|
+
const entries = await readdir(this.root, { withFileTypes: true });
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith('.json')) {
|
|
163
|
+
throw new Error(`Unsafe verification gate entry: ${entry.name}`);
|
|
164
|
+
}
|
|
165
|
+
await removeFile(join(this.root, entry.name), this.root);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
pathFor(repoFingerprint) {
|
|
169
|
+
return join(this.root, `${safeSegment(repoFingerprint)}.json`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function asObject(value) {
|
|
173
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
174
|
+
}
|
|
175
|
+
function isTaskManifest(value) {
|
|
176
|
+
return (Array.isArray(value) &&
|
|
177
|
+
value.every((entry) => entry !== null &&
|
|
178
|
+
typeof entry === 'object' &&
|
|
179
|
+
typeof entry.path === 'string' &&
|
|
180
|
+
isRepositoryRelative(entry.path) &&
|
|
181
|
+
(entry.originalPath === undefined ||
|
|
182
|
+
isRepositoryRelative(entry.originalPath ?? '')) &&
|
|
183
|
+
typeof entry.status === 'string' &&
|
|
184
|
+
(entry.contentHash === null ||
|
|
185
|
+
/^[0-9a-f]{64}$/.test(entry.contentHash ?? '')) &&
|
|
186
|
+
(entry.size === null ||
|
|
187
|
+
(Number.isSafeInteger(entry.size) &&
|
|
188
|
+
(entry.size ?? -1) >= 0)) &&
|
|
189
|
+
(entry.blobOid === null ||
|
|
190
|
+
/^[0-9a-f]{40,64}$/.test(entry.blobOid ?? '')) &&
|
|
191
|
+
(entry.mode === null ||
|
|
192
|
+
entry.mode === '100644' ||
|
|
193
|
+
entry.mode === '100755')));
|
|
194
|
+
}
|
|
195
|
+
function isRepositoryRelative(value) {
|
|
196
|
+
const normalized = value.replace(/\\/g, '/');
|
|
197
|
+
return (normalized.length > 0 &&
|
|
198
|
+
!normalized.startsWith('/') &&
|
|
199
|
+
!/^[A-Za-z]:\//.test(normalized) &&
|
|
200
|
+
!normalized.split('/').includes('..') &&
|
|
201
|
+
!normalized.includes('\0'));
|
|
202
|
+
}
|
|
203
|
+
function belongsToTask(staged, taskChanges) {
|
|
204
|
+
return taskChanges.some((taskChange) => taskChange.path === staged.path &&
|
|
205
|
+
semanticStatus(taskChange.status) === semanticStatus(staged.status) &&
|
|
206
|
+
(staged.originalPath ?? null) === (taskChange.originalPath ?? null) &&
|
|
207
|
+
staged.blobOid === taskChange.blobOid &&
|
|
208
|
+
staged.mode === taskChange.mode);
|
|
209
|
+
}
|
|
210
|
+
function semanticStatus(status) {
|
|
211
|
+
if (status === '??') {
|
|
212
|
+
return 'A';
|
|
213
|
+
}
|
|
214
|
+
for (const candidate of ['R', 'C', 'A', 'D', 'T', 'U', 'M']) {
|
|
215
|
+
if (status.includes(candidate)) {
|
|
216
|
+
return candidate;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return status.trim() || status;
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=verification-gate.js.map
|