clay-server 4.0.0-beta.16 → 4.0.0-beta.17

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.
@@ -7,6 +7,7 @@
7
7
  var fs = require("fs");
8
8
  var { scanWorktrees, isWorktree } = require("./worktree");
9
9
  var { daemonSync } = require("./daemon-sync");
10
+ var logContext = require("./project-log-context");
10
11
 
11
12
  // --- Worktree tracking state ---
12
13
  var worktreeRegistry = {}; // parentSlug -> [wtSlug, ...]
@@ -28,7 +29,7 @@ function isWorktreeSlug(slug) {
28
29
  * @param {string} parentIcon - icon of parent project
29
30
  * @param {string} parentOwnerId - owner user ID
30
31
  */
31
- function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId) {
32
+ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId) {
32
33
  if (isWorktree(parentPath)) return;
33
34
  var worktrees = scanWorktrees(parentPath);
34
35
  if (worktrees.length === 0) return;
@@ -41,13 +42,14 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
41
42
  if (worktreeRegistry[parentSlug][j] === wtSlug) { alreadyRegistered = true; break; }
42
43
  }
43
44
  if (alreadyRegistered) continue;
44
- var wtMeta = { parentSlug: parentSlug, branch: wt.branch || wt.dirName, external: wt.external };
45
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
46
+ var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
45
47
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
46
48
  worktreeRegistry[parentSlug].push(wtSlug);
47
49
  console.log("[daemon] Registered worktree:", wtSlug, "->", wt.path, wt.external ? "(external)" : "(inside project folder)");
48
50
  }
49
51
  daemonSync.register(getWorktreeSyncKey(parentSlug), function () {
50
- rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId);
52
+ rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, null, projectKnowledgeId);
51
53
  });
52
54
  }
53
55
 
@@ -60,7 +62,7 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
60
62
  * @param {string} parentOwnerId - owner user ID
61
63
  * @param {object} [config] - daemon config (optional, for broadcasting project count)
62
64
  */
