djournal 0.4.1 → 0.4.2

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.
@@ -62,12 +62,22 @@ function activeWork(root) {
62
62
  const context = projectContext(root);
63
63
  const state = JSON.parse(fs.readFileSync(path.join(context.journalRoot, "state.json"), "utf8"));
64
64
  if (typeof state.active_work_name !== "string" || !state.active_work_name) return null;
65
- const work = path.join(context.journalRoot, "work", state.active_work_name, "work.md");
65
+ return workBySlug(root, state.active_work_name);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function workBySlug(root, slug) {
72
+ try {
73
+ if (typeof slug !== "string" || !slug || slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") return null;
74
+ const context = projectContext(root);
75
+ const work = path.join(context.journalRoot, "work", slug, "work.md");
66
76
  if (!fs.existsSync(work)) return null;
67
77
  const metadata = parseFrontmatter(fs.readFileSync(work, "utf8"));
68
78
  const config = readConfig(context);
69
- const shared = config.sharing?.sharedWorkItems && Object.prototype.hasOwnProperty.call(config.sharing.sharedWorkItems, state.active_work_name);
70
- return { slug: state.active_work_name, visibility: metadata.visibility || "local_only", shared: !!shared, path: work };
79
+ const shared = config.sharing?.sharedWorkItems && Object.prototype.hasOwnProperty.call(config.sharing.sharedWorkItems, slug);
80
+ return { slug, visibility: metadata.visibility || "local_only", shared: !!shared, path: work };
71
81
  } catch {
72
82
  return null;
73
83
  }
@@ -96,26 +106,50 @@ function context(event, message) {
96
106
  };
97
107
  }
98
108
 
99
- function validateClosedPath(root, value) {
100
- if (!value) return false;
109
+ function resolveClosedEntry(root, value) {
110
+ if (!value) return null;
101
111
  const relative = value.trim().replaceAll("\\", "/");
102
- if (!relative.endsWith(".md") || path.isAbsolute(relative)) return false;
112
+ if (!relative.endsWith(".md") || path.isAbsolute(relative)) return null;
103
113
 
104
114
  const validSpineEntry = (resolved, journalRoot) => {
105
- const workRoot = path.resolve(journalRoot, "work") + path.sep;
106
- if (!resolved.startsWith(workRoot)) return false;
107
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return false;
108
- return resolved.split(path.sep).join("/").includes("/journal/");
115
+ const resolvedJournalRoot = path.resolve(journalRoot);
116
+ const workRoot = path.join(resolvedJournalRoot, "work");
117
+ const workRelative = path.relative(workRoot, resolved);
118
+ if (!workRelative || workRelative.startsWith("..") || path.isAbsolute(workRelative)) return null;
119
+ const parts = workRelative.split(path.sep);
120
+ if (parts.length < 3 || !parts[0] || parts[1] !== "journal") return null;
121
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return null;
122
+ return { relative, resolved, journalRoot: resolvedJournalRoot, workSlug: parts[0] };
109
123
  };
110
124
 
111
- const repoResolved = path.resolve(root, relative);
112
- if (validSpineEntry(repoResolved, path.join(root, ".journal"))) return true;
113
-
114
125
  const context = projectContext(root);
115
- const resolved = relative.startsWith(".journal/")
116
- ? path.resolve(context.journalRoot, relative.slice(".journal/".length))
117
- : path.resolve(root, relative);
118
- return validSpineEntry(resolved, context.journalRoot);
126
+ const candidates = [];
127
+ if (context.global && relative.startsWith(".journal/")) {
128
+ candidates.push({
129
+ resolved: path.resolve(context.journalRoot, relative.slice(".journal/".length)),
130
+ journalRoot: context.journalRoot,
131
+ });
132
+ }
133
+ candidates.push({
134
+ resolved: path.resolve(root, relative),
135
+ journalRoot: path.join(root, ".journal"),
136
+ });
137
+ if (!context.global && relative.startsWith(".journal/")) {
138
+ candidates.push({
139
+ resolved: path.resolve(context.journalRoot, relative.slice(".journal/".length)),
140
+ journalRoot: context.journalRoot,
141
+ });
142
+ }
143
+
144
+ for (const candidate of candidates) {
145
+ const entry = validSpineEntry(candidate.resolved, candidate.journalRoot);
146
+ if (entry) return entry;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function validateClosedPath(root, value) {
152
+ return !!resolveClosedEntry(root, value);
119
153
  }
120
154
 
121
155
  function syncSharedWork(root, work, runner) {
@@ -166,14 +200,16 @@ function handle(payload, options = {}) {
166
200
  };
167
201
  }
168
202
 
169
- if (match[1].toLowerCase() === "closed" && !validateClosedPath(root, match[2])) {
203
+ const status = match[1].toLowerCase();
204
+ const closedEntry = status === "closed" ? resolveClosedEntry(root, match[2]) : null;
205
+ if (status === "closed" && !closedEntry) {
170
206
  return {
171
207
  decision: "block",
172
208
  reason: "The journal-status closed marker must reference an existing Markdown spine entry under the resolved journal root. In global-store projects, .journal/... paths resolve through .djournal.json.",
173
209
  };
174
210
  }
175
- if (match[1].toLowerCase() === "closed") {
176
- const work = activeWork(root);
211
+ if (status === "closed") {
212
+ const work = workBySlug(root, closedEntry.workSlug);
177
213
  if ((work?.shared || work?.visibility === "team_shared") && shouldAutoSync(root)) {
178
214
  const result = syncSharedWork(root, work, options.syncRunner);
179
215
  if (!result.ok) {
@@ -200,4 +236,4 @@ if (require.main === module) {
200
236
  main();
201
237
  }
202
238
 
203
- module.exports = { handle, parseFrontmatter, shouldAutoSync };
239
+ module.exports = { handle, parseFrontmatter, resolveClosedEntry, shouldAutoSync };
@@ -124,6 +124,9 @@ Current behavior:
124
124
 
125
125
  Hook-triggered sync uses the same command path with `--auto` only when
126
126
  global config enables standalone automatic sync and the closed work is shared.
127
+ The hook derives the work item from the validated closed marker path, so a
128
+ response that closes `.journal/work/<slug>/journal/...` synchronizes that
129
+ `<slug>` even if `state.json` currently selects a different active work item.
127
130
 
128
131
  ## Keep local state local
129
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "djournal",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Durable project memory for coding agents, stored as linked Markdown",
5
5
  "type": "commonjs",
6
6
  "bin": {