clay-server 4.0.0-beta.10 → 4.0.0-beta.12

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.
Files changed (55) hide show
  1. package/lib/config.js +25 -1
  2. package/lib/git-cli.js +94 -1
  3. package/lib/knowledge-search.js +17 -1
  4. package/lib/mate-knowledge-service.js +2 -2
  5. package/lib/notes-lifecycle.js +149 -0
  6. package/lib/notes.js +60 -3
  7. package/lib/project-http.js +15 -1
  8. package/lib/project-logs-mcp-server.js +24 -0
  9. package/lib/project-logs.js +1 -0
  10. package/lib/project-pair-lifecycle.js +461 -0
  11. package/lib/project-session-notes.js +64 -13
  12. package/lib/project-session-pair.js +124 -111
  13. package/lib/project-sessions.js +46 -6
  14. package/lib/project-user-message.js +41 -4
  15. package/lib/project-worker-permission.js +429 -0
  16. package/lib/project-worker-proposal.js +16 -1
  17. package/lib/project.js +57 -1
  18. package/lib/public/app.js +28 -17
  19. package/lib/public/css/git-placard.css +154 -0
  20. package/lib/public/css/notifications-center.css +67 -0
  21. package/lib/public/css/pane.css +52 -0
  22. package/lib/public/css/sticky-notes.css +231 -226
  23. package/lib/public/index.html +9 -0
  24. package/lib/public/modules/app-messages.js +8 -1
  25. package/lib/public/modules/app-notifications.js +11 -7
  26. package/lib/public/modules/app-projects.js +2 -2
  27. package/lib/public/modules/git-agent-sessions.js +85 -0
  28. package/lib/public/modules/git-panel.js +111 -84
  29. package/lib/public/modules/git-placard.js +161 -0
  30. package/lib/public/modules/project-logs.js +3 -2
  31. package/lib/public/modules/sidebar.js +17 -5
  32. package/lib/public/modules/sticky-notes-browser.js +324 -0
  33. package/lib/public/modules/sticky-notes-card.js +358 -0
  34. package/lib/public/modules/sticky-notes-editor.js +288 -0
  35. package/lib/public/modules/sticky-notes-shared.js +90 -0
  36. package/lib/public/modules/sticky-notes.js +69 -899
  37. package/lib/public/modules/tool-palette-order.js +143 -0
  38. package/lib/public/modules/tool-palette-overlays.js +159 -0
  39. package/lib/public/modules/tool-palette.js +11 -250
  40. package/lib/public/modules/tools.js +21 -1
  41. package/lib/public/modules/update-snooze.js +169 -0
  42. package/lib/public/modules/worker-pane-lock.js +174 -0
  43. package/lib/public/style.css +1 -0
  44. package/lib/sdk-bridge.js +77 -10
  45. package/lib/server.js +6 -6
  46. package/lib/session-driver-eligibility.js +183 -0
  47. package/lib/session-notes-mcp-server.js +25 -5
  48. package/lib/session-pair-factory.js +244 -0
  49. package/lib/session-pair-mcp-server.js +45 -5
  50. package/lib/session-pair-prompts.js +60 -0
  51. package/lib/update-snooze.js +437 -0
  52. package/lib/workspace-query-access.js +102 -0
  53. package/lib/workspace-query-service.js +24 -17
  54. package/lib/ws-schema.js +7 -3
  55. 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: !!(gitDir && commonDir && gitDir !== commonDir),
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
  };
@@ -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 || String(a.id).localeCompare(String(b.id));
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.localeCompare(b.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.localeCompare(b.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", "hidden", "zIndex", "opacity"];
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 (!notes[i].hidden && notes[i].text) active.push(notes[i]);
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,
@@ -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,
@@ -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