djournal 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/.agents/adapters/contract.md +48 -0
- package/.agents/adapters/shared/journal-hook.js +120 -0
- package/.agents/rules/AUTOMATION.md +84 -0
- package/.agents/rules/FEAT.md +83 -0
- package/.agents/rules/JOURNAL.md +127 -0
- package/.agents/rules/LINKS.md +96 -0
- package/.agents/rules/METADATA.md +179 -0
- package/.agents/rules/SAFETY.md +92 -0
- package/.agents/rules/STATE.md +31 -0
- package/.agents/skills/audit/SKILL.md +89 -0
- package/.agents/skills/audit/agents/openai.yaml +4 -0
- package/.agents/skills/decision/SKILL.md +74 -0
- package/.agents/skills/decision/agents/openai.yaml +4 -0
- package/.agents/skills/document/SKILL.md +68 -0
- package/.agents/skills/init-work/SKILL.md +62 -0
- package/.agents/skills/journal/SKILL.md +97 -0
- package/.agents/skills/journal-workflow/SKILL.md +45 -0
- package/.agents/skills/journal-workflow/agents/openai.yaml +4 -0
- package/.agents/skills/plan/SKILL.md +98 -0
- package/.agents/skills/recall/SKILL.md +67 -0
- package/.agents/skills/reconcile/SKILL.md +93 -0
- package/.agents/skills/reconcile/agents/openai.yaml +4 -0
- package/.agents/skills/research-codebase/SKILL.md +72 -0
- package/.agents/skills/research-web/SKILL.md +72 -0
- package/.agents/skills/resume/SKILL.md +68 -0
- package/.agents/skills/switch/SKILL.md +31 -0
- package/.claude/settings.json +41 -0
- package/.codex/hooks.json +40 -0
- package/AGENTS.md +23 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +202 -0
- package/README.md +158 -0
- package/bin/journal.js +118 -0
- package/install.sh +35 -0
- package/lib/installer/index.js +538 -0
- package/lib/installer/merge.js +125 -0
- package/package.json +55 -0
- package/spec.md +297 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const readline = require("node:readline/promises");
|
|
7
|
+
const {
|
|
8
|
+
equal,
|
|
9
|
+
hasManagedBlock,
|
|
10
|
+
mergeHookConfig,
|
|
11
|
+
removeHookConfig,
|
|
12
|
+
removeManagedBlock,
|
|
13
|
+
upsertManagedBlock,
|
|
14
|
+
} = require("./merge.js");
|
|
15
|
+
|
|
16
|
+
const SCHEMA_VERSION = 1;
|
|
17
|
+
const SUPPORTED_HARNESSES = ["codex", "claude-code"];
|
|
18
|
+
const FUTURE_HARNESSES = ["opencode", "pi", "zed"];
|
|
19
|
+
const MANIFEST_PATH = ".journal/.install/manifest.json";
|
|
20
|
+
const TRANSACTION_PATH = ".journal/.install/transaction.json";
|
|
21
|
+
|
|
22
|
+
class InstallerError extends Error {
|
|
23
|
+
constructor(message, code = "INSTALL_ERROR") {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "InstallerError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sha256(value) {
|
|
31
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readOptional(file) {
|
|
35
|
+
try { return fs.readFileSync(file); } catch (error) {
|
|
36
|
+
if (error.code === "ENOENT") return null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseJson(buffer, label) {
|
|
42
|
+
try { return buffer ? JSON.parse(buffer.toString("utf8")) : {}; }
|
|
43
|
+
catch (error) { throw new InstallerError(`${label} is not valid JSON: ${error.message}`, "INVALID_JSON"); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function jsonBuffer(value) {
|
|
47
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveWithin(target, relative) {
|
|
51
|
+
if (!relative || path.isAbsolute(relative)) {
|
|
52
|
+
throw new InstallerError(`unsafe destination path: ${relative}`, "UNSAFE_PATH");
|
|
53
|
+
}
|
|
54
|
+
const root = path.resolve(target);
|
|
55
|
+
const resolved = path.resolve(root, relative);
|
|
56
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
57
|
+
throw new InstallerError(`destination escapes target: ${relative}`, "UNSAFE_PATH");
|
|
58
|
+
}
|
|
59
|
+
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
|
|
60
|
+
throw new InstallerError(`destination is a symlink: ${relative}`, "UNSAFE_PATH");
|
|
61
|
+
}
|
|
62
|
+
let cursor = root;
|
|
63
|
+
for (const part of path.relative(root, path.dirname(resolved)).split(path.sep).filter(Boolean)) {
|
|
64
|
+
cursor = path.join(cursor, part);
|
|
65
|
+
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
|
|
66
|
+
throw new InstallerError(`destination traverses symlink: ${relative}`, "UNSAFE_PATH");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function listFiles(directory, prefix = "") {
|
|
73
|
+
if (!fs.existsSync(directory)) return [];
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
76
|
+
const absolute = path.join(directory, entry.name);
|
|
77
|
+
const relative = path.join(prefix, entry.name);
|
|
78
|
+
if (entry.isDirectory()) result.push(...listFiles(absolute, relative));
|
|
79
|
+
else if (entry.isFile()) result.push(relative.split(path.sep).join("/"));
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sourceInventory(sourceRoot) {
|
|
85
|
+
const files = ["spec.md"];
|
|
86
|
+
for (const directory of [".agents/rules", ".agents/skills", ".agents/adapters"]) {
|
|
87
|
+
for (const relative of listFiles(path.join(sourceRoot, directory))) {
|
|
88
|
+
files.push(`${directory}/${relative}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return files.sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function packageVersion(sourceRoot) {
|
|
95
|
+
return parseJson(fs.readFileSync(path.join(sourceRoot, "package.json")), "package.json").version;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function commandExists(command, env = process.env) {
|
|
99
|
+
const pathValue = env.PATH || "";
|
|
100
|
+
const extensions = process.platform === "win32"
|
|
101
|
+
? (env.PATHEXT || ".EXE;.CMD;.BAT").split(";")
|
|
102
|
+
: [""];
|
|
103
|
+
return pathValue.split(path.delimiter).some((directory) => extensions.some((extension) => {
|
|
104
|
+
const file = path.join(directory, `${command}${extension}`);
|
|
105
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function detectHarnesses(target, options = {}) {
|
|
110
|
+
const exists = options.commandExists || commandExists;
|
|
111
|
+
const evidence = {
|
|
112
|
+
codex: exists("codex") || fs.existsSync(path.join(target, ".codex")),
|
|
113
|
+
"claude-code": exists("claude") || fs.existsSync(path.join(target, ".claude")) || fs.existsSync(path.join(target, "CLAUDE.md")),
|
|
114
|
+
opencode: exists("opencode") || fs.existsSync(path.join(target, ".opencode")) || fs.existsSync(path.join(target, "opencode.json")),
|
|
115
|
+
pi: exists("pi") || fs.existsSync(path.join(target, ".pi")),
|
|
116
|
+
zed: exists("zed") || fs.existsSync(path.join(target, ".zed")),
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
supported: SUPPORTED_HARNESSES.filter((name) => evidence[name]),
|
|
120
|
+
future: FUTURE_HARNESSES.filter((name) => evidence[name]),
|
|
121
|
+
evidence,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeHarness(name) {
|
|
126
|
+
if (name === "claude") return "claude-code";
|
|
127
|
+
return name;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function selectHarnesses(options, detected) {
|
|
131
|
+
if (options.instructionsOnly) return [];
|
|
132
|
+
if (options.all) return [...SUPPORTED_HARNESSES];
|
|
133
|
+
if (options.harnesses?.length) {
|
|
134
|
+
const selected = [...new Set(options.harnesses.map(normalizeHarness))];
|
|
135
|
+
const invalid = selected.filter((name) => !SUPPORTED_HARNESSES.includes(name));
|
|
136
|
+
if (invalid.length) throw new InstallerError(`unsupported harness: ${invalid.join(", ")}`, "UNSUPPORTED_HARNESS");
|
|
137
|
+
return selected;
|
|
138
|
+
}
|
|
139
|
+
if (detected.supported.length <= 1) return [...detected.supported];
|
|
140
|
+
if (!options.interactive) {
|
|
141
|
+
throw new InstallerError("multiple harnesses detected; pass --harness, --all, or --instructions-only", "HARNESS_SELECTION_REQUIRED");
|
|
142
|
+
}
|
|
143
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
144
|
+
const answer = await rl.question(`Select harnesses (${detected.supported.join(", ")}): `);
|
|
145
|
+
rl.close();
|
|
146
|
+
const selected = answer.split(",").map((value) => normalizeHarness(value.trim())).filter(Boolean);
|
|
147
|
+
const invalid = selected.filter((name) => !detected.supported.includes(name));
|
|
148
|
+
if (!selected.length || invalid.length) throw new InstallerError("invalid harness selection", "HARNESS_SELECTION_REQUIRED");
|
|
149
|
+
return [...new Set(selected)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loadManifest(target) {
|
|
153
|
+
const buffer = readOptional(resolveWithin(target, MANIFEST_PATH));
|
|
154
|
+
if (!buffer) return null;
|
|
155
|
+
const manifest = parseJson(buffer, MANIFEST_PATH);
|
|
156
|
+
if (manifest.schemaVersion !== SCHEMA_VERSION) {
|
|
157
|
+
throw new InstallerError(`unsupported manifest schema: ${manifest.schemaVersion}`, "UNSUPPORTED_MANIFEST");
|
|
158
|
+
}
|
|
159
|
+
return manifest;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function recordMap(manifest) {
|
|
163
|
+
return new Map((manifest?.files || []).map((record) => [record.path, record]));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function operation(target, relative, next) {
|
|
167
|
+
return { path: relative, absolute: resolveWithin(target, relative), next: next === null ? null : Buffer.from(next) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function atomicWrite(file, content) {
|
|
171
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
172
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
173
|
+
fs.writeFileSync(temporary, content);
|
|
174
|
+
fs.renameSync(temporary, file);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cleanupEmpty(directory, stop) {
|
|
178
|
+
let current = directory;
|
|
179
|
+
const boundary = path.resolve(stop);
|
|
180
|
+
while (current.startsWith(boundary) && current !== boundary) {
|
|
181
|
+
try { fs.rmdirSync(current); } catch { break; }
|
|
182
|
+
current = path.dirname(current);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function recoverTransaction(target) {
|
|
187
|
+
const file = resolveWithin(target, TRANSACTION_PATH);
|
|
188
|
+
const buffer = readOptional(file);
|
|
189
|
+
if (!buffer) return false;
|
|
190
|
+
const transaction = parseJson(buffer, TRANSACTION_PATH);
|
|
191
|
+
for (const snapshot of [...transaction.snapshots].reverse()) {
|
|
192
|
+
const absolute = resolveWithin(target, snapshot.path);
|
|
193
|
+
if (snapshot.existed) atomicWrite(absolute, Buffer.from(snapshot.original, "base64"));
|
|
194
|
+
else {
|
|
195
|
+
try { fs.unlinkSync(absolute); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
196
|
+
cleanupEmpty(path.dirname(absolute), target);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
fs.unlinkSync(file);
|
|
200
|
+
cleanupEmpty(path.dirname(file), target);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function applyTransaction(target, operations, options = {}) {
|
|
205
|
+
if (options.dryRun) return;
|
|
206
|
+
recoverTransaction(target);
|
|
207
|
+
const transactionFile = resolveWithin(target, TRANSACTION_PATH);
|
|
208
|
+
const snapshots = operations.map((item) => {
|
|
209
|
+
const original = readOptional(item.absolute);
|
|
210
|
+
return { path: item.path, existed: original !== null, original: original?.toString("base64") || "" };
|
|
211
|
+
});
|
|
212
|
+
atomicWrite(transactionFile, jsonBuffer({ schemaVersion: 1, snapshots }));
|
|
213
|
+
let applied = 0;
|
|
214
|
+
try {
|
|
215
|
+
for (const item of operations) {
|
|
216
|
+
if (item.next === null) {
|
|
217
|
+
try { fs.unlinkSync(item.absolute); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
218
|
+
} else atomicWrite(item.absolute, item.next);
|
|
219
|
+
applied++;
|
|
220
|
+
if (options.failAfter && applied >= options.failAfter) throw new Error("injected transaction failure");
|
|
221
|
+
}
|
|
222
|
+
fs.unlinkSync(transactionFile);
|
|
223
|
+
cleanupEmpty(path.dirname(transactionFile), target);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
recoverTransaction(target);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function markdownRecord(target, relative, body, oldRecord, harness) {
|
|
231
|
+
const absolute = resolveWithin(target, relative);
|
|
232
|
+
const original = readOptional(absolute);
|
|
233
|
+
const current = original?.toString("utf8") || "";
|
|
234
|
+
if (oldRecord) {
|
|
235
|
+
let present;
|
|
236
|
+
try { present = hasManagedBlock(current); }
|
|
237
|
+
catch (error) {
|
|
238
|
+
throw new InstallerError(`managed block is invalid: ${relative}: ${error.message}`, "ASSET_CONFLICT");
|
|
239
|
+
}
|
|
240
|
+
if (!present) throw new InstallerError(`managed block is missing: ${relative}`, "ASSET_CONFLICT");
|
|
241
|
+
}
|
|
242
|
+
const next = Buffer.from(upsertManagedBlock(current, body));
|
|
243
|
+
return {
|
|
244
|
+
op: operation(target, relative, next),
|
|
245
|
+
record: {
|
|
246
|
+
path: relative,
|
|
247
|
+
mode: "markdown_block",
|
|
248
|
+
harness,
|
|
249
|
+
created: oldRecord?.created ?? original === null,
|
|
250
|
+
installedHash: sha256(next),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function jsonRecord(target, relative, fragment, oldRecord, harness) {
|
|
256
|
+
const absolute = resolveWithin(target, relative);
|
|
257
|
+
const original = readOptional(absolute);
|
|
258
|
+
const current = parseJson(original, relative);
|
|
259
|
+
let base = current;
|
|
260
|
+
if (oldRecord?.injected) {
|
|
261
|
+
const removed = removeHookConfig(base, oldRecord.injected);
|
|
262
|
+
if (removed.conflicts.length) {
|
|
263
|
+
throw new InstallerError(`managed hook group was modified: ${relative}`, "ASSET_CONFLICT");
|
|
264
|
+
}
|
|
265
|
+
base = removed.value;
|
|
266
|
+
}
|
|
267
|
+
const merged = mergeHookConfig(base, fragment);
|
|
268
|
+
const injectedCount = Object.values(merged.injected).reduce((total, groups) => total + groups.length, 0);
|
|
269
|
+
const next = injectedCount === 0 && original !== null ? original : jsonBuffer(merged.value);
|
|
270
|
+
return {
|
|
271
|
+
op: operation(target, relative, next),
|
|
272
|
+
record: {
|
|
273
|
+
path: relative,
|
|
274
|
+
mode: "json_merge",
|
|
275
|
+
harness,
|
|
276
|
+
created: oldRecord?.created ?? original === null,
|
|
277
|
+
installedHash: sha256(next),
|
|
278
|
+
injected: merged.injected,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function copyRecord(sourceRoot, target, relative, oldRecord) {
|
|
284
|
+
const source = fs.readFileSync(path.join(sourceRoot, relative));
|
|
285
|
+
const absolute = resolveWithin(target, relative);
|
|
286
|
+
const current = readOptional(absolute);
|
|
287
|
+
if (oldRecord) {
|
|
288
|
+
if (current === null || sha256(current) !== oldRecord.installedHash) {
|
|
289
|
+
throw new InstallerError(`modified installed asset: ${relative}`, "ASSET_CONFLICT");
|
|
290
|
+
}
|
|
291
|
+
} else if (current !== null && !current.equals(source)) {
|
|
292
|
+
throw new InstallerError(`destination already exists with different content: ${relative}`, "ASSET_CONFLICT");
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
op: operation(target, relative, source),
|
|
296
|
+
record: {
|
|
297
|
+
path: relative,
|
|
298
|
+
mode: "copy",
|
|
299
|
+
created: oldRecord?.created ?? current === null,
|
|
300
|
+
installedHash: sha256(source),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function harnessFragment(sourceRoot, harness) {
|
|
306
|
+
const relative = harness === "codex" ? ".codex/hooks.json" : ".claude/settings.json";
|
|
307
|
+
return parseJson(fs.readFileSync(path.join(sourceRoot, relative)), relative);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function planInstall(sourceRoot, target, harnesses, oldManifest) {
|
|
311
|
+
const old = recordMap(oldManifest);
|
|
312
|
+
const records = [];
|
|
313
|
+
const operations = [];
|
|
314
|
+
for (const relative of sourceInventory(sourceRoot)) {
|
|
315
|
+
const result = copyRecord(sourceRoot, target, relative, old.get(relative));
|
|
316
|
+
records.push(result.record); operations.push(result.op);
|
|
317
|
+
}
|
|
318
|
+
const agentBody = fs.readFileSync(path.join(sourceRoot, "AGENTS.md"), "utf8");
|
|
319
|
+
const agent = markdownRecord(target, "AGENTS.md", agentBody, old.get("AGENTS.md"));
|
|
320
|
+
records.push(agent.record); operations.push(agent.op);
|
|
321
|
+
|
|
322
|
+
if (harnesses.includes("codex")) {
|
|
323
|
+
const result = jsonRecord(target, ".codex/hooks.json", harnessFragment(sourceRoot, "codex"), old.get(".codex/hooks.json"), "codex");
|
|
324
|
+
records.push(result.record); operations.push(result.op);
|
|
325
|
+
}
|
|
326
|
+
if (harnesses.includes("claude-code")) {
|
|
327
|
+
const bridge = markdownRecord(target, "CLAUDE.md", "@AGENTS.md", old.get("CLAUDE.md"), "claude-code");
|
|
328
|
+
records.push(bridge.record); operations.push(bridge.op);
|
|
329
|
+
const hooks = jsonRecord(target, ".claude/settings.json", harnessFragment(sourceRoot, "claude-code"), old.get(".claude/settings.json"), "claude-code");
|
|
330
|
+
records.push(hooks.record); operations.push(hooks.op);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const desired = new Set(records.map((record) => record.path));
|
|
334
|
+
for (const record of oldManifest?.files || []) {
|
|
335
|
+
if (desired.has(record.path)) continue;
|
|
336
|
+
if (record.mode === "copy") {
|
|
337
|
+
const current = readOptional(resolveWithin(target, record.path));
|
|
338
|
+
if (current !== null && sha256(current) !== record.installedHash) {
|
|
339
|
+
throw new InstallerError(`modified obsolete asset: ${record.path}`, "ASSET_CONFLICT");
|
|
340
|
+
}
|
|
341
|
+
if (record.created) operations.push(operation(target, record.path, null));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { records, operations };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function checkTarget(target) {
|
|
348
|
+
const resolved = path.resolve(target);
|
|
349
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
350
|
+
throw new InstallerError(`target is not a directory: ${resolved}`, "INVALID_TARGET");
|
|
351
|
+
}
|
|
352
|
+
if (fs.lstatSync(resolved).isSymbolicLink()) {
|
|
353
|
+
throw new InstallerError(`target must not be a symlink: ${resolved}`, "UNSAFE_PATH");
|
|
354
|
+
}
|
|
355
|
+
fs.accessSync(resolved, fs.constants.R_OK | fs.constants.W_OK);
|
|
356
|
+
return resolved;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function doctor(options) {
|
|
360
|
+
const target = checkTarget(options.target || process.cwd());
|
|
361
|
+
const detected = detectHarnesses(target, options);
|
|
362
|
+
const checks = [
|
|
363
|
+
{ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 18, detail: process.versions.node },
|
|
364
|
+
{ name: "target", ok: true, detail: target },
|
|
365
|
+
];
|
|
366
|
+
for (const relative of [".codex/hooks.json", ".claude/settings.json"]) {
|
|
367
|
+
const buffer = readOptional(resolveWithin(target, relative));
|
|
368
|
+
if (buffer) {
|
|
369
|
+
try { parseJson(buffer, relative); checks.push({ name: relative, ok: true, detail: "valid JSON" }); }
|
|
370
|
+
catch (error) { checks.push({ name: relative, ok: false, detail: error.message }); }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const manifest = readOptional(resolveWithin(target, MANIFEST_PATH));
|
|
374
|
+
if (manifest) {
|
|
375
|
+
try { loadManifest(target); checks.push({ name: "manifest", ok: true, detail: "supported" }); }
|
|
376
|
+
catch (error) { checks.push({ name: "manifest", ok: false, detail: error.message }); }
|
|
377
|
+
}
|
|
378
|
+
return { ok: checks.every((check) => check.ok), target, detected, checks };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function install(options) {
|
|
382
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
383
|
+
const target = checkTarget(options.target || process.cwd());
|
|
384
|
+
if (sourceRoot === target) throw new InstallerError("source and target must differ", "INVALID_TARGET");
|
|
385
|
+
recoverTransaction(target);
|
|
386
|
+
const health = doctor({ ...options, target });
|
|
387
|
+
if (!health.ok) throw new InstallerError("prerequisite checks failed", "DOCTOR_FAILED");
|
|
388
|
+
const oldManifest = loadManifest(target);
|
|
389
|
+
const detected = health.detected;
|
|
390
|
+
const selected = await selectHarnesses({ ...options, interactive: options.interactive ?? process.stdin.isTTY }, detected);
|
|
391
|
+
const harnesses = [...new Set([...(oldManifest?.harnesses || []), ...selected])].sort();
|
|
392
|
+
const planned = planInstall(sourceRoot, target, harnesses, oldManifest);
|
|
393
|
+
const manifest = {
|
|
394
|
+
schemaVersion: SCHEMA_VERSION,
|
|
395
|
+
toolVersion: packageVersion(sourceRoot),
|
|
396
|
+
installedAt: oldManifest?.installedAt || new Date().toISOString(),
|
|
397
|
+
updatedAt: new Date().toISOString(),
|
|
398
|
+
harnesses,
|
|
399
|
+
files: planned.records.sort((a, b) => a.path.localeCompare(b.path)),
|
|
400
|
+
};
|
|
401
|
+
planned.operations.push(operation(target, MANIFEST_PATH, jsonBuffer(manifest)));
|
|
402
|
+
applyTransaction(target, planned.operations, options);
|
|
403
|
+
if (!options.dryRun) {
|
|
404
|
+
for (const item of planned.operations.filter((candidate) => candidate.next === null)) {
|
|
405
|
+
cleanupEmpty(path.dirname(item.absolute), target);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return { action: oldManifest ? "upgrade" : "install", target, harnesses, dryRun: !!options.dryRun, operations: planned.operations.map((item) => item.path) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function upgrade(options) {
|
|
412
|
+
const target = checkTarget(options.target || process.cwd());
|
|
413
|
+
const manifest = loadManifest(target);
|
|
414
|
+
if (!manifest) throw new InstallerError("journal is not installed", "NOT_INSTALLED");
|
|
415
|
+
return install({
|
|
416
|
+
...options,
|
|
417
|
+
target,
|
|
418
|
+
harnesses: manifest.harnesses,
|
|
419
|
+
instructionsOnly: manifest.harnesses.length === 0,
|
|
420
|
+
interactive: false,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function uninstallRecord(target, record, conflicts) {
|
|
425
|
+
const absolute = resolveWithin(target, record.path);
|
|
426
|
+
const current = readOptional(absolute);
|
|
427
|
+
if (record.mode === "copy") {
|
|
428
|
+
if (current === null) return null;
|
|
429
|
+
if (sha256(current) !== record.installedHash) { conflicts.push(record.path); return null; }
|
|
430
|
+
return record.created ? operation(target, record.path, null) : null;
|
|
431
|
+
}
|
|
432
|
+
if (record.mode === "markdown_block") {
|
|
433
|
+
if (current === null) return null;
|
|
434
|
+
const removed = removeManagedBlock(current.toString("utf8"));
|
|
435
|
+
if (!removed.removed) { conflicts.push(record.path); return null; }
|
|
436
|
+
return operation(target, record.path, removed.text ? Buffer.from(removed.text) : null);
|
|
437
|
+
}
|
|
438
|
+
if (record.mode === "json_merge") {
|
|
439
|
+
if (current === null) return null;
|
|
440
|
+
const injectedCount = Object.values(record.injected || {}).reduce((total, groups) => total + groups.length, 0);
|
|
441
|
+
if (injectedCount === 0 && !record.created) return null;
|
|
442
|
+
const parsed = parseJson(current, record.path);
|
|
443
|
+
const removed = removeHookConfig(parsed, record.injected);
|
|
444
|
+
if (removed.conflicts.length) { conflicts.push(record.path); return null; }
|
|
445
|
+
const empty = Object.keys(removed.value).length === 0;
|
|
446
|
+
return operation(target, record.path, empty && record.created ? null : jsonBuffer(removed.value));
|
|
447
|
+
}
|
|
448
|
+
throw new InstallerError(`unknown manifest mode: ${record.mode}`, "UNSUPPORTED_MANIFEST");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function uninstall(options) {
|
|
452
|
+
const target = checkTarget(options.target || process.cwd());
|
|
453
|
+
recoverTransaction(target);
|
|
454
|
+
const manifest = loadManifest(target);
|
|
455
|
+
if (!manifest) throw new InstallerError("journal is not installed", "NOT_INSTALLED");
|
|
456
|
+
const selected = options.all || !options.harnesses?.length
|
|
457
|
+
? null
|
|
458
|
+
: new Set(options.harnesses.map(normalizeHarness));
|
|
459
|
+
if (selected) {
|
|
460
|
+
const invalid = [...selected].filter((name) => !SUPPORTED_HARNESSES.includes(name));
|
|
461
|
+
if (invalid.length) throw new InstallerError(`unsupported harness: ${invalid.join(", ")}`, "UNSUPPORTED_HARNESS");
|
|
462
|
+
}
|
|
463
|
+
const full = selected === null;
|
|
464
|
+
const remove = manifest.files.filter((record) => full || (record.harness && selected.has(record.harness)));
|
|
465
|
+
const keep = manifest.files.filter((record) => !remove.includes(record));
|
|
466
|
+
const conflicts = [];
|
|
467
|
+
const operations = remove.map((record) => uninstallRecord(target, record, conflicts)).filter(Boolean);
|
|
468
|
+
const conflictSet = new Set(conflicts);
|
|
469
|
+
const retained = [...keep, ...remove.filter((record) => conflictSet.has(record.path))];
|
|
470
|
+
let nextManifest = null;
|
|
471
|
+
if (!full || conflicts.length) {
|
|
472
|
+
const remainingHarnesses = [...new Set(retained.map((record) => record.harness).filter(Boolean))].sort();
|
|
473
|
+
nextManifest = {
|
|
474
|
+
...manifest,
|
|
475
|
+
updatedAt: new Date().toISOString(),
|
|
476
|
+
harnesses: remainingHarnesses,
|
|
477
|
+
files: retained,
|
|
478
|
+
};
|
|
479
|
+
operations.push(operation(target, MANIFEST_PATH, jsonBuffer(nextManifest)));
|
|
480
|
+
} else operations.push(operation(target, MANIFEST_PATH, null));
|
|
481
|
+
applyTransaction(target, operations, options);
|
|
482
|
+
if (!options.dryRun) {
|
|
483
|
+
for (const item of operations.filter((candidate) => candidate.next === null)) {
|
|
484
|
+
cleanupEmpty(path.dirname(item.absolute), target);
|
|
485
|
+
}
|
|
486
|
+
if (full) cleanupEmpty(path.join(target, ".journal", ".install"), target);
|
|
487
|
+
}
|
|
488
|
+
const action = full ? (conflicts.length ? "uninstall-with-conflicts" : "uninstall") : "partial-uninstall";
|
|
489
|
+
return { action, target, conflicts, dryRun: !!options.dryRun, operations: operations.map((item) => item.path), harnesses: nextManifest?.harnesses || [] };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function recordStatus(target, record) {
|
|
493
|
+
const current = readOptional(resolveWithin(target, record.path));
|
|
494
|
+
if (current === null) return "missing";
|
|
495
|
+
if (record.mode === "copy") return sha256(current) === record.installedHash ? "clean" : "modified";
|
|
496
|
+
if (record.mode === "markdown_block") {
|
|
497
|
+
const text = current.toString("utf8");
|
|
498
|
+
try {
|
|
499
|
+
return hasManagedBlock(text)
|
|
500
|
+
? (sha256(current) === record.installedHash ? "clean" : "modified")
|
|
501
|
+
: "missing";
|
|
502
|
+
} catch { return "invalid"; }
|
|
503
|
+
}
|
|
504
|
+
if (record.mode === "json_merge") {
|
|
505
|
+
try {
|
|
506
|
+
const value = parseJson(current, record.path);
|
|
507
|
+
const present = Object.entries(record.injected || {}).every(([event, groups]) =>
|
|
508
|
+
groups.every((group) => value.hooks?.[event]?.some((candidate) => equal(candidate, group))));
|
|
509
|
+
return present ? (sha256(current) === record.installedHash ? "clean" : "modified") : "missing";
|
|
510
|
+
} catch { return "invalid"; }
|
|
511
|
+
}
|
|
512
|
+
return "invalid";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function status(options) {
|
|
516
|
+
const target = checkTarget(options.target || process.cwd());
|
|
517
|
+
const manifest = loadManifest(target);
|
|
518
|
+
if (!manifest) return { installed: false, target };
|
|
519
|
+
const files = manifest.files.map((record) => ({ path: record.path, mode: record.mode, status: recordStatus(target, record) }));
|
|
520
|
+
return { installed: true, target, toolVersion: manifest.toolVersion, harnesses: manifest.harnesses, files, clean: files.every((file) => file.status === "clean") };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
module.exports = {
|
|
524
|
+
InstallerError,
|
|
525
|
+
MANIFEST_PATH,
|
|
526
|
+
SUPPORTED_HARNESSES,
|
|
527
|
+
applyTransaction,
|
|
528
|
+
detectHarnesses,
|
|
529
|
+
doctor,
|
|
530
|
+
install,
|
|
531
|
+
loadManifest,
|
|
532
|
+
recoverTransaction,
|
|
533
|
+
selectHarnesses,
|
|
534
|
+
sourceInventory,
|
|
535
|
+
status,
|
|
536
|
+
uninstall,
|
|
537
|
+
upgrade,
|
|
538
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const BEGIN = "<!-- djournal:begin -->";
|
|
4
|
+
const END = "<!-- djournal:end -->";
|
|
5
|
+
const LEGACY_BEGIN = "<!-- filesystem-journal:begin -->";
|
|
6
|
+
const LEGACY_END = "<!-- filesystem-journal:end -->";
|
|
7
|
+
const MARKERS = [
|
|
8
|
+
{ begin: BEGIN, end: END },
|
|
9
|
+
{ begin: LEGACY_BEGIN, end: LEGACY_END },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function managedBlock(body) {
|
|
13
|
+
return `${BEGIN}\n${body.trim()}\n${END}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function blockRange(text) {
|
|
17
|
+
const ranges = [];
|
|
18
|
+
for (const marker of MARKERS) {
|
|
19
|
+
const starts = [];
|
|
20
|
+
const ends = [];
|
|
21
|
+
let cursor = 0;
|
|
22
|
+
while ((cursor = text.indexOf(marker.begin, cursor)) !== -1) {
|
|
23
|
+
starts.push(cursor);
|
|
24
|
+
cursor += marker.begin.length;
|
|
25
|
+
}
|
|
26
|
+
cursor = 0;
|
|
27
|
+
while ((cursor = text.indexOf(marker.end, cursor)) !== -1) {
|
|
28
|
+
ends.push(cursor);
|
|
29
|
+
cursor += marker.end.length;
|
|
30
|
+
}
|
|
31
|
+
if (starts.length > 1 || ends.length > 1) throw new Error("duplicate djournal blocks");
|
|
32
|
+
if (starts.length !== ends.length || (starts.length === 1 && ends[0] < starts[0])) {
|
|
33
|
+
throw new Error("unterminated djournal block");
|
|
34
|
+
}
|
|
35
|
+
if (starts.length === 1) ranges.push({ start: starts[0], end: ends[0] + marker.end.length });
|
|
36
|
+
}
|
|
37
|
+
if (ranges.length > 1) throw new Error("duplicate djournal blocks");
|
|
38
|
+
return ranges[0] || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasManagedBlock(text) {
|
|
42
|
+
return blockRange(text) !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function upsertManagedBlock(text, body) {
|
|
46
|
+
const block = managedBlock(body);
|
|
47
|
+
const range = blockRange(text);
|
|
48
|
+
if (range) return `${text.slice(0, range.start)}${block}${text.slice(range.end)}`;
|
|
49
|
+
if (!text) return `${block}\n`;
|
|
50
|
+
const separator = text.endsWith("\n") ? "\n" : "\n\n";
|
|
51
|
+
return `${text}${separator}${block}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeManagedBlock(text) {
|
|
55
|
+
const range = blockRange(text);
|
|
56
|
+
if (!range) return { text, removed: false };
|
|
57
|
+
let before = text.slice(0, range.start);
|
|
58
|
+
let after = text.slice(range.end);
|
|
59
|
+
if (after.startsWith("\n")) after = after.slice(1);
|
|
60
|
+
if (before.endsWith("\n\n")) before = before.slice(0, -1);
|
|
61
|
+
return { text: `${before}${after}`, removed: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stable(value) {
|
|
65
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
66
|
+
if (value && typeof value === "object") {
|
|
67
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function equal(left, right) {
|
|
73
|
+
return JSON.stringify(stable(left)) === JSON.stringify(stable(right));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeHookConfig(current, fragment) {
|
|
77
|
+
const next = structuredClone(current);
|
|
78
|
+
next.hooks ||= {};
|
|
79
|
+
const injected = {};
|
|
80
|
+
for (const [event, groups] of Object.entries(fragment.hooks || {})) {
|
|
81
|
+
next.hooks[event] ||= [];
|
|
82
|
+
for (const group of groups) {
|
|
83
|
+
if (!next.hooks[event].some((candidate) => equal(candidate, group))) {
|
|
84
|
+
const copy = structuredClone(group);
|
|
85
|
+
next.hooks[event].push(copy);
|
|
86
|
+
(injected[event] ||= []).push(copy);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { value: next, injected };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function removeHookConfig(current, injected) {
|
|
94
|
+
const next = structuredClone(current);
|
|
95
|
+
const conflicts = [];
|
|
96
|
+
for (const [event, groups] of Object.entries(injected || {})) {
|
|
97
|
+
const existing = next.hooks?.[event];
|
|
98
|
+
if (!Array.isArray(existing)) {
|
|
99
|
+
conflicts.push(event);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const group of groups) {
|
|
103
|
+
const index = existing.findIndex((candidate) => equal(candidate, group));
|
|
104
|
+
if (index === -1) conflicts.push(event);
|
|
105
|
+
else existing.splice(index, 1);
|
|
106
|
+
}
|
|
107
|
+
if (existing.length === 0) delete next.hooks[event];
|
|
108
|
+
}
|
|
109
|
+
if (next.hooks && Object.keys(next.hooks).length === 0) delete next.hooks;
|
|
110
|
+
return { value: next, conflicts };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
BEGIN,
|
|
115
|
+
END,
|
|
116
|
+
LEGACY_BEGIN,
|
|
117
|
+
LEGACY_END,
|
|
118
|
+
equal,
|
|
119
|
+
hasManagedBlock,
|
|
120
|
+
managedBlock,
|
|
121
|
+
mergeHookConfig,
|
|
122
|
+
removeHookConfig,
|
|
123
|
+
removeManagedBlock,
|
|
124
|
+
upsertManagedBlock,
|
|
125
|
+
};
|