clay-server 4.0.0-beta.10 → 4.0.0-beta.11
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/config.js +25 -1
- package/lib/git-cli.js +94 -1
- package/lib/knowledge-search.js +17 -1
- package/lib/mate-knowledge-service.js +2 -2
- package/lib/notes-lifecycle.js +149 -0
- package/lib/notes.js +60 -3
- package/lib/project-http.js +15 -1
- package/lib/project-logs-mcp-server.js +24 -0
- package/lib/project-logs.js +1 -0
- package/lib/project-pair-lifecycle.js +461 -0
- package/lib/project-session-notes.js +64 -13
- package/lib/project-session-pair.js +124 -111
- package/lib/project-sessions.js +46 -6
- package/lib/project-user-message.js +41 -4
- package/lib/project-worker-permission.js +429 -0
- package/lib/project-worker-proposal.js +16 -1
- package/lib/project.js +57 -1
- package/lib/public/app.js +28 -17
- package/lib/public/css/git-placard.css +154 -0
- package/lib/public/css/notifications-center.css +67 -0
- package/lib/public/css/pane.css +52 -0
- package/lib/public/css/sticky-notes.css +231 -226
- package/lib/public/index.html +9 -0
- package/lib/public/modules/app-messages.js +8 -1
- package/lib/public/modules/app-notifications.js +11 -7
- package/lib/public/modules/app-projects.js +2 -2
- package/lib/public/modules/git-agent-sessions.js +85 -0
- package/lib/public/modules/git-panel.js +111 -84
- package/lib/public/modules/git-placard.js +161 -0
- package/lib/public/modules/project-logs.js +3 -2
- package/lib/public/modules/sidebar.js +17 -5
- package/lib/public/modules/sticky-notes-browser.js +324 -0
- package/lib/public/modules/sticky-notes-card.js +358 -0
- package/lib/public/modules/sticky-notes-editor.js +288 -0
- package/lib/public/modules/sticky-notes-shared.js +90 -0
- package/lib/public/modules/sticky-notes.js +69 -899
- package/lib/public/modules/tool-palette-order.js +143 -0
- package/lib/public/modules/tool-palette-overlays.js +159 -0
- package/lib/public/modules/tool-palette.js +11 -250
- package/lib/public/modules/update-snooze.js +169 -0
- package/lib/public/modules/worker-pane-lock.js +174 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +77 -10
- package/lib/server.js +6 -6
- package/lib/session-driver-eligibility.js +183 -0
- package/lib/session-notes-mcp-server.js +25 -5
- package/lib/session-pair-factory.js +233 -0
- package/lib/session-pair-mcp-server.js +45 -5
- package/lib/session-pair-prompts.js +60 -0
- package/lib/update-snooze.js +437 -0
- package/lib/workspace-query-access.js +102 -0
- package/lib/workspace-query-service.js +24 -17
- package/lib/ws-schema.js +7 -3
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -148,12 +148,36 @@ function saveConfig(config) {
|
|
|
148
148
|
chmodSafe(configPath(), 0o600);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// Does a process with this pid exist? Existence only — never ownership.
|
|
152
|
+
//
|
|
153
|
+
// `process.kill(pid, 0)` sends no signal; it just asks the kernel. Three
|
|
154
|
+
// answers matter, and conflating them has caused real damage:
|
|
155
|
+
//
|
|
156
|
+
// success -> the process exists and we may signal it
|
|
157
|
+
// EPERM -> the process EXISTS but belongs to another user. This is alive.
|
|
158
|
+
// Reading it as dead is how a daemon started by another OS user
|
|
159
|
+
// (or under OS-user isolation) got its config cleared and its
|
|
160
|
+
// socket unlinked by a second daemon, which then started
|
|
161
|
+
// alongside it.
|
|
162
|
+
// ESRCH -> no such process. This is the only "dead".
|
|
163
|
+
//
|
|
164
|
+
// Any other error is a question we could not answer, so we answer "alive":
|
|
165
|
+
// every caller uses this to gate a destructive or duplicating action
|
|
166
|
+
// (clearStaleConfig unlinks the live socket; the migration lock would be
|
|
167
|
+
// stolen), and doing nothing is always the recoverable choice.
|
|
168
|
+
//
|
|
169
|
+
// The pid is validated first, so a malformed value never reaches the kernel
|
|
170
|
+
// call. That matters beyond hygiene: process.kill accepts 0 and negative pids
|
|
171
|
+
// as process-GROUP selectors and returns success for them, so an unvalidated
|
|
172
|
+
// 0 or -1 in a config file used to report a live daemon that does not exist.
|
|
151
173
|
function isPidAlive(pid) {
|
|
174
|
+
if (typeof pid !== "number" || !isFinite(pid) || Math.floor(pid) !== pid || pid <= 0) return false;
|
|
152
175
|
try {
|
|
153
176
|
process.kill(pid, 0);
|
|
154
177
|
return true;
|
|
155
178
|
} catch (e) {
|
|
156
|
-
return false;
|
|
179
|
+
if (e && e.code === "ESRCH") return false;
|
|
180
|
+
return true;
|
|
157
181
|
}
|
|
158
182
|
}
|
|
159
183
|
|
package/lib/git-cli.js
CHANGED
|
@@ -6,6 +6,7 @@ var { execFile, execFileSync } = require("child_process");
|
|
|
6
6
|
var { wrapSpawnAsUser } = require("./os-users");
|
|
7
7
|
|
|
8
8
|
var MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
var MAX_REMOTE_LABEL = 64;
|
|
9
10
|
|
|
10
11
|
function gitEnvironment(osUserInfo) {
|
|
11
12
|
var overrides = { GIT_TERMINAL_PROMPT: "0" };
|
|
@@ -157,6 +158,24 @@ function normalizeGitPath(cwd, value) {
|
|
|
157
158
|
return path.normalize(path.isAbsolute(value) ? value : path.resolve(cwd, value));
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
// Whether this checkout is a linked worktree. The two directories have to be
|
|
162
|
+
// compared through realpath: git answers --absolute-git-dir already resolved
|
|
163
|
+
// but --git-common-dir relative to cwd, so on any platform where the working
|
|
164
|
+
// directory sits behind a symlink (macOS /var -> /private/var, for one) a
|
|
165
|
+
// plain string compare reports every ordinary repository as a worktree.
|
|
166
|
+
function isLinkedWorktree(gitDir, commonDir) {
|
|
167
|
+
if (!gitDir || !commonDir) return false;
|
|
168
|
+
return realGitPath(gitDir) !== realGitPath(commonDir);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function realGitPath(value) {
|
|
172
|
+
try {
|
|
173
|
+
return fs.realpathSync(value);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
function parseWorktrees(raw) {
|
|
161
180
|
var blocks = String(raw || "").trim().split(/\n\n+/);
|
|
162
181
|
var result = [];
|
|
@@ -219,7 +238,7 @@ function getStatus(cwd, osUserInfo) {
|
|
|
219
238
|
origin: origin,
|
|
220
239
|
gitDir: gitDir,
|
|
221
240
|
commonDir: commonDir,
|
|
222
|
-
isWorktree:
|
|
241
|
+
isWorktree: isLinkedWorktree(gitDir, commonDir),
|
|
223
242
|
mainWorktree: worktrees.length > 0 ? worktrees[0].path : root,
|
|
224
243
|
worktrees: worktrees,
|
|
225
244
|
files: parsed.files,
|
|
@@ -227,6 +246,78 @@ function getStatus(cwd, osUserInfo) {
|
|
|
227
246
|
};
|
|
228
247
|
}
|
|
229
248
|
|
|
249
|
+
// A bounded, credential-free remote label. The raw remote URL can carry an
|
|
250
|
+
// embedded token, so the userinfo segment is dropped before the label is
|
|
251
|
+
// built rather than after.
|
|
252
|
+
function shortRemoteLabel(origin) {
|
|
253
|
+
if (!origin) return null;
|
|
254
|
+
var label = String(origin).trim()
|
|
255
|
+
.replace(/^git@([^:]+):/, "$1/")
|
|
256
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
|
|
257
|
+
.replace(/^[^@/]*@/, "")
|
|
258
|
+
.replace(/\.git$/, "")
|
|
259
|
+
.replace(/\/+$/, "");
|
|
260
|
+
if (label.length > MAX_REMOTE_LABEL) label = label.slice(0, MAX_REMOTE_LABEL - 1) + "…";
|
|
261
|
+
return label || null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function countChangedFiles(files) {
|
|
265
|
+
var counts = { changed: files.length, staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
|
|
266
|
+
for (var i = 0; i < files.length; i++) {
|
|
267
|
+
var file = files[i];
|
|
268
|
+
if (file.conflicted) counts.conflicted++;
|
|
269
|
+
if (file.untracked) counts.untracked++;
|
|
270
|
+
if (file.staged) counts.staged++;
|
|
271
|
+
if (file.unstaged && !file.untracked) counts.unstaged++;
|
|
272
|
+
}
|
|
273
|
+
return counts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Cheap always-visible repository placard data. Deliberately narrower than
|
|
277
|
+
// getStatus: three git invocations instead of six, no per-file payload, no
|
|
278
|
+
// session attribution, and no filesystem path in the result. Identity is
|
|
279
|
+
// derived here so the client never resolves or renders a repository path.
|
|
280
|
+
function getSummary(cwd, osUserInfo) {
|
|
281
|
+
var probe;
|
|
282
|
+
try {
|
|
283
|
+
probe = runGitSync(cwd, [
|
|
284
|
+
"rev-parse", "--is-inside-work-tree", "--show-toplevel",
|
|
285
|
+
"--absolute-git-dir", "--git-common-dir",
|
|
286
|
+
], null, osUserInfo);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
return { isRepository: false };
|
|
289
|
+
}
|
|
290
|
+
var lines = String(probe || "").trim().split("\n");
|
|
291
|
+
if (String(lines[0] || "").trim() !== "true" || !lines[1]) return { isRepository: false };
|
|
292
|
+
|
|
293
|
+
var root = String(lines[1]).trim();
|
|
294
|
+
var gitDir = lines[2] ? normalizeGitPath(cwd, String(lines[2]).trim()) : null;
|
|
295
|
+
var commonDir = lines[3] ? normalizeGitPath(cwd, String(lines[3]).trim()) : null;
|
|
296
|
+
var parsed = parsePorcelainV2(runGitSync(root, ["status", "--porcelain=v2", "--branch", "-z"], null, osUserInfo));
|
|
297
|
+
var origin = null;
|
|
298
|
+
try { origin = runGitSync(cwd, ["remote", "get-url", "origin"], null, osUserInfo).trim() || null; } catch (e) {}
|
|
299
|
+
|
|
300
|
+
var detached = parsed.branch === "(detached)";
|
|
301
|
+
var counts = countChangedFiles(parsed.files);
|
|
302
|
+
return {
|
|
303
|
+
isRepository: true,
|
|
304
|
+
name: path.basename(root),
|
|
305
|
+
branch: detached ? null : parsed.branch,
|
|
306
|
+
detached: detached,
|
|
307
|
+
shortOid: parsed.oid && parsed.oid !== "(initial)" ? String(parsed.oid).slice(0, 8) : null,
|
|
308
|
+
isWorktree: isLinkedWorktree(gitDir, commonDir),
|
|
309
|
+
hasUpstream: !!parsed.upstream,
|
|
310
|
+
ahead: parsed.ahead,
|
|
311
|
+
behind: parsed.behind,
|
|
312
|
+
changed: counts.changed,
|
|
313
|
+
staged: counts.staged,
|
|
314
|
+
unstaged: counts.unstaged,
|
|
315
|
+
untracked: counts.untracked,
|
|
316
|
+
conflicted: counts.conflicted,
|
|
317
|
+
remote: shortRemoteLabel(origin),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
230
321
|
function resolveChangedPaths(status, requestedPaths) {
|
|
231
322
|
if (!Array.isArray(requestedPaths) || requestedPaths.length === 0 || requestedPaths.length > 200) {
|
|
232
323
|
throw new Error("Select at least one changed file");
|
|
@@ -363,8 +454,10 @@ module.exports = {
|
|
|
363
454
|
getFileAtCommit: getFileAtCommit,
|
|
364
455
|
getFileDiff: getFileDiff,
|
|
365
456
|
getStatus: getStatus,
|
|
457
|
+
getSummary: getSummary,
|
|
366
458
|
parsePorcelainV2: parsePorcelainV2,
|
|
367
459
|
parseWorktrees: parseWorktrees,
|
|
368
460
|
readWorkingTreeFile: readWorkingTreeFile,
|
|
369
461
|
runAction: runAction,
|
|
462
|
+
shortRemoteLabel: shortRemoteLabel,
|
|
370
463
|
};
|
package/lib/knowledge-search.js
CHANGED
|
@@ -179,12 +179,27 @@ function rank(items, query, project, maxResults) {
|
|
|
179
179
|
// Stable, deterministic ordering before the caller applies its own
|
|
180
180
|
// recency and reference tie-breaks.
|
|
181
181
|
collapsed.sort(function (a, b) {
|
|
182
|
-
return b.score - a.score ||
|
|
182
|
+
return b.score - a.score || compareIds(a.id, b.id);
|
|
183
183
|
});
|
|
184
184
|
var limit = maxResults || collapsed.length;
|
|
185
185
|
return collapsed.slice(0, limit);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Locale-independent lexical order over UTF-16 code units, for the opaque ids
|
|
189
|
+
// and refs that Knowledge uses as its deterministic tie-break.
|
|
190
|
+
//
|
|
191
|
+
// Those identifiers are base64url, so they contain both "-" (0x2D) and "_"
|
|
192
|
+
// (0x5F). String.prototype.localeCompare applies collation rules that order
|
|
193
|
+
// those two the other way round from code-unit order, and the result depends on
|
|
194
|
+
// the ICU data the runtime happens to carry, so it cannot express a
|
|
195
|
+
// deterministic order that holds across machines. This matches the default
|
|
196
|
+
// Array.prototype.sort ordering exactly.
|
|
197
|
+
function compareIds(a, b) {
|
|
198
|
+
var left = String(a);
|
|
199
|
+
var right = String(b);
|
|
200
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
// A snippet centred on the first query term that appears in the text, so the
|
|
189
204
|
// excerpt shows why the document matched rather than just its opening. Works
|
|
190
205
|
// across the whole record, not only its first segment.
|
|
@@ -219,6 +234,7 @@ module.exports = {
|
|
|
219
234
|
coverage: coverage,
|
|
220
235
|
segmentTexts: segmentTexts,
|
|
221
236
|
buildDocs: buildDocs,
|
|
237
|
+
compareIds: compareIds,
|
|
222
238
|
rank: rank,
|
|
223
239
|
snippet: snippet,
|
|
224
240
|
};
|
|
@@ -250,7 +250,7 @@ function attachMateKnowledgeService(ctx) {
|
|
|
250
250
|
items = items.filter(function (item) { return item.kind === kind; });
|
|
251
251
|
}
|
|
252
252
|
items.sort(function (a, b) {
|
|
253
|
-
return b.updatedAt - a.updatedAt || a.ref
|
|
253
|
+
return b.updatedAt - a.updatedAt || knowledgeSearch.compareIds(a.ref, b.ref);
|
|
254
254
|
});
|
|
255
255
|
var result = page(items, options);
|
|
256
256
|
return {
|
|
@@ -282,7 +282,7 @@ function attachMateKnowledgeService(ctx) {
|
|
|
282
282
|
hits.push(hit);
|
|
283
283
|
}
|
|
284
284
|
hits.sort(function (a, b) {
|
|
285
|
-
return b.score - a.score || b.updatedAt - a.updatedAt || a.ref
|
|
285
|
+
return b.score - a.score || b.updatedAt - a.updatedAt || knowledgeSearch.compareIds(a.ref, b.ref);
|
|
286
286
|
});
|
|
287
287
|
var result = page(hits, options);
|
|
288
288
|
return {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Sticky Note lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// A note is an attention item, so "done" must never mean "gone". Completing a
|
|
4
|
+
// note closes it: the record stays, stays queryable, and stays reversible. Only
|
|
5
|
+
// its place on the active canvas changes.
|
|
6
|
+
//
|
|
7
|
+
// Legacy notes carry no state field. They project as open, with one exception:
|
|
8
|
+
// the older `hidden: true` flag already meant "taken off the canvas and hidden
|
|
9
|
+
// from agents", which is exactly what closed means, so those project as closed.
|
|
10
|
+
// The original `hidden` value is preserved rather than rewritten, and no
|
|
11
|
+
// closedAt is invented for a note that never recorded one.
|
|
12
|
+
//
|
|
13
|
+
// Projection is applied when notes are loaded, so nothing on disk is rewritten
|
|
14
|
+
// just by reading. The normalized fields reach the file the next time that note
|
|
15
|
+
// is written for some other reason.
|
|
16
|
+
|
|
17
|
+
var STATE_OPEN = "open";
|
|
18
|
+
var STATE_CLOSED = "closed";
|
|
19
|
+
var STATES = [STATE_OPEN, STATE_CLOSED];
|
|
20
|
+
|
|
21
|
+
function isState(value) {
|
|
22
|
+
return STATES.indexOf(value) !== -1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The state a note is in, derived rather than trusted: an unknown or missing
|
|
26
|
+
// value falls back to the legacy flag and then to open.
|
|
27
|
+
function stateOf(note) {
|
|
28
|
+
if (!note) return STATE_OPEN;
|
|
29
|
+
if (isState(note.state)) return note.state;
|
|
30
|
+
return note.hidden === true ? STATE_CLOSED : STATE_OPEN;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isOpen(note) {
|
|
34
|
+
return stateOf(note) === STATE_OPEN;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isClosed(note) {
|
|
38
|
+
return stateOf(note) === STATE_CLOSED;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Normalize one note in place. Returns true when anything changed, so a caller
|
|
42
|
+
// can decide whether the migration is worth persisting.
|
|
43
|
+
function normalize(note) {
|
|
44
|
+
if (!note || typeof note !== "object") return false;
|
|
45
|
+
var changed = false;
|
|
46
|
+
var state = stateOf(note);
|
|
47
|
+
if (note.state !== state) {
|
|
48
|
+
note.state = state;
|
|
49
|
+
changed = true;
|
|
50
|
+
}
|
|
51
|
+
if (note.closedAt === undefined) {
|
|
52
|
+
// A legacy hidden note has no recorded close time and none is invented.
|
|
53
|
+
note.closedAt = null;
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
if (note.closedBy === undefined) {
|
|
57
|
+
note.closedBy = null;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
// Keep the legacy flag consistent with the state so an old client that only
|
|
61
|
+
// understands `hidden` still takes a closed note off its canvas.
|
|
62
|
+
var hidden = state === STATE_CLOSED;
|
|
63
|
+
if (note.hidden !== hidden) {
|
|
64
|
+
note.hidden = hidden;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
return changed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeAll(notes) {
|
|
71
|
+
var list = Array.isArray(notes) ? notes : [];
|
|
72
|
+
var changed = false;
|
|
73
|
+
for (var i = 0; i < list.length; i++) {
|
|
74
|
+
if (normalize(list[i])) changed = true;
|
|
75
|
+
}
|
|
76
|
+
return { notes: list, changed: changed };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function openNotes(notes) {
|
|
80
|
+
return (notes || []).filter(function (note) { return note && isOpen(note); });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function closedNotes(notes) {
|
|
84
|
+
return (notes || []).filter(function (note) { return note && isClosed(note); });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Actors are always built from server-bound context. Nothing here reads a
|
|
88
|
+
// caller-supplied identity, so a payload cannot claim to be someone else.
|
|
89
|
+
function sessionActor(session) {
|
|
90
|
+
if (!session) return null;
|
|
91
|
+
return {
|
|
92
|
+
type: "session",
|
|
93
|
+
sessionId: session.localId !== undefined && session.localId !== null ? session.localId : null,
|
|
94
|
+
vendor: session.vendor || null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function userActor(user) {
|
|
99
|
+
return {
|
|
100
|
+
type: "user",
|
|
101
|
+
userId: (user && user.id) || null,
|
|
102
|
+
displayName: (user && (user.displayName || user.username)) || null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Apply the close transition. Idempotent: closing an already-closed note keeps
|
|
107
|
+
// the original closedAt and actor rather than restamping it, so the record of
|
|
108
|
+
// when it was actually completed survives repeated calls.
|
|
109
|
+
function applyClose(note, actor, now) {
|
|
110
|
+
if (!note) return false;
|
|
111
|
+
normalize(note);
|
|
112
|
+
if (isClosed(note)) return false;
|
|
113
|
+
note.state = STATE_CLOSED;
|
|
114
|
+
note.hidden = true;
|
|
115
|
+
note.closedAt = typeof now === "number" ? now : Date.now();
|
|
116
|
+
note.closedBy = actor || null;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Apply the reopen transition. Idempotent in the same way, and it clears the
|
|
121
|
+
// close provenance because the note is live again.
|
|
122
|
+
function applyReopen(note) {
|
|
123
|
+
if (!note) return false;
|
|
124
|
+
normalize(note);
|
|
125
|
+
if (isOpen(note)) return false;
|
|
126
|
+
note.state = STATE_OPEN;
|
|
127
|
+
note.hidden = false;
|
|
128
|
+
note.closedAt = null;
|
|
129
|
+
note.closedBy = null;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
STATE_OPEN: STATE_OPEN,
|
|
135
|
+
STATE_CLOSED: STATE_CLOSED,
|
|
136
|
+
STATES: STATES,
|
|
137
|
+
isState: isState,
|
|
138
|
+
stateOf: stateOf,
|
|
139
|
+
isOpen: isOpen,
|
|
140
|
+
isClosed: isClosed,
|
|
141
|
+
normalize: normalize,
|
|
142
|
+
normalizeAll: normalizeAll,
|
|
143
|
+
openNotes: openNotes,
|
|
144
|
+
closedNotes: closedNotes,
|
|
145
|
+
sessionActor: sessionActor,
|
|
146
|
+
userActor: userActor,
|
|
147
|
+
applyClose: applyClose,
|
|
148
|
+
applyReopen: applyReopen,
|
|
149
|
+
};
|
package/lib/notes.js
CHANGED
|
@@ -3,6 +3,7 @@ var path = require("path");
|
|
|
3
3
|
var crypto = require("crypto");
|
|
4
4
|
var config = require("./config");
|
|
5
5
|
var utils = require("./utils");
|
|
6
|
+
var lifecycle = require("./notes-lifecycle");
|
|
6
7
|
|
|
7
8
|
function createNotesManager(opts) {
|
|
8
9
|
var cwd = opts.cwd;
|
|
@@ -19,11 +20,14 @@ function createNotesManager(opts) {
|
|
|
19
20
|
return "n_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// Legacy files are normalized on load, never on read. Nothing is rewritten
|
|
24
|
+
// just because a note was looked at; the projected fields reach disk the next
|
|
25
|
+
// time some other write happens.
|
|
22
26
|
function loadFromDisk() {
|
|
23
27
|
try {
|
|
24
28
|
var data = fs.readFileSync(notesFile, "utf8");
|
|
25
29
|
var parsed = JSON.parse(data);
|
|
26
|
-
return parsed.notes || [];
|
|
30
|
+
return lifecycle.normalizeAll(parsed.notes || []).notes;
|
|
27
31
|
} catch (e) {
|
|
28
32
|
return [];
|
|
29
33
|
}
|
|
@@ -56,6 +60,10 @@ function createNotesManager(opts) {
|
|
|
56
60
|
color: data.color || "purple",
|
|
57
61
|
opacity: typeof data.opacity === "number" ? data.opacity : 0.64,
|
|
58
62
|
minimized: false,
|
|
63
|
+
// A new note is always an open attention item.
|
|
64
|
+
state: lifecycle.STATE_OPEN,
|
|
65
|
+
closedAt: null,
|
|
66
|
+
closedBy: null,
|
|
59
67
|
zIndex: notes.length + 1,
|
|
60
68
|
createdAt: now,
|
|
61
69
|
updatedAt: now,
|
|
@@ -74,13 +82,20 @@ function createNotesManager(opts) {
|
|
|
74
82
|
function update(id, changes) {
|
|
75
83
|
for (var i = 0; i < notes.length; i++) {
|
|
76
84
|
if (notes[i].id === id) {
|
|
77
|
-
var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "
|
|
85
|
+
var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "zIndex", "opacity"];
|
|
78
86
|
for (var j = 0; j < allowed.length; j++) {
|
|
79
87
|
var key = allowed[j];
|
|
80
88
|
if (changes[key] !== undefined) {
|
|
81
89
|
notes[i][key] = changes[key];
|
|
82
90
|
}
|
|
83
91
|
}
|
|
92
|
+
// `hidden` is the legacy spelling of the lifecycle. Route it through the
|
|
93
|
+
// transitions so state, hidden, and the close provenance can never
|
|
94
|
+
// disagree, and so an old client cannot half-apply a close.
|
|
95
|
+
if (changes.hidden !== undefined) {
|
|
96
|
+
if (changes.hidden === true) lifecycle.applyClose(notes[i], changes.actor || null, Date.now());
|
|
97
|
+
else lifecycle.applyReopen(notes[i]);
|
|
98
|
+
}
|
|
84
99
|
notes[i].updatedAt = Date.now();
|
|
85
100
|
saveToDisk();
|
|
86
101
|
return notes[i];
|
|
@@ -89,6 +104,44 @@ function createNotesManager(opts) {
|
|
|
89
104
|
return null;
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
// Close a note. This is what completion means: the record stays and only its
|
|
108
|
+
// place on the active canvas changes. Idempotent.
|
|
109
|
+
function close(id, actor) {
|
|
110
|
+
for (var i = 0; i < notes.length; i++) {
|
|
111
|
+
if (notes[i].id !== id) continue;
|
|
112
|
+
if (lifecycle.applyClose(notes[i], actor || null, Date.now())) {
|
|
113
|
+
notes[i].updatedAt = Date.now();
|
|
114
|
+
saveToDisk();
|
|
115
|
+
}
|
|
116
|
+
return notes[i];
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Reopen a closed note. Idempotent.
|
|
122
|
+
function reopen(id) {
|
|
123
|
+
for (var i = 0; i < notes.length; i++) {
|
|
124
|
+
if (notes[i].id !== id) continue;
|
|
125
|
+
if (lifecycle.applyReopen(notes[i])) {
|
|
126
|
+
notes[i].updatedAt = Date.now();
|
|
127
|
+
saveToDisk();
|
|
128
|
+
}
|
|
129
|
+
return notes[i];
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function openList() {
|
|
135
|
+
return lifecycle.openNotes(notes);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function closedList() {
|
|
139
|
+
return lifecycle.closedNotes(notes);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Permanent deletion. Deliberately not reachable from any WebSocket message,
|
|
143
|
+
// MCP tool, or UI control: completion closes, it never erases. Retained only
|
|
144
|
+
// for maintenance callers that must truly discard a record.
|
|
92
145
|
function remove(id) {
|
|
93
146
|
for (var i = 0; i < notes.length; i++) {
|
|
94
147
|
if (notes[i].id === id) {
|
|
@@ -123,7 +176,7 @@ function createNotesManager(opts) {
|
|
|
123
176
|
function getActiveNotesText() {
|
|
124
177
|
var active = [];
|
|
125
178
|
for (var i = 0; i < notes.length; i++) {
|
|
126
|
-
if (
|
|
179
|
+
if (lifecycle.isOpen(notes[i]) && notes[i].text) active.push(notes[i]);
|
|
127
180
|
}
|
|
128
181
|
if (active.length === 0) return "";
|
|
129
182
|
var lines = [];
|
|
@@ -137,8 +190,12 @@ function createNotesManager(opts) {
|
|
|
137
190
|
|
|
138
191
|
return {
|
|
139
192
|
list: list,
|
|
193
|
+
openList: openList,
|
|
194
|
+
closedList: closedList,
|
|
140
195
|
create: create,
|
|
141
196
|
update: update,
|
|
197
|
+
close: close,
|
|
198
|
+
reopen: reopen,
|
|
142
199
|
remove: remove,
|
|
143
200
|
bringToFront: bringToFront,
|
|
144
201
|
getActiveNotesText: getActiveNotesText,
|
package/lib/project-http.js
CHANGED
|
@@ -730,7 +730,7 @@ function attachHTTP(ctx) {
|
|
|
730
730
|
}
|
|
731
731
|
|
|
732
732
|
// Git panel status and actions
|
|
733
|
-
var isGitPanelRequest = urlPath === "/api/git/status" ||
|
|
733
|
+
var isGitPanelRequest = urlPath === "/api/git/status" || urlPath === "/api/git/summary" ||
|
|
734
734
|
urlPath === "/api/git/action" || urlPath.indexOf("/api/git/file-diff?") === 0 ||
|
|
735
735
|
urlPath.indexOf("/api/git/session-diff?") === 0;
|
|
736
736
|
if (isGitPanelRequest && usersModule.isMultiUser()) {
|
|
@@ -768,6 +768,20 @@ function attachHTTP(ctx) {
|
|
|
768
768
|
return true;
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
+
// Compact repository placard data for the always-visible sidebar block.
|
|
772
|
+
// Same permission gate as the panel, but no file payload and no path.
|
|
773
|
+
if (req.method === "GET" && urlPath === "/api/git/summary") {
|
|
774
|
+
try {
|
|
775
|
+
var gitSummary = gitCli.getSummary(cwd, getOsUserInfoForReq(req));
|
|
776
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
777
|
+
res.end(JSON.stringify(gitSummary));
|
|
778
|
+
} catch (e) {
|
|
779
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
780
|
+
res.end(JSON.stringify({ error: e.message || "Unable to read Git summary" }));
|
|
781
|
+
}
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
|
|
771
785
|
if (req.method === "GET" && urlPath.indexOf("/api/git/file-diff?") === 0) {
|
|
772
786
|
var gitDiffParams = new URLSearchParams(urlPath.slice(urlPath.indexOf("?")));
|
|
773
787
|
var gitDiffPath = gitDiffParams.get("path");
|
|
@@ -38,6 +38,29 @@ var LEARNING_CONTRACT =
|
|
|
38
38
|
"Do not log routine command syntax, trivial confirmations, facts the person clearly already knows, or every explanation you happen to give. Capture when a conceptual model becomes measurably more precise. " +
|
|
39
39
|
"When new learning refines or supersedes an existing learning entry, revise that entry instead of adding a near-duplicate.";
|
|
40
40
|
|
|
41
|
+
// Sticky Notes and Project Logs are different layers, and the failure mode is
|
|
42
|
+
// treating them as one. A note is an alert that should leave the active board
|
|
43
|
+
// once the thing it is shouting about is handled; a log entry is the permanent
|
|
44
|
+
// record of what happened. Conflating them either fills the board with resolved
|
|
45
|
+
// history or loses the history when the board is cleared.
|
|
46
|
+
//
|
|
47
|
+
// This is guidance for the Driver, not an automatic mirror: notes are also
|
|
48
|
+
// written by people and by Mates that hold no Logs authority, so nothing here
|
|
49
|
+
// makes creating a note mutate the ledger on its own.
|
|
50
|
+
var ATTENTION_CONTRACT =
|
|
51
|
+
"Sticky Notes and Project Logs are two different layers and must not be confused. " +
|
|
52
|
+
"A Sticky Note is the transient attention layer: an unresolved, actionable commitment or defect that stays on the active board only while it still needs action, and is closed once it no longer does. Closing is reversible and never deletes the note. " +
|
|
53
|
+
"A Project Log is the durable project-scoped ledger: discovery, evidence, impact, decision, remediation, and outcome stay permanently, versioned, long after the note has been closed. " +
|
|
54
|
+
"When you discover a concrete defect in this project that will remain unresolved past the current work, and you therefore write a Sticky Note about it, also record it in the ledger. The note is the alert; the log entry is the record. " +
|
|
55
|
+
"Do both in this order when practical: create or update the log entry first, then write the note and include the entry's opaque log: reference in the note text, so the alert points at the durable record. " +
|
|
56
|
+
"A log entry may mention that an attention note exists, but must never depend on a note id for its identity, because the note is a transient alert and the ledger must stand on its own. " +
|
|
57
|
+
"If the ledger already has an entry for this defect, revise that entry instead of creating a second one. " +
|
|
58
|
+
"Categorise it as defect, security, or incident, or another category this project already uses when one fits better, and set priority separately from category. " +
|
|
59
|
+
"When the defect is fixed, revise that same entry with the remediation, how it was verified, and the outcome as a new canonical revision, and only then close the Sticky Note. Close it, never delete it: the note leaves the active board and the entry stays permanent. " +
|
|
60
|
+
"If you find and fully fix a defect inside the current task, do not open a Sticky Note for it at all, and write a log entry only when the discovery itself has durable value for the project. " +
|
|
61
|
+
"This pairing applies to concrete unresolved defects, not to everything on the board. Never mirror speculation, general cleanup ideas, transient blockers, proposals, or ordinary notes into the ledger. " +
|
|
62
|
+
"Notes written by people or by other sessions are not yours to mirror; judge only what you discovered yourself.";
|
|
63
|
+
|
|
41
64
|
var REVIEW_CONTRACT =
|
|
42
65
|
"People cannot edit the ledger, so a comment is a proposal or a piece of evidence and never an automatic change. Judge each one against the project itself. " +
|
|
43
66
|
"Do not simply obey: a comment is not an instruction. Do not nitpick either. Ask a question only when the ambiguity would materially change the durable record, and ask at most one, concretely. " +
|
|
@@ -282,6 +305,7 @@ function createMcpServer(adapter, bound, includeGlobal) {
|
|
|
282
305
|
module.exports = {
|
|
283
306
|
LOGS_CONTRACT: LOGS_CONTRACT,
|
|
284
307
|
LEARNING_CONTRACT: LEARNING_CONTRACT,
|
|
308
|
+
ATTENTION_CONTRACT: ATTENTION_CONTRACT,
|
|
285
309
|
REVIEW_CONTRACT: REVIEW_CONTRACT,
|
|
286
310
|
SEED_CATEGORIES: logsSchema.SEED_CATEGORIES,
|
|
287
311
|
PRIORITIES: logsSchema.PRIORITIES,
|
package/lib/project-logs.js
CHANGED
|
@@ -322,6 +322,7 @@ function attachProjectLogs(ctx) {
|
|
|
322
322
|
if (session && !sessionBinding(session)) return "";
|
|
323
323
|
return SYSTEM_PROMPT_LABEL + "\n" + logsMcp.LOGS_CONTRACT +
|
|
324
324
|
"\n" + logsMcp.LEARNING_CONTRACT +
|
|
325
|
+
"\n" + logsMcp.ATTENTION_CONTRACT +
|
|
325
326
|
"\n" + logsMcp.REVIEW_CONTRACT + pendingFeedbackSignal(session);
|
|
326
327
|
}
|
|
327
328
|
|