djournal 0.1.0 → 0.2.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.
@@ -1,14 +1,16 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("node:crypto");
4
+ const { spawnSync } = require("node:child_process");
4
5
  const fs = require("node:fs");
6
+ const os = require("node:os");
5
7
  const path = require("node:path");
6
8
  const readline = require("node:readline/promises");
7
9
  const {
8
10
  equal,
9
11
  hasManagedBlock,
10
- mergeHookConfig,
11
- removeHookConfig,
12
+ mergeConfigFragment,
13
+ removeConfigFragment,
12
14
  removeManagedBlock,
13
15
  upsertManagedBlock,
14
16
  } = require("./merge.js");
@@ -18,6 +20,7 @@ const SUPPORTED_HARNESSES = ["codex", "claude-code"];
18
20
  const FUTURE_HARNESSES = ["opencode", "pi", "zed"];
19
21
  const MANIFEST_PATH = ".journal/.install/manifest.json";
20
22
  const TRANSACTION_PATH = ".journal/.install/transaction.json";
23
+ const PROJECT_MARKER_PATH = ".djournal.json";
21
24
 
22
25
  class InstallerError extends Error {
23
26
  constructor(message, code = "INSTALL_ERROR") {
@@ -43,10 +46,163 @@ function parseJson(buffer, label) {
43
46
  catch (error) { throw new InstallerError(`${label} is not valid JSON: ${error.message}`, "INVALID_JSON"); }
44
47
  }
45
48
 
49
+ function parseFrontmatter(text) {
50
+ if (!text.startsWith("---\n")) return {};
51
+ const end = text.indexOf("\n---", 4);
52
+ if (end === -1) return {};
53
+ const result = {};
54
+ for (const line of text.slice(4, end).split("\n")) {
55
+ const match = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/);
56
+ if (!match) continue;
57
+ result[match[1]] = match[2].trim().replace(/^"(.*)"$/, "$1");
58
+ }
59
+ return result;
60
+ }
61
+
62
+ function stringifyFrontmatterValue(value) {
63
+ return /[:#{}\[\],"]|\s/.test(value) ? JSON.stringify(value) : value;
64
+ }
65
+
66
+ function replaceFrontmatterField(text, field, value) {
67
+ if (!text.startsWith("---\n")) throw new InstallerError("work.md is missing frontmatter", "INVALID_JOURNAL");
68
+ const end = text.indexOf("\n---", 4);
69
+ if (end === -1) throw new InstallerError("work.md has unterminated frontmatter", "INVALID_JOURNAL");
70
+ const before = text.slice(0, end);
71
+ const after = text.slice(end);
72
+ const line = `${field}: ${stringifyFrontmatterValue(value)}`;
73
+ const pattern = new RegExp(`^${field}:.*$`, "m");
74
+ const next = pattern.test(before) ? before.replace(pattern, line) : `${before}\n${line}`;
75
+ return `${next}${after}`;
76
+ }
77
+
46
78
  function jsonBuffer(value) {
47
79
  return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
48
80
  }
49
81
 
82
+ function readJsonFile(file, label) {
83
+ return parseJson(readOptional(file), label);
84
+ }
85
+
86
+ function writeJsonFile(file, value) {
87
+ atomicWrite(file, jsonBuffer(value));
88
+ }
89
+
90
+ function mergePlainObject(base, patch) {
91
+ const result = { ...base };
92
+ for (const [key, value] of Object.entries(patch)) {
93
+ if (value && typeof value === "object" && !Array.isArray(value) && result[key] && typeof result[key] === "object" && !Array.isArray(result[key])) {
94
+ result[key] = mergePlainObject(result[key], value);
95
+ } else result[key] = value;
96
+ }
97
+ return result;
98
+ }
99
+
100
+ function djournalHome(options = {}) {
101
+ return path.resolve(options.djournalHome || process.env.DJOURNAL_HOME || path.join(os.homedir(), ".djournal"));
102
+ }
103
+
104
+ function projectKeyFor(target) {
105
+ const base = path.basename(path.resolve(target)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
106
+ return `${base}-${sha256(path.resolve(target)).slice(0, 8)}`;
107
+ }
108
+
109
+ function projectStoreFor(target, options = {}) {
110
+ const projectKey = options.projectKey || projectKeyFor(target);
111
+ const root = path.join(djournalHome(options), "projects", projectKey);
112
+ return { projectKey, root, journalRoot: path.join(root, ".journal"), configFile: path.join(root, "config.json") };
113
+ }
114
+
115
+ function resolveProjectStore(target, options = {}) {
116
+ return readProjectMarker(target) || projectStoreFor(target, options);
117
+ }
118
+
119
+ function readProjectMarker(target) {
120
+ const marker = readOptional(resolveWithin(target, PROJECT_MARKER_PATH));
121
+ if (!marker) return null;
122
+ const value = parseJson(marker, PROJECT_MARKER_PATH);
123
+ if (value.schemaVersion !== SCHEMA_VERSION) {
124
+ throw new InstallerError(`unsupported project marker schema: ${value.schemaVersion}`, "UNSUPPORTED_MARKER");
125
+ }
126
+ if (!value.projectKey || !value.journalStore) {
127
+ throw new InstallerError("project marker is missing projectKey or journalStore", "INVALID_JSON");
128
+ }
129
+ const root = path.resolve(value.journalStore);
130
+ return { projectKey: value.projectKey, root, journalRoot: path.join(root, ".journal"), configFile: path.join(root, "config.json") };
131
+ }
132
+
133
+ function projectContext(target, options = {}) {
134
+ const marker = readProjectMarker(target);
135
+ if (marker) return { ...marker, target, global: true };
136
+ const store = projectStoreFor(target, options);
137
+ if (fs.existsSync(store.configFile) || fs.existsSync(store.journalRoot)) return { ...store, target, global: true };
138
+ return {
139
+ target,
140
+ global: false,
141
+ projectKey: null,
142
+ root: target,
143
+ journalRoot: resolveWithin(target, ".journal"),
144
+ configFile: resolveWithin(target, ".journal/config.json"),
145
+ };
146
+ }
147
+
148
+ function defaultProjectConfig(target, store) {
149
+ return {
150
+ schemaVersion: SCHEMA_VERSION,
151
+ project: {
152
+ id: store.projectKey,
153
+ name: path.basename(target),
154
+ sourcePath: path.resolve(target),
155
+ },
156
+ sync: {
157
+ enabled: false,
158
+ mode: "colocated",
159
+ path: path.resolve(target),
160
+ auto: false,
161
+ },
162
+ sharing: {
163
+ default: "local_only",
164
+ sharedWorkItems: {},
165
+ },
166
+ };
167
+ }
168
+
169
+ function readProjectConfig(context) {
170
+ const config = readJsonFile(context.configFile, context.global ? "config.json" : ".journal/config.json");
171
+ if (!context.global) return config;
172
+ return mergePlainObject(defaultProjectConfig(context.target, context), config);
173
+ }
174
+
175
+ function writeProjectConfig(context, config) {
176
+ if (!context.global) writeJsonFile(context.configFile, config);
177
+ else writeJsonFile(context.configFile, config);
178
+ }
179
+
180
+ function copyDirectoryContents(source, destination, options = {}) {
181
+ if (!fs.existsSync(source)) return [];
182
+ if (fs.lstatSync(source).isSymbolicLink()) throw new InstallerError(`source is a symlink: ${source}`, "UNSAFE_PATH");
183
+ const copied = [];
184
+ for (const relative of listFiles(source)) {
185
+ if (options.exclude?.(relative)) continue;
186
+ const sourceFile = path.join(source, relative);
187
+ const destinationFile = path.join(destination, relative);
188
+ const destinationRoot = path.resolve(destination);
189
+ const resolved = path.resolve(destinationFile);
190
+ if (resolved !== destinationRoot && !resolved.startsWith(`${destinationRoot}${path.sep}`)) {
191
+ throw new InstallerError(`projection escapes target: ${relative}`, "UNSAFE_PATH");
192
+ }
193
+ if (fs.lstatSync(sourceFile).isSymbolicLink()) throw new InstallerError(`source file is a symlink: ${relative}`, "UNSAFE_PATH");
194
+ if (fs.existsSync(destinationFile) && fs.lstatSync(destinationFile).isSymbolicLink()) {
195
+ throw new InstallerError(`projection destination is a symlink: ${relative}`, "UNSAFE_PATH");
196
+ }
197
+ if (!options.dryRun) {
198
+ fs.mkdirSync(path.dirname(destinationFile), { recursive: true });
199
+ fs.copyFileSync(sourceFile, destinationFile);
200
+ }
201
+ copied.push(relative.split(path.sep).join("/"));
202
+ }
203
+ return copied;
204
+ }
205
+
50
206
  function resolveWithin(target, relative) {
51
207
  if (!relative || path.isAbsolute(relative)) {
52
208
  throw new InstallerError(`unsafe destination path: ${relative}`, "UNSAFE_PATH");
@@ -82,7 +238,7 @@ function listFiles(directory, prefix = "") {
82
238
  }
83
239
 
84
240
  function sourceInventory(sourceRoot) {
85
- const files = ["spec.md"];
241
+ const files = [];
86
242
  for (const directory of [".agents/rules", ".agents/skills", ".agents/adapters"]) {
87
243
  for (const relative of listFiles(path.join(sourceRoot, directory))) {
88
244
  files.push(`${directory}/${relative}`);
@@ -252,21 +408,36 @@ function markdownRecord(target, relative, body, oldRecord, harness) {
252
408
  };
253
409
  }
254
410
 
411
+ function journalAccessFragment(harness, store) {
412
+ if (harness !== "claude-code") return {};
413
+ const commands = ["status", "doctor", "config", "share", "sync"];
414
+ return {
415
+ permissions: {
416
+ additionalDirectories: [store.root],
417
+ allow: commands.flatMap((command) => [
418
+ `Bash(journal ${command}:*)`,
419
+ `Bash(djournal ${command}:*)`,
420
+ ]),
421
+ },
422
+ };
423
+ }
424
+
255
425
  function jsonRecord(target, relative, fragment, oldRecord, harness) {
256
426
  const absolute = resolveWithin(target, relative);
257
427
  const original = readOptional(absolute);
258
428
  const current = parseJson(original, relative);
259
429
  let base = current;
260
- if (oldRecord?.injected) {
261
- const removed = removeHookConfig(base, oldRecord.injected);
430
+ if (oldRecord?.injected || oldRecord?.injectedPermissions) {
431
+ const removed = removeConfigFragment(base, oldRecord.injected, oldRecord.injectedPermissions);
262
432
  if (removed.conflicts.length) {
263
- throw new InstallerError(`managed hook group was modified: ${relative}`, "ASSET_CONFLICT");
433
+ throw new InstallerError(`managed JSON fragment was modified: ${relative}`, "ASSET_CONFLICT");
264
434
  }
265
435
  base = removed.value;
266
436
  }
267
- const merged = mergeHookConfig(base, fragment);
437
+ const merged = mergeConfigFragment(base, fragment);
268
438
  const injectedCount = Object.values(merged.injected).reduce((total, groups) => total + groups.length, 0);
269
- const next = injectedCount === 0 && original !== null ? original : jsonBuffer(merged.value);
439
+ const injectedPermissionCount = Object.values(merged.injectedPermissions).reduce((total, values) => total + values.length, 0);
440
+ const next = injectedCount === 0 && injectedPermissionCount === 0 && original !== null ? original : jsonBuffer(merged.value);
270
441
  return {
271
442
  op: operation(target, relative, next),
272
443
  record: {
@@ -276,6 +447,7 @@ function jsonRecord(target, relative, fragment, oldRecord, harness) {
276
447
  created: oldRecord?.created ?? original === null,
277
448
  installedHash: sha256(next),
278
449
  injected: merged.injected,
450
+ injectedPermissions: merged.injectedPermissions,
279
451
  },
280
452
  };
281
453
  }
@@ -307,7 +479,7 @@ function harnessFragment(sourceRoot, harness) {
307
479
  return parseJson(fs.readFileSync(path.join(sourceRoot, relative)), relative);
308
480
  }
309
481
 
310
- function planInstall(sourceRoot, target, harnesses, oldManifest) {
482
+ function planInstall(sourceRoot, target, harnesses, oldManifest, store) {
311
483
  const old = recordMap(oldManifest);
312
484
  const records = [];
313
485
  const operations = [];
@@ -326,7 +498,13 @@ function planInstall(sourceRoot, target, harnesses, oldManifest) {
326
498
  if (harnesses.includes("claude-code")) {
327
499
  const bridge = markdownRecord(target, "CLAUDE.md", "@AGENTS.md", old.get("CLAUDE.md"), "claude-code");
328
500
  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");
501
+ const hooks = jsonRecord(
502
+ target,
503
+ ".claude/settings.json",
504
+ mergePlainObject(harnessFragment(sourceRoot, "claude-code"), journalAccessFragment("claude-code", store)),
505
+ old.get(".claude/settings.json"),
506
+ "claude-code",
507
+ );
330
508
  records.push(hooks.record); operations.push(hooks.op);
331
509
  }
332
510
 
@@ -356,6 +534,258 @@ function checkTarget(target) {
356
534
  return resolved;
357
535
  }
358
536
 
537
+ function readActiveWork(context, requested) {
538
+ const journalRoot = context.journalRoot;
539
+ let slug = requested;
540
+ if (!slug) {
541
+ const state = parseJson(readOptional(path.join(journalRoot, "state.json")), ".journal/state.json");
542
+ slug = state.active_work_name;
543
+ }
544
+ if (typeof slug !== "string" || !slug) {
545
+ throw new InstallerError("No active work found. Run init-work or switch first.", "NO_ACTIVE_WORK");
546
+ }
547
+ if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
548
+ throw new InstallerError(`invalid work slug: ${slug}`, "INVALID_WORK");
549
+ }
550
+ const relative = `.journal/work/${slug}/work.md`;
551
+ const file = path.join(journalRoot, "work", slug, "work.md");
552
+ const buffer = readOptional(file);
553
+ if (!buffer) throw new InstallerError(`work item not found: ${slug}`, "INVALID_WORK");
554
+ const text = buffer.toString("utf8");
555
+ const metadata = parseFrontmatter(text);
556
+ return { slug, relative, file, text, metadata, visibility: metadata.visibility || "local_only" };
557
+ }
558
+
559
+ function readJournalConfig(target) {
560
+ const context = projectContext(target);
561
+ return readProjectConfig(context);
562
+ }
563
+
564
+ function syncConfig(target) {
565
+ const config = readJournalConfig(target);
566
+ return config.sync && typeof config.sync === "object" ? config.sync : {};
567
+ }
568
+
569
+ function sharingConfig(config) {
570
+ return config.sharing && typeof config.sharing === "object" ? config.sharing : {};
571
+ }
572
+
573
+ function sharedWorkItems(config) {
574
+ const sharing = sharingConfig(config);
575
+ return sharing.sharedWorkItems && typeof sharing.sharedWorkItems === "object" ? sharing.sharedWorkItems : {};
576
+ }
577
+
578
+ function isWorkShared(config, slug) {
579
+ return Object.prototype.hasOwnProperty.call(sharedWorkItems(config), slug);
580
+ }
581
+
582
+ function parseConfigValue(value) {
583
+ if (value === "true") return true;
584
+ if (value === "false") return false;
585
+ if (value === "null") return null;
586
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
587
+ return value;
588
+ }
589
+
590
+ function setNested(value, keyPath, next) {
591
+ const parts = keyPath.split(".").filter(Boolean);
592
+ if (!parts.length) throw new InstallerError("config key is required", "USAGE");
593
+ const result = { ...value };
594
+ let cursor = result;
595
+ for (const part of parts.slice(0, -1)) {
596
+ const current = cursor[part];
597
+ cursor[part] = current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
598
+ cursor = cursor[part];
599
+ }
600
+ cursor[parts.at(-1)] = next;
601
+ return result;
602
+ }
603
+
604
+ function getNested(value, keyPath) {
605
+ if (!keyPath) return value;
606
+ let cursor = value;
607
+ for (const part of keyPath.split(".").filter(Boolean)) {
608
+ if (!cursor || typeof cursor !== "object" || !(part in cursor)) return undefined;
609
+ cursor = cursor[part];
610
+ }
611
+ return cursor;
612
+ }
613
+
614
+ function configure(options) {
615
+ const target = checkTarget(options.target || process.cwd());
616
+ const context = projectContext(target, options);
617
+ if (!context.global) throw new InstallerError("djournal project store is not initialized; run journal install first", "NOT_INSTALLED");
618
+ const current = readProjectConfig(context);
619
+ if (!options.key) return { action: "config", target, projectKey: context.projectKey, config: current };
620
+ if (typeof options.value === "undefined") {
621
+ return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(current, options.key) };
622
+ }
623
+ const next = setNested(current, options.key, parseConfigValue(options.value));
624
+ if (!options.dryRun) writeProjectConfig(context, next);
625
+ return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(next, options.key), changed: true, dryRun: !!options.dryRun };
626
+ }
627
+
628
+ function gitIgnored(target, relative, options = {}) {
629
+ const result = (options.runner || spawnSync)("git", ["-C", target, "check-ignore", "-q", relative], {
630
+ encoding: "utf8",
631
+ timeout: options.timeout || 30000,
632
+ });
633
+ if (result.error) return false;
634
+ return result.status === 0;
635
+ }
636
+
637
+ function share(options) {
638
+ const target = checkTarget(options.target || process.cwd());
639
+ const context = projectContext(target, options);
640
+ const config = readProjectConfig(context);
641
+ const work = readActiveWork(context, options.work);
642
+ const projectionRoot = projectionRootFor(target, config);
643
+ const ignored = config.sync?.mode === "colocated" ? gitIgnored(projectionRoot, `.journal/work/${work.slug}`, options) : false;
644
+ const warning = ignored
645
+ ? `.journal/work/${work.slug} appears to be ignored by Git; update .gitignore or force-add it before expecting colocated Git commits to share this work.`
646
+ : undefined;
647
+ const shared = sharedWorkItems(config);
648
+ if (Object.prototype.hasOwnProperty.call(shared, work.slug)) {
649
+ return { action: "share", target, projectKey: context.projectKey, work: work.slug, changed: false, shared: true, gitIgnored: ignored, warning };
650
+ }
651
+ const next = mergePlainObject(config, {
652
+ sharing: {
653
+ sharedWorkItems: {
654
+ ...shared,
655
+ [work.slug]: {
656
+ sharedAt: new Date().toISOString(),
657
+ sharedBy: process.env.JOURNAL_USER_ID || runGitConfigEmail(options) || `${os.userInfo().username}@local`,
658
+ },
659
+ },
660
+ },
661
+ });
662
+ if (!options.dryRun) writeProjectConfig(context, next);
663
+ return { action: "share", target, projectKey: context.projectKey, work: work.slug, changed: true, shared: true, dryRun: !!options.dryRun, gitIgnored: ignored, warning };
664
+ }
665
+
666
+ function runGitConfigEmail(options = {}) {
667
+ const result = (options.runner || spawnSync)("git", ["config", "user.email"], {
668
+ encoding: "utf8",
669
+ timeout: options.timeout || 30000,
670
+ });
671
+ if (result.error || result.status !== 0) return "";
672
+ return (result.stdout || "").trim();
673
+ }
674
+
675
+ function runGit(gitRoot, args, options = {}) {
676
+ const result = (options.runner || spawnSync)("git", ["-C", gitRoot, ...args], {
677
+ encoding: "utf8",
678
+ timeout: options.timeout || 30000,
679
+ });
680
+ if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
681
+ if (result.status !== 0) {
682
+ const detail = `${result.stderr || ""}${result.stdout || ""}`.trim();
683
+ throw new InstallerError(detail || `git ${args.join(" ")} failed`, "GIT_ERROR");
684
+ }
685
+ return (result.stdout || "").trim();
686
+ }
687
+
688
+ function gitRootFor(directory, options = {}) {
689
+ const result = (options.runner || spawnSync)("git", ["-C", directory, "rev-parse", "--show-toplevel"], {
690
+ encoding: "utf8",
691
+ timeout: options.timeout || 30000,
692
+ });
693
+ if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
694
+ if (result.status !== 0) throw new InstallerError(".journal is not inside a Git work tree", "GIT_ERROR");
695
+ return (result.stdout || "").trim();
696
+ }
697
+
698
+ function journalPathspec(gitRoot, journalRoot) {
699
+ const relative = path.relative(gitRoot, journalRoot).split(path.sep).join("/");
700
+ return relative && !relative.startsWith("..") ? relative : ".";
701
+ }
702
+
703
+ function projectionRootFor(target, config) {
704
+ const sync = config.sync && typeof config.sync === "object" ? config.sync : {};
705
+ if (sync.path) return path.resolve(target, sync.path);
706
+ return path.resolve(target);
707
+ }
708
+
709
+ function projectJournalRoot(projectionRoot) {
710
+ return path.join(projectionRoot, ".journal");
711
+ }
712
+
713
+ function projectSharedWork(context, config, work, options = {}) {
714
+ const targetRoot = projectionRootFor(context.target, config);
715
+ const destinationJournal = projectJournalRoot(targetRoot);
716
+ const sourceWork = path.join(context.journalRoot, "work", work.slug);
717
+ const destinationWork = path.join(destinationJournal, "work", work.slug);
718
+ if (!fs.existsSync(sourceWork)) throw new InstallerError(`work item not found: ${work.slug}`, "INVALID_WORK");
719
+ const files = copyDirectoryContents(sourceWork, destinationWork, options).map((file) => `.journal/work/${work.slug}/${file}`);
720
+ return { projectionRoot: targetRoot, journalRoot: destinationJournal, files };
721
+ }
722
+
723
+ function bootstrapProjectStore(target, options = {}) {
724
+ const store = options.store || resolveProjectStore(target, options);
725
+ const existingConfig = readOptional(store.configFile);
726
+ const config = existingConfig
727
+ ? mergePlainObject(defaultProjectConfig(target, store), parseJson(existingConfig, "config.json"))
728
+ : defaultProjectConfig(target, store);
729
+ const legacyConfig = readOptional(resolveWithin(target, ".journal/config.json"));
730
+ if (legacyConfig) {
731
+ const parsedLegacy = parseJson(legacyConfig, ".journal/config.json");
732
+ if (parsedLegacy.sync || parsedLegacy.sharing) {
733
+ config.sync = { ...config.sync, ...(parsedLegacy.sync || {}) };
734
+ config.sharing = mergePlainObject(config.sharing, parsedLegacy.sharing || {});
735
+ }
736
+ }
737
+ if (!options.dryRun) {
738
+ fs.mkdirSync(store.journalRoot, { recursive: true });
739
+ copyDirectoryContents(resolveWithin(target, ".journal"), store.journalRoot, {
740
+ dryRun: false,
741
+ exclude: (relative) => relative === "config.json" || relative.startsWith(".install/"),
742
+ });
743
+ writeProjectConfig({ ...store, target, global: true }, config);
744
+ }
745
+ return { ...store, config };
746
+ }
747
+
748
+ function sync(options) {
749
+ const target = checkTarget(options.target || process.cwd());
750
+ const context = projectContext(target, options);
751
+ const config = readProjectConfig(context);
752
+ const syncOptions = config.sync && typeof config.sync === "object" ? config.sync : {};
753
+ if (syncOptions.enabled !== true) {
754
+ return { action: "sync", target, skipped: true, reason: "sync is not enabled" };
755
+ }
756
+ if (!["colocated", "standalone"].includes(syncOptions.mode)) {
757
+ return { action: "sync", target, skipped: true, reason: "sync mode is not standalone" };
758
+ }
759
+ const work = readActiveWork(context, options.work);
760
+ if (!isWorkShared(config, work.slug)) {
761
+ return { action: "sync", target, work: work.slug, skipped: true, reason: "work is not shared" };
762
+ }
763
+ const projection = projectSharedWork(context, config, work, options);
764
+ if (syncOptions.mode === "colocated") {
765
+ return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, files: projection.files, dryRun: !!options.dryRun };
766
+ }
767
+ const gitRoot = gitRootFor(projection.journalRoot, options);
768
+ const pathspec = journalPathspec(gitRoot, projection.journalRoot);
769
+ const unmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
770
+ if (unmerged) throw new InstallerError(`unresolved journal conflicts:\n${unmerged}`, "SYNC_CONFLICT");
771
+ if (options.dryRun) return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, dryRun: true };
772
+
773
+ runGit(gitRoot, ["pull", "--ff-only"], options);
774
+ const afterPullUnmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
775
+ if (afterPullUnmerged) throw new InstallerError(`unresolved journal conflicts:\n${afterPullUnmerged}`, "SYNC_CONFLICT");
776
+ runGit(gitRoot, ["add", "--", pathspec], options);
777
+ let committed = true;
778
+ try {
779
+ runGit(gitRoot, ["diff", "--cached", "--quiet", "--", pathspec], options);
780
+ committed = false;
781
+ } catch (error) {
782
+ if (!(error instanceof InstallerError) || error.code !== "GIT_ERROR") throw error;
783
+ runGit(gitRoot, ["commit", "-m", `chore(journal): sync ${work.slug}`], options);
784
+ }
785
+ runGit(gitRoot, ["push"], options);
786
+ return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, committed, pushed: true, auto: !!options.auto };
787
+ }
788
+
359
789
  function doctor(options) {
360
790
  const target = checkTarget(options.target || process.cwd());
361
791
  const detected = detectHarnesses(target, options);
@@ -389,7 +819,8 @@ async function install(options) {
389
819
  const detected = health.detected;
390
820
  const selected = await selectHarnesses({ ...options, interactive: options.interactive ?? process.stdin.isTTY }, detected);
391
821
  const harnesses = [...new Set([...(oldManifest?.harnesses || []), ...selected])].sort();
392
- const planned = planInstall(sourceRoot, target, harnesses, oldManifest);
822
+ const store = bootstrapProjectStore(target, { ...options, store: resolveProjectStore(target, options) });
823
+ const planned = planInstall(sourceRoot, target, harnesses, oldManifest, store);
393
824
  const manifest = {
394
825
  schemaVersion: SCHEMA_VERSION,
395
826
  toolVersion: packageVersion(sourceRoot),
@@ -398,6 +829,11 @@ async function install(options) {
398
829
  harnesses,
399
830
  files: planned.records.sort((a, b) => a.path.localeCompare(b.path)),
400
831
  };
832
+ planned.operations.push(operation(target, PROJECT_MARKER_PATH, jsonBuffer({
833
+ schemaVersion: SCHEMA_VERSION,
834
+ projectKey: store.projectKey,
835
+ journalStore: store.root,
836
+ })));
401
837
  planned.operations.push(operation(target, MANIFEST_PATH, jsonBuffer(manifest)));
402
838
  applyTransaction(target, planned.operations, options);
403
839
  if (!options.dryRun) {
@@ -440,7 +876,7 @@ function uninstallRecord(target, record, conflicts) {
440
876
  const injectedCount = Object.values(record.injected || {}).reduce((total, groups) => total + groups.length, 0);
441
877
  if (injectedCount === 0 && !record.created) return null;
442
878
  const parsed = parseJson(current, record.path);
443
- const removed = removeHookConfig(parsed, record.injected);
879
+ const removed = removeConfigFragment(parsed, record.injected, record.injectedPermissions);
444
880
  if (removed.conflicts.length) { conflicts.push(record.path); return null; }
445
881
  const empty = Object.keys(removed.value).length === 0;
446
882
  return operation(target, record.path, empty && record.created ? null : jsonBuffer(removed.value));
@@ -504,8 +940,11 @@ function recordStatus(target, record) {
504
940
  if (record.mode === "json_merge") {
505
941
  try {
506
942
  const value = parseJson(current, record.path);
507
- const present = Object.entries(record.injected || {}).every(([event, groups]) =>
943
+ const hooksPresent = Object.entries(record.injected || {}).every(([event, groups]) =>
508
944
  groups.every((group) => value.hooks?.[event]?.some((candidate) => equal(candidate, group))));
945
+ const permissionsPresent = Object.entries(record.injectedPermissions || {}).every(([key, values]) =>
946
+ values.every((item) => value.permissions?.[key]?.some((candidate) => equal(candidate, item))));
947
+ const present = hooksPresent && permissionsPresent;
509
948
  return present ? (sha256(current) === record.installedHash ? "clean" : "modified") : "missing";
510
949
  } catch { return "invalid"; }
511
950
  }
@@ -523,15 +962,21 @@ function status(options) {
523
962
  module.exports = {
524
963
  InstallerError,
525
964
  MANIFEST_PATH,
965
+ PROJECT_MARKER_PATH,
526
966
  SUPPORTED_HARNESSES,
527
967
  applyTransaction,
968
+ configure,
528
969
  detectHarnesses,
529
970
  doctor,
971
+ djournalHome,
530
972
  install,
531
973
  loadManifest,
974
+ projectContext,
532
975
  recoverTransaction,
533
976
  selectHarnesses,
977
+ share,
534
978
  sourceInventory,
979
+ sync,
535
980
  status,
536
981
  uninstall,
537
982
  upgrade,
@@ -90,6 +90,47 @@ function mergeHookConfig(current, fragment) {
90
90
  return { value: next, injected };
91
91
  }
92
92
 
93
+ function appendUnique(target, values) {
94
+ const injected = [];
95
+ for (const value of values || []) {
96
+ if (!target.some((candidate) => equal(candidate, value))) {
97
+ const copy = structuredClone(value);
98
+ target.push(copy);
99
+ injected.push(copy);
100
+ }
101
+ }
102
+ return injected;
103
+ }
104
+
105
+ function mergePermissionsConfig(current, fragment) {
106
+ const next = structuredClone(current);
107
+ const permissions = fragment.permissions || {};
108
+ const injected = {};
109
+ if (Array.isArray(permissions.additionalDirectories)) {
110
+ next.permissions ||= {};
111
+ next.permissions.additionalDirectories ||= [];
112
+ const additions = appendUnique(next.permissions.additionalDirectories, permissions.additionalDirectories);
113
+ if (additions.length) injected.additionalDirectories = additions;
114
+ }
115
+ if (Array.isArray(permissions.allow)) {
116
+ next.permissions ||= {};
117
+ next.permissions.allow ||= [];
118
+ const additions = appendUnique(next.permissions.allow, permissions.allow);
119
+ if (additions.length) injected.allow = additions;
120
+ }
121
+ return { value: next, injected };
122
+ }
123
+
124
+ function mergeConfigFragment(current, fragment) {
125
+ const hook = mergeHookConfig(current, fragment);
126
+ const permissions = mergePermissionsConfig(hook.value, fragment);
127
+ return {
128
+ value: permissions.value,
129
+ injected: hook.injected,
130
+ injectedPermissions: permissions.injected,
131
+ };
132
+ }
133
+
93
134
  function removeHookConfig(current, injected) {
94
135
  const next = structuredClone(current);
95
136
  const conflicts = [];
@@ -110,6 +151,35 @@ function removeHookConfig(current, injected) {
110
151
  return { value: next, conflicts };
111
152
  }
112
153
 
154
+ function removePermissionsConfig(current, injected) {
155
+ const next = structuredClone(current);
156
+ const conflicts = [];
157
+ for (const [key, values] of Object.entries(injected || {})) {
158
+ const existing = next.permissions?.[key];
159
+ if (!Array.isArray(existing)) {
160
+ conflicts.push(`permissions.${key}`);
161
+ continue;
162
+ }
163
+ for (const value of values) {
164
+ const index = existing.findIndex((candidate) => equal(candidate, value));
165
+ if (index === -1) conflicts.push(`permissions.${key}`);
166
+ else existing.splice(index, 1);
167
+ }
168
+ if (existing.length === 0) delete next.permissions[key];
169
+ }
170
+ if (next.permissions && Object.keys(next.permissions).length === 0) delete next.permissions;
171
+ return { value: next, conflicts };
172
+ }
173
+
174
+ function removeConfigFragment(current, injected, injectedPermissions) {
175
+ const hook = removeHookConfig(current, injected);
176
+ const permissions = removePermissionsConfig(hook.value, injectedPermissions);
177
+ return {
178
+ value: permissions.value,
179
+ conflicts: [...hook.conflicts, ...permissions.conflicts],
180
+ };
181
+ }
182
+
113
183
  module.exports = {
114
184
  BEGIN,
115
185
  END,
@@ -118,8 +188,12 @@ module.exports = {
118
188
  equal,
119
189
  hasManagedBlock,
120
190
  managedBlock,
191
+ mergeConfigFragment,
121
192
  mergeHookConfig,
193
+ mergePermissionsConfig,
194
+ removeConfigFragment,
122
195
  removeHookConfig,
196
+ removePermissionsConfig,
123
197
  removeManagedBlock,
124
198
  upsertManagedBlock,
125
199
  };