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.
- package/lib/daemon-projects.js +10 -5
- package/lib/daemon.js +61 -10
- package/lib/project-log-context.js +171 -0
- package/lib/project-log-feedback-delivery.js +47 -5
- package/lib/project-logs-context-state.js +42 -0
- package/lib/project-logs-mcp-server.js +5 -0
- package/lib/project-logs-query.js +18 -0
- package/lib/project-logs-root.js +8 -0
- package/lib/project-logs-service.js +92 -15
- package/lib/project-logs-snapshot.js +3 -0
- package/lib/project-logs-store.js +29 -32
- package/lib/project-logs-versioning.js +6 -2
- package/lib/project-logs.js +56 -12
- package/lib/project-message-delivery.js +70 -0
- package/lib/project-user-message.js +11 -0
- package/lib/project.js +5 -0
- package/lib/public/app.js +5 -0
- package/lib/public/css/project-logs.css +20 -2
- package/lib/public/modules/app-connection.js +22 -3
- package/lib/public/modules/app-messages.js +29 -1
- package/lib/public/modules/app-rendering.js +1 -0
- package/lib/public/modules/input.js +6 -1
- package/lib/public/modules/message-delivery.js +97 -0
- package/lib/public/modules/project-logs-render.js +48 -2
- package/lib/public/modules/project-logs.js +133 -11
- package/lib/server.js +6 -0
- package/lib/ws-schema.js +6 -2
- package/package.json +1 -1
package/lib/daemon-projects.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
1078
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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,
|
package/lib/project-logs-root.js
CHANGED
|
@@ -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
|
};
|