63
- function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config) {
65
+ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId) {
64
66
  if (worktreeScanning[parentSlug]) return;
65
67
  worktreeScanning[parentSlug] = true;
66
68
  try {
@@ -79,7 +81,8 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
79
81
  if (existingSlugs[ei] === wtSlug) { found = true; break; }
80
82
  }
81
83
  if (!found) {
82
- var wtMeta = { parentSlug: parentSlug, branch: wt.branch || wt.dirName, external: wt.external };
84
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
85
+ var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
83
86
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
84
87
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
85
88
  worktreeRegistry[parentSlug].push(wtSlug);
@@ -91,6 +94,7 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
91
94
  var sSlug = existingSlugs[si];
92
95
  var dirName = sSlug.split("--").slice(1).join("--");
93
96
  if (!discoveredNames[dirName]) {
97
+ if (relay.setProjectLogContextState) relay.setProjectLogContextState(sSlug, "archived");
94
98
  relay.removeProject(sSlug);
95
99
  existingSlugs.splice(si, 1);
96
100
  console.log("[daemon] Rescan: removed stale worktree:", sSlug);
@@ -112,6 +116,7 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
112
116
  function cleanupWorktreesForParent(relay, parentSlug) {
113
117
  var wtSlugs = worktreeRegistry[parentSlug] || [];
114
118
  for (var i = 0; i < wtSlugs.length; i++) {
119
+ if (relay.setProjectLogContextState) relay.setProjectLogContextState(wtSlugs[i], "archived");
115
120
  relay.removeProject(wtSlugs[i]);
116
121
  console.log("[daemon] Cascade removed worktree:", wtSlugs[i]);
117
122
  }
package/lib/daemon.js CHANGED
@@ -36,6 +36,7 @@ var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
36
36
  var mates = require("./mates");
37
37
  var mateKnowledgeMigration = require("./mate-knowledge-migration");
38
38
  var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
39
+ var projectLogContext = require("./project-log-context");
39
40
 
40
41
  var daemonVersion = require("../package.json").version;
41
42
  var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
@@ -65,6 +66,11 @@ try {
65
66
  console.error("[daemon] ensureMultiUser threw (continuing):", e && e.message);
66
67
  }
67
68
 
69
+ if (projectLogContext.initializeProjectKnowledge(config)) {
70
+ saveConfig(config);
71
+ console.log("[daemon] Assigned stable knowledge identities to existing projects");
72
+ }
73
+
68
74
  // --- OS users mode: check required system dependencies ---
69
75
  // Supplementary-group inheritance (issue #367): default on so sessions behave
70
76
  // like a normal login; operators can disable via config / the settings toggle.
@@ -153,6 +159,20 @@ var lanIp = (function () {
153
159
  // --- Create multi-project server ---
154
160
  var listenHost = config.host || "0.0.0.0";
155
161
 
162
+ function rememberedProjectKnowledgeId(projectPath) {
163
+ var removed = config.removedProjects || [];
164
+ for (var i = removed.length - 1; i >= 0; i--) {
165
+ if (removed[i].path === projectPath && projectLogContext.validId(removed[i].projectKnowledgeId)) {
166
+ return removed[i].projectKnowledgeId;
167
+ }
168
+ }
169
+ return projectLogContext.createProjectKnowledgeId();
170
+ }
171
+
172
+ function projectRuntimeOptions(project) {
173
+ return { projectKnowledgeId: project.projectKnowledgeId };
174
+ }
175
+
156
176
  var relay = createServer({
157
177
  tlsOptions: tlsOptions,
158
178
  caPath: caRoot,
@@ -173,13 +193,14 @@ var relay = createServer({
173
193
  }
174
194
  var slugs = config.projects.map(function (p) { return p.slug; });
175
195
  var slug = generateSlug(absPath, slugs);
176
- relay.addProject(absPath, slug, null, null, wsUser ? wsUser.id : null);
177
196
  var projectEntry = { path: absPath, slug: slug, addedAt: Date.now(), visibility: "private" };
197
+ projectEntry.projectKnowledgeId = rememberedProjectKnowledgeId(absPath);
178
198
  // The user who adds a project always becomes the owner
179
199
  if (wsUser && wsUser.id) {
180
200
  projectEntry.ownerId = wsUser.id;
181
201
  }
182
202
  config.projects.push(projectEntry);
203
+ relay.addProject(absPath, slug, null, null, wsUser ? wsUser.id : null, null, projectRuntimeOptions(projectEntry));
183
204
  // Remove from removedProjects if present
184
205
  if (config.removedProjects) {
185
206
  config.removedProjects = config.removedProjects.filter(function (rp) { return rp.path !== absPath; });
@@ -198,7 +219,7 @@ var relay = createServer({
198
219
  }
199
220
  }
200
221
  // Discover and register worktrees for the new project
201
- scanAndRegisterWorktrees(relay, absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null);
222
+ scanAndRegisterWorktrees(relay, absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null, projectEntry.projectKnowledgeId);
202
223
  // Broadcast updated project list to all clients
203
224
  relay.broadcastAll({
204
225
  type: "projects_updated",
@@ -255,10 +276,11 @@ var relay = createServer({
255
276
  }
256
277
  // Register project - creator always becomes owner, default private
257
278
  var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now(), visibility: "private" };
279
+ projectEntry.projectKnowledgeId = projectLogContext.createProjectKnowledgeId();
258
280
  if (wsUser && wsUser.id) {
259
281
  projectEntry.ownerId = wsUser.id;
260
282
  }
261
- relay.addProject(targetDir, slug, null, null, wsUser ? wsUser.id : null);
283
+ relay.addProject(targetDir, slug, null, null, wsUser ? wsUser.id : null, null, projectRuntimeOptions(projectEntry));
262
284
  config.projects.push(projectEntry);
263
285
  saveConfig(config);
264
286
  try { syncClayrc(config.projects); } catch (e) {}
@@ -345,10 +367,11 @@ var relay = createServer({
345
367
  // Register project - creator always becomes owner
346
368
  // Creator always becomes owner, default private
347
369
  var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now(), visibility: "private" };
370
+ projectEntry.projectKnowledgeId = projectLogContext.createProjectKnowledgeId();
348
371
  if (wsUser && wsUser.id) {
349
372
  projectEntry.ownerId = wsUser.id;
350
373
  }
351
- relay.addProject(targetDir, slug);
374
+ relay.addProject(targetDir, slug, null, null, wsUser ? wsUser.id : null, null, projectRuntimeOptions(projectEntry));
352
375
  config.projects.push(projectEntry);
353
376
  saveConfig(config);
354
377
  try { syncClayrc(config.projects); } catch (e) {}
@@ -380,11 +403,14 @@ var relay = createServer({
380
403
  if (config.projects[pi].slug === wtParent) { parentProject = config.projects[pi]; break; }
381
404
  }
382
405
  if (parentProject) {
406
+ var wtPath = path.join(parentProject.path, wtDirName);
407
+ var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath) ? "merged" : "archived";
383
408
  var rmResult = removeWorktree(parentProject.path, wtDirName);
384
409
  if (!rmResult.ok) {
385
410
  console.log("[daemon] Failed to remove worktree:", slug, rmResult.error);
386
411
  return { ok: false, error: rmResult.error };
387
412
  }
413
+ relay.setProjectLogContextState(slug, contextStatus);
388
414
  }
389
415
  relay.removeProject(slug);
390
416
  unregisterWorktreeSlug(wtParent, slug);
@@ -410,6 +436,7 @@ var relay = createServer({
410
436
  title: found.title || null,
411
437
  icon: found.icon || null,
412
438
  userId: userId || null,
439
+ projectKnowledgeId: found.projectKnowledgeId || null,
413
440
  removedAt: Date.now(),
414
441
  });
415
442
  // Cap at 20 entries (oldest first)
@@ -984,11 +1011,16 @@ var relay = createServer({
984
1011
  }
985
1012
  if (!parent) return { ok: false, error: "Parent project not found" };
986
1013
  if (isWorktree(parent.path)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
1014
+ var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD");
987
1015
  var result = createWorktree(parent.path, branchName, dirName, baseBranch);
988
1016
  if (!result.ok) return result;
989
1017
  // Register the new worktree as ephemeral project
990
1018
  var wtSlug = parentSlug + "--" + dirName;
991
- var wtMeta = { parentSlug: parentSlug, branch: branchName, external: false };
1019
+ var context = projectLogContext.resolveWorktreeContext(result.path, parent.projectKnowledgeId, {
1020
+ branch: branchName,
1021
+ baseCommit: baseCommit,
1022
+ });
1023
+ var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context);
992
1024
  relay.addProject(result.path, wtSlug, branchName, parent.icon, parent.ownerId, wtMeta);
993
1025
  registerWorktreeSlug(parentSlug, wtSlug);
994
1026
  console.log("[daemon] Created worktree:", wtSlug, "->", result.path);
@@ -1074,13 +1106,15 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1074
1106
  }
1075
1107
  var slugs = config.projects.map(function (p) { return p.slug; });
1076
1108
  var slug = generateSlug(absPath, slugs);
1077
- relay.addProject(absPath, slug);
1078
- config.projects.push({ path: absPath, slug: slug, addedAt: Date.now(), visibility: "private" });
1109
+ var projectEntry = { path: absPath, slug: slug, addedAt: Date.now(), visibility: "private" };
1110
+ projectEntry.projectKnowledgeId = rememberedProjectKnowledgeId(absPath);
1111
+ relay.addProject(absPath, slug, null, null, null, null, projectRuntimeOptions(projectEntry));
1112
+ config.projects.push(projectEntry);
1079
1113
  saveConfig(config);
1080
1114
  try { syncClayrc(config.projects); } catch (e) {}
1081
1115
  console.log("[daemon] Added project:", slug, "→", absPath);
1082
1116
  // Discover and register worktrees for the new project
1083
- scanAndRegisterWorktrees(relay, absPath, slug, null, null);
1117
+ scanAndRegisterWorktrees(relay, absPath, slug, null, null, projectEntry.projectKnowledgeId);
1084
1118
  relay.broadcastAll({
1085
1119
  type: "projects_updated",
1086
1120
  projects: relay.getProjects(),
@@ -1102,6 +1136,23 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1102
1136
  }
1103
1137
  }
1104
1138
  if (!target) return { ok: false, error: "project not found" };
1139
+ var removedProject = null;
1140
+ for (var ri = 0; ri < config.projects.length; ri++) {
1141
+ if (config.projects[ri].slug === target) { removedProject = config.projects[ri]; break; }
1142
+ }
1143
+ if (removedProject) {
1144
+ if (!config.removedProjects) config.removedProjects = [];
1145
+ config.removedProjects.push({
1146
+ path: removedProject.path,
1147
+ title: removedProject.title || null,
1148
+ icon: removedProject.icon || null,
1149
+ projectKnowledgeId: removedProject.projectKnowledgeId || null,
1150
+ removedAt: Date.now(),
1151
+ });
1152
+ if (config.removedProjects.length > 20) {
1153
+ config.removedProjects = config.removedProjects.slice(config.removedProjects.length - 20);
1154
+ }
1155
+ }
1105
1156
  relay.removeProject(target);
1106
1157
  config.projects = config.projects.filter(function (p) { return p.slug !== target; });
1107
1158
  saveConfig(config);
@@ -1292,9 +1343,9 @@ for (var i = 0; i < projects.length; i++) {
1292
1343
  var p = projects[i];
1293
1344
  if (fs.existsSync(p.path)) {
1294
1345
  console.log("[daemon] Adding project:", p.slug, "→", p.path);
1295
- relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId);
1346
+ relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId, null, projectRuntimeOptions(p));
1296
1347
  // Discover and register worktrees for this project
1297
- scanAndRegisterWorktrees(relay, p.path, p.slug, p.icon, p.ownerId);
1348
+ scanAndRegisterWorktrees(relay, p.path, p.slug, p.icon, p.ownerId, p.projectKnowledgeId);
1298
1349
  } else {
1299
1350
  console.log("[daemon] Skipping missing project:", p.path);
1300
1351
  }
@@ -0,0 +1,171 @@
1
+ // Stable project knowledge and Git worktree change-set identities.
2
+ //
3
+ // Existing projects freeze their current path-derived scope so every opaque
4
+ // log ref remains valid. New projects receive a path-independent id. A linked
5
+ // worktree stores its generated change-set marker in Git's administrative
6
+ // directory, which survives daemon restarts and branch renames but disappears
7
+ // with the worktree itself; the durable log records retain the id afterward.
8
+
9
+ var crypto = require("crypto");
10
+ var fs = require("fs");
11
+ var path = require("path");
12
+ var execFileSync = require("child_process").execFileSync;
13
+ var logsRoot = require("./project-logs-root");
14
+ var utils = require("./utils");
15
+
16
+ var ID_PATTERN = /^[A-Za-z0-9_-]{1,120}$/;
17
+
18
+ function newId(prefix) {
19
+ return prefix + "_" + crypto.randomBytes(16).toString("base64url");
20
+ }
21
+
22
+ function validId(value, prefix) {
23
+ return typeof value === "string" && ID_PATTERN.test(value) && (!prefix || value.indexOf(prefix + "_") === 0);
24
+ }
25
+
26
+ function legacyKnowledgeId(cwd) {
27
+ return utils.encodeCwd(logsRoot.resolveProjectRoot(cwd));
28
+ }
29
+
30
+ function ensureProjectKnowledgeId(project) {
31
+ if (!project || typeof project !== "object") return false;
32
+ if (validId(project.projectKnowledgeId)) return false;
33
+ project.projectKnowledgeId = project.path ? legacyKnowledgeId(project.path) : newId("pk");
34
+ return true;
35
+ }
36
+
37
+ function initializeProjectKnowledge(config) {
38
+ var changed = false;
39
+ var projects = config && Array.isArray(config.projects) ? config.projects : [];
40
+ for (var i = 0; i < projects.length; i++) {
41
+ if (ensureProjectKnowledgeId(projects[i])) changed = true;
42
+ }
43
+ return changed;
44
+ }
45
+
46
+ function createProjectKnowledgeId() {
47
+ return newId("pk");
48
+ }
49
+
50
+ function git(cwd, args) {
51
+ return execFileSync("git", args, {
52
+ cwd: cwd,
53
+ encoding: "utf8",
54
+ timeout: 5000,
55
+ stdio: ["pipe", "pipe", "pipe"],
56
+ }).trim();
57
+ }
58
+
59
+ function commit(cwd, ref) {
60
+ try { return git(cwd, ["rev-parse", ref || "HEAD"]); }
61
+ catch (e) { return null; }
62
+ }
63
+
64
+ function markerPath(cwd) {
65
+ try {
66
+ var gitDir = git(cwd, ["rev-parse", "--git-dir"]);
67
+ return path.join(path.resolve(cwd, gitDir), "clay-change-set.json");
68
+ } catch (e) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function readMarker(filePath, knowledgeId) {
74
+ if (!filePath) return null;
75
+ try {
76
+ var parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
77
+ if (!parsed || parsed.projectKnowledgeId !== knowledgeId || !validId(parsed.changeSetId, "cs")) return null;
78
+ return parsed;
79
+ } catch (e) {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function deterministicFallback(knowledgeId, cwd) {
85
+ var digest = crypto.createHash("sha256").update(knowledgeId + "\u0000" + logsRoot.canonicalPath(cwd)).digest("base64url");
86
+ return "cs_" + digest.substring(0, 22);
87
+ }
88
+
89
+ function resolveWorktreeContext(cwd, knowledgeId, details) {
90
+ var input = details || {};
91
+ var filePath = markerPath(cwd);
92
+ var marker = readMarker(filePath, knowledgeId);
93
+ if (!marker) {
94
+ marker = {
95
+ projectKnowledgeId: knowledgeId,
96
+ changeSetId: newId("cs"),
97
+ baseCommit: input.baseCommit || null,
98
+ createdAt: Date.now(),
99
+ };
100
+ if (filePath) {
101
+ try {
102
+ fs.writeFileSync(filePath, JSON.stringify(marker, null, 2) + "\n", { flag: "wx", mode: 0o600 });
103
+ } catch (e) {
104
+ marker = readMarker(filePath, knowledgeId) || marker;
105
+ }
106
+ }
107
+ }
108
+ if (!filePath || !readMarker(filePath, knowledgeId)) {
109
+ marker.changeSetId = deterministicFallback(knowledgeId, cwd);
110
+ }
111
+ return {
112
+ kind: "worktree",
113
+ changeSetId: marker.changeSetId,
114
+ branch: input.branch || null,
115
+ baseCommit: marker.baseCommit || input.baseCommit || null,
116
+ headCommit: commit(cwd, "HEAD"),
117
+ status: "active",
118
+ };
119
+ }
120
+
121
+ function isMerged(parentCwd, worktreeCwd) {
122
+ var head = commit(worktreeCwd, "HEAD");
123
+ if (!head) return false;
124
+ try {
125
+ execFileSync("git", ["merge-base", "--is-ancestor", head, "HEAD"], {
126
+ cwd: parentCwd,
127
+ timeout: 5000,
128
+ stdio: ["pipe", "pipe", "pipe"],
129
+ });
130
+ return true;
131
+ } catch (e) {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ function cleanCommit(value) {
137
+ return typeof value === "string" && /^[0-9a-f]{7,64}$/i.test(value) ? value : null;
138
+ }
139
+
140
+ function cleanBranch(value) {
141
+ if (typeof value !== "string") return null;
142
+ var cleaned = value.trim().substring(0, 200);
143
+ return cleaned && cleaned.indexOf("\n") === -1 && cleaned.indexOf("\r") === -1 ? cleaned : null;
144
+ }
145
+
146
+ function normalizeRecordContext(value) {
147
+ if (!value || value.kind !== "worktree" || !validId(value.changeSetId, "cs")) {
148
+ return { kind: "project", changeSetId: null, branch: null, baseCommit: null, headCommit: null, status: "active" };
149
+ }
150
+ return {
151
+ kind: "worktree",
152
+ changeSetId: value.changeSetId,
153
+ branch: cleanBranch(value.branch),
154
+ baseCommit: cleanCommit(value.baseCommit),
155
+ headCommit: cleanCommit(value.headCommit),
156
+ status: value.status === "merged" || value.status === "archived" ? value.status : "active",
157
+ };
158
+ }
159
+
160
+ module.exports = {
161
+ ID_PATTERN: ID_PATTERN,
162
+ validId: validId,
163
+ legacyKnowledgeId: legacyKnowledgeId,
164
+ ensureProjectKnowledgeId: ensureProjectKnowledgeId,
165
+ initializeProjectKnowledge: initializeProjectKnowledge,
166
+ createProjectKnowledgeId: createProjectKnowledgeId,
167
+ resolveWorktreeContext: resolveWorktreeContext,
168
+ commit: commit,
169
+ isMerged: isMerged,
170
+ normalizeRecordContext: normalizeRecordContext,
171
+ };
@@ -1,6 +1,6 @@
1
- // Deliver new Project Log comments to the exact live session responsible for
2
- // the canonical entry. Comments remain durable in the ledger when that session
3
- // is gone; this module never guesses another session or creates one.
1
+ // Deliver new Project Log comments to the session responsible for the
2
+ // canonical entry. A busy Driver gets an isolated hidden reviewer so feedback
3
+ // never waits behind unrelated work in the visible conversation.
4
4
 
5
5
  function attachProjectLogFeedbackDelivery(ctx) {
6
6
  var sm = ctx.sm;
@@ -8,8 +8,7 @@ function attachProjectLogFeedbackDelivery(ctx) {
8
8
  var onProcessingChanged = ctx.onProcessingChanged || function () {};
9
9
  var getLinuxUserForSession = ctx.getLinuxUserForSession || function () { return null; };
10
10
 
11
- function findAuthorSession(entry) {
12
- var author = entry && entry.updatedBy;
11
+ function sessionForAuthor(author) {
13
12
  if (!author || author.type !== "session" || !author.sessionKey) return null;
14
13
  var match = null;
15
14
  sm.sessions.forEach(function (session) {
@@ -22,6 +21,48 @@ function attachProjectLogFeedbackDelivery(ctx) {
22
21
  return match;
23
22
  }
24
23
 
24
+ function findAuthorSession(entry) {
25
+ return sessionForAuthor(entry && entry.updatedBy) || sessionForAuthor(entry && entry.createdBy);
26
+ }
27
+
28
+ function startImmediateReviewer(source, entry, text, sdk) {
29
+ if (!sm.createSessionRaw || !sm.deleteSessionQuiet) return false;
30
+ var reviewer = sm.createSessionRaw({
31
+ ownerId: source.ownerId || null,
32
+ sessionVisibility: "private",
33
+ vendor: source.vendor || null,
34
+ model: source.model || null,
35
+ effort: source.effort || null,
36
+ permissionMode: source.permissionMode || null,
37
+ });
38
+ reviewer.hidden = true;
39
+ reviewer.title = "Project Log review";
40
+ reviewer.singleTurn = true;
41
+ reviewer.projectLogReview = { ref: entry.ref };
42
+ reviewer.isProcessing = true;
43
+ reviewer.sentToolResults = {};
44
+ reviewer.onQueryComplete = function () {
45
+ setImmediate(function () { sm.deleteSessionQuiet(reviewer.localId); });
46
+ };
47
+ sm.sendAndRecord(reviewer, {
48
+ type: "user_message",
49
+ text: text,
50
+ _internal: true,
51
+ projectLogFeedback: true,
52
+ projectLogRef: entry.ref,
53
+ });
54
+ Promise.resolve(sdk.startQuery(reviewer, text, null, getLinuxUserForSession(reviewer))).catch(function (error) {
55
+ reviewer.isProcessing = false;
56
+ sm.sendAndRecord(reviewer, {
57
+ type: "error",
58
+ text: "Could not review Project Log feedback: " + (error.message || String(error)),
59
+ _internal: true,
60
+ });
61
+ sm.deleteSessionQuiet(reviewer.localId);
62
+ });
63
+ return true;
64
+ }
65
+
25
66
  function feedbackPrompt(entry) {
26
67
  return "[Project Log feedback]\n" +
27
68
  "A user added a comment to " + entry.ref + ". Review the pending feedback now with list_log_feedback, " +
@@ -37,6 +78,7 @@ function attachProjectLogFeedbackDelivery(ctx) {
37
78
  var sdk = getSdk();
38
79
  if (!session || !sdk) return false;
39
80
  var text = feedbackPrompt(entry);
81
+ if (session.isProcessing && startImmediateReviewer(session, entry, text, sdk)) return true;
40
82
  sm.sendAndRecord(session, {
41
83
  type: "user_message",
42
84
  text: text,
@@ -0,0 +1,42 @@
1
+ // Append-only lifecycle projection for worktree-scoped Project Log entries.
2
+
3
+ var recordStore = require("./knowledge-record-store");
4
+ var logContext = require("./project-log-context");
5
+
6
+ function attachContextState(store, scopeId) {
7
+ function states() {
8
+ var out = {};
9
+ var records = store.all();
10
+ for (var i = 0; i < records.length; i++) {
11
+ var record = records[i];
12
+ if (record.op !== "context-state" || !record.changeSetId) continue;
13
+ if (record.status !== "archived" && record.status !== "merged" && record.status !== "active") continue;
14
+ out[record.changeSetId] = record.status;
15
+ }
16
+ return out;
17
+ }
18
+
19
+ function set(context, status) {
20
+ var normalized = logContext.normalizeRecordContext(context);
21
+ if (!normalized.changeSetId) return false;
22
+ if (status !== "active" && status !== "archived" && status !== "merged") {
23
+ throw new Error("Invalid Project Log change-set status.");
24
+ }
25
+ if (states()[normalized.changeSetId] === status) return false;
26
+ store.append({
27
+ id: recordStore.newRecordId(),
28
+ rootId: "context-" + normalized.changeSetId,
29
+ op: "context-state",
30
+ scope: scopeId,
31
+ changeSetId: normalized.changeSetId,
32
+ status: status,
33
+ context: normalized,
34
+ at: Date.now(),
35
+ });
36
+ return true;
37
+ }
38
+
39
+ return { states: states, set: set };
40
+ }
41
+
42
+ module.exports = { attachContextState: attachContextState };
@@ -78,6 +78,7 @@ var CATEGORY_DESCRIPTION = "Record category: a short lowercase hyphen-separated
78
78
  var PRIORITY_DESCRIPTION = "How much this outranks routine work: " + logsSchema.PRIORITIES.join(", ") + ". Defaults to normal. Priority is independent of category, so an urgent decision is both.";
79
79
  var SUMMARY_DESCRIPTION = "One or two sentences combining the user's requested outcome with the current result or status. This is what a new Driver sees in the ledger, so it must stand alone. For a learning entry, identify the concept the user engaged with plainly enough that the row itself teaches it.";
80
80
  var REF_DESCRIPTION = "Opaque log reference returned by list_logs, search_logs, or create_log.";
81
+ var CONTEXT_DESCRIPTION = "Optional logical scope: current includes project-wide entries plus this worktree's change set, project shows only project-wide entries, and all includes archived and merged worktree changes. Defaults to current in a worktree and project otherwise.";
81
82
 
82
83
  function textResult(value) {
83
84
  return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
@@ -114,6 +115,7 @@ function projectTools(bound) {
114
115
  kind: { type: "string", description: "Optional category filter, matched exactly against this project's vocabulary. The response lists the categories currently in use." },
115
116
  priority: { type: "string", enum: logsSchema.PRIORITIES, description: "Optional priority filter." },
116
117
  tag: { type: "string", description: "Optional single tag filter." },
118
+ contextMode: { type: "string", enum: ["current", "project", "all"], description: CONTEXT_DESCRIPTION },
117
119
  cursor: { type: "string", description: "Opaque pagination cursor from a previous response." },
118
120
  limit: { type: "number", description: "Page size, from 1 to " + logsStore.MAX_PAGE + "." },
119
121
  }),
@@ -126,6 +128,7 @@ function projectTools(bound) {
126
128
  query: { type: "string", description: "Search query." },
127
129
  kind: { type: "string", description: "Optional category filter, matched exactly against this project's vocabulary. The response lists the categories currently in use." },
128
130
  priority: { type: "string", enum: logsSchema.PRIORITIES, description: "Optional priority filter." },
131
+ contextMode: { type: "string", enum: ["current", "project", "all"], description: CONTEXT_DESCRIPTION },
129
132
  cursor: { type: "string", description: "Opaque pagination cursor from a previous response." },
130
133
  limit: { type: "number", description: "Page size, from 1 to " + logsStore.MAX_PAGE + "." },
131
134
  }, ["query"]),
@@ -239,6 +242,7 @@ function globalTools(bound) {
239
242
  kind: { type: "string", description: "Optional category filter, matched exactly against this project's vocabulary. The response lists the categories currently in use." },
240
243
  priority: { type: "string", enum: logsSchema.PRIORITIES, description: "Optional priority filter." },
241
244
  tag: { type: "string", description: "Optional single tag filter." },
245
+ contextMode: { type: "string", enum: ["project", "all"], description: CONTEXT_DESCRIPTION },
242
246
  cursor: { type: "string", description: "Opaque pagination cursor from a previous response." },
243
247
  limit: { type: "number", description: "Page size, from 1 to " + logsStore.MAX_PAGE + "." },
244
248
  }, ["projectSlug"]),
@@ -252,6 +256,7 @@ function globalTools(bound) {
252
256
  query: { type: "string", description: "Search query." },
253
257
  kind: { type: "string", description: "Optional category filter, matched exactly against this project's vocabulary. The response lists the categories currently in use." },
254
258
  priority: { type: "string", enum: logsSchema.PRIORITIES, description: "Optional priority filter." },
259
+ contextMode: { type: "string", enum: ["project", "all"], description: CONTEXT_DESCRIPTION },
255
260
  cursor: { type: "string", description: "Opaque pagination cursor from a previous response." },
256
261
  limit: { type: "number", description: "Page size, from 1 to " + logsStore.MAX_PAGE + "." },
257
262
  }, ["projectSlug", "query"]),
@@ -62,6 +62,19 @@ function page(items, args) {
62
62
  };
63
63
  }
64
64
 
65
+ function contextMode(options) {
66
+ var value = options && options.contextMode;
67
+ return value === "all" || value === "current" || value === "project" ? value : "all";
68
+ }
69
+
70
+ function matchesContext(entry, options) {
71
+ var mode = contextMode(options);
72
+ var changeSetId = entry && entry.context && entry.context.changeSetId;
73
+ if (mode === "all") return true;
74
+ if (mode === "project") return !changeSetId;
75
+ return !changeSetId || changeSetId === options.currentChangeSetId;
76
+ }
77
+
65
78
  function listEntries(entries, args, cleanLine, validateKind) {
66
79
  var options = args || {};
67
80
  var kind = options.kind ? validateKind(options.kind, null) : null;
@@ -73,6 +86,7 @@ function listEntries(entries, args, cleanLine, validateKind) {
73
86
  if (kind && entry.category !== kind) return false;
74
87
  if (priority && entry.priority !== priority) return false;
75
88
  if (tag && entry.tags.indexOf(tag) === -1) return false;
89
+ if (!matchesContext(entry, options)) return false;
76
90
  return true;
77
91
  });
78
92
  var result = page(filtered, options);
@@ -116,6 +130,7 @@ function summarize(entry) {
116
130
  // How many comments still await Driver review. A count only: the ledger
117
131
  // never carries comment bodies.
118
132
  pendingFeedbackCount: entry.pendingFeedbackCount || 0,
133
+ context: entry.context || null,
119
134
  };
120
135
  }
121
136
 
@@ -128,6 +143,7 @@ function searchEntries(entries, args, cleanLine, validateKind) {
128
143
  var candidates = entries.filter(function (entry) {
129
144
  if (kind && entry.category !== kind) return false;
130
145
  if (priority && entry.priority !== priority) return false;
146
+ if (!matchesContext(entry, options)) return false;
131
147
  return true;
132
148
  });
133
149
 
@@ -161,6 +177,8 @@ module.exports = {
161
177
  encodeCursor: encodeCursor,
162
178
  decodeCursor: decodeCursor,
163
179
  page: page,
180
+ contextMode: contextMode,
181
+ matchesContext: matchesContext,
164
182
  summarize: summarize,
165
183
  listEntries: listEntries,
166
184
  searchEntries: searchEntries,
@@ -60,8 +60,16 @@ function scopeIdForRoot(root) {
60
60
  return "project/" + utils.encodeCwd(root);
61
61
  }
62
62
 
63
+ function scopeIdForKnowledgeId(knowledgeId) {
64
+ if (typeof knowledgeId !== "string" || !/^[A-Za-z0-9_-]{1,120}$/.test(knowledgeId)) {
65
+ throw new Error("Invalid project knowledge id.");
66
+ }
67
+ return "project/" + knowledgeId;
68
+ }
69
+
63
70
  module.exports = {
64
71
  resolveProjectRoot: resolveProjectRoot,
65
72
  scopeIdForRoot: scopeIdForRoot,
73
+ scopeIdForKnowledgeId: scopeIdForKnowledgeId,
66
74
  canonicalPath: canonicalPath,
67
75
  };