clearctx 3.1.0 → 3.2.0

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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * notebook-store.js
3
+ * Cognition Layer: Shared knowledge scratchpad for worker collaboration.
4
+ *
5
+ * Provides a team wiki where any worker can write and read free-form markdown
6
+ * notes organized by category. Unlike artifacts (structured, versioned, immutable),
7
+ * notebook entries are informal, collaborative, and appendable — a worker can
8
+ * start a note and another worker can append to it.
9
+ *
10
+ * Storage structure:
11
+ * team/{teamName}/notebook/
12
+ * notebook-index.json - registry of all notes
13
+ * entries/{noteId}.jsonl - append-only entry log per note
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
21
+ const { acquireLock, releaseLock } = require('./file-lock');
22
+
23
+ /**
24
+ * Valid note categories.
25
+ * Exported so mcp-server.js can validate tool inputs.
26
+ */
27
+ const NOTEBOOK_CATEGORIES = [
28
+ 'research',
29
+ 'decision',
30
+ 'gotcha',
31
+ 'pattern',
32
+ 'plan',
33
+ 'investigation',
34
+ 'reference'
35
+ ];
36
+
37
+ /**
38
+ * Validate a note ID.
39
+ * Must be lowercase, alphanumeric + hyphens only, max 80 chars.
40
+ * @param {string} noteId - Note ID to validate
41
+ * @throws {Error} If noteId is invalid
42
+ * @private
43
+ */
44
+ function _validateNoteId(noteId) {
45
+ if (!noteId || typeof noteId !== 'string') {
46
+ throw new Error('Note ID is required and must be a string');
47
+ }
48
+
49
+ if (noteId.length > 80) {
50
+ throw new Error(`Note ID '${noteId}' exceeds 80 character limit`);
51
+ }
52
+
53
+ if (!/^[a-z0-9-]+$/.test(noteId)) {
54
+ throw new Error(`Note ID '${noteId}' must be lowercase alphanumeric with hyphens only`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * NotebookStore manages a shared knowledge scratchpad for worker collaboration.
60
+ *
61
+ * Directory structure:
62
+ * team/{teamName}/notebook/
63
+ * notebook-index.json - note registry (locked for writes)
64
+ * entries/{noteId}.jsonl - append-only entry log per note
65
+ */
66
+ class NotebookStore {
67
+ /**
68
+ * Create a new NotebookStore instance
69
+ * @param {string} teamName - Name of the team (default: 'default')
70
+ */
71
+ constructor(teamName = 'default') {
72
+ const baseDir = path.join(os.homedir(), '.clearctx');
73
+ this.teamDir = path.join(baseDir, 'team', teamName);
74
+ this.notebookDir = path.join(this.teamDir, 'notebook');
75
+ this.entriesDir = path.join(this.notebookDir, 'entries');
76
+ this.indexPath = path.join(this.notebookDir, 'notebook-index.json');
77
+ this.locksDir = path.join(this.teamDir, 'locks');
78
+
79
+ this._ensureDirectories();
80
+ }
81
+
82
+ /**
83
+ * Ensure all required directories exist
84
+ * @private
85
+ */
86
+ _ensureDirectories() {
87
+ [this.notebookDir, this.entriesDir, this.locksDir].forEach(dir => {
88
+ if (!fs.existsSync(dir)) {
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ }
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Get the JSONL file path for a note
96
+ * @param {string} noteId - The note ID
97
+ * @returns {string} Absolute path to the note's JSONL file
98
+ * @private
99
+ */
100
+ _entryPath(noteId) {
101
+ return path.join(this.entriesDir, `${noteId}.jsonl`);
102
+ }
103
+
104
+ /**
105
+ * Read and parse all JSONL entries for a note
106
+ * @param {string} noteId - The note ID
107
+ * @returns {Array} Parsed entry objects
108
+ * @private
109
+ */
110
+ _readEntries(noteId) {
111
+ const jsonlPath = this._entryPath(noteId);
112
+ let entries = [];
113
+
114
+ try {
115
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
116
+ const lines = content.split('\n').filter(line => line.trim());
117
+
118
+ for (const line of lines) {
119
+ try {
120
+ entries.push(JSON.parse(line));
121
+ } catch (parseErr) {
122
+ // Skip corrupted lines
123
+ }
124
+ }
125
+ } catch (err) {
126
+ // File doesn't exist or can't be read — return empty
127
+ entries = [];
128
+ }
129
+
130
+ return entries;
131
+ }
132
+
133
+ /**
134
+ * Write a note or append to an existing one
135
+ * @param {string} noteId - Unique note ID (lowercase, alphanumeric + hyphens, max 80 chars)
136
+ * @param {Object} options - Write options
137
+ * @param {string} options.author - Session name writing the note
138
+ * @param {string} options.category - Note category (must be in NOTEBOOK_CATEGORIES)
139
+ * @param {string} options.title - Human-readable title
140
+ * @param {string} options.content - Markdown content
141
+ * @param {string[]} [options.tags=[]] - Tags for categorization
142
+ * @returns {Object} { noteId, entryId, appended: boolean }
143
+ */
144
+ write(noteId, { author, category, title, content, tags = [] }) {
145
+ // Step 1: Validate inputs
146
+ _validateNoteId(noteId);
147
+
148
+ if (!author || typeof author !== 'string') {
149
+ throw new Error('Note author is required');
150
+ }
151
+
152
+ if (!NOTEBOOK_CATEGORIES.includes(category)) {
153
+ throw new Error(`Invalid category '${category}'. Must be one of: ${NOTEBOOK_CATEGORIES.join(', ')}`);
154
+ }
155
+
156
+ if (!title || typeof title !== 'string') {
157
+ throw new Error('Note title is required');
158
+ }
159
+
160
+ if (!content || typeof content !== 'string') {
161
+ throw new Error('Note content is required');
162
+ }
163
+
164
+ // Step 2: Build entry object
165
+ const entryId = `entry_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
166
+ const entry = {
167
+ entryId,
168
+ author,
169
+ content,
170
+ timestamp: new Date().toISOString()
171
+ };
172
+
173
+ // Step 3: Acquire lock on the notebook index
174
+ acquireLock(this.locksDir, 'notebook-index');
175
+
176
+ try {
177
+ // Step 4: Read the current index
178
+ const index = readJsonSafe(this.indexPath, {});
179
+ let appended = false;
180
+
181
+ if (index[noteId]) {
182
+ // Existing note — collaborative append
183
+ appended = true;
184
+ index[noteId].updatedAt = new Date().toISOString();
185
+ index[noteId].entryCount += 1;
186
+
187
+ // Merge new tags (no duplicates)
188
+ if (tags.length > 0) {
189
+ const existingTags = index[noteId].tags || [];
190
+ const merged = [...new Set([...existingTags, ...tags])];
191
+ index[noteId].tags = merged;
192
+ }
193
+ } else {
194
+ // New note — create index entry
195
+ appended = false;
196
+ index[noteId] = {
197
+ noteId,
198
+ category,
199
+ title,
200
+ creator: author,
201
+ createdAt: new Date().toISOString(),
202
+ updatedAt: new Date().toISOString(),
203
+ tags,
204
+ entryCount: 1
205
+ };
206
+ }
207
+
208
+ // Step 5: Save the index atomically
209
+ atomicWriteJson(this.indexPath, index);
210
+
211
+ // Step 6: Release index lock before JSONL append
212
+ releaseLock(this.locksDir, 'notebook-index');
213
+
214
+ // Step 7: Acquire per-note lock for JSONL append
215
+ acquireLock(this.locksDir, `notebook-${noteId}`);
216
+
217
+ try {
218
+ // Step 8: Append JSONL line
219
+ const jsonlPath = this._entryPath(noteId);
220
+ fs.appendFileSync(jsonlPath, JSON.stringify(entry) + '\n', 'utf-8');
221
+
222
+ // Step 9: Release the per-note lock
223
+ releaseLock(this.locksDir, `notebook-${noteId}`);
224
+
225
+ return { noteId, entryId, appended };
226
+
227
+ } catch (err) {
228
+ releaseLock(this.locksDir, `notebook-${noteId}`);
229
+ throw err;
230
+ }
231
+
232
+ } catch (err) {
233
+ // Ensure index lock is released on error (may already be released)
234
+ releaseLock(this.locksDir, 'notebook-index');
235
+ throw err;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Read a note with all its entries
241
+ * @param {string} noteId - The note to read
242
+ * @returns {Object} { noteId, category, title, creator, createdAt, updatedAt, tags, entries: [...], entryCount }
243
+ */
244
+ read(noteId) {
245
+ // Step 1: Validate note exists
246
+ const index = readJsonSafe(this.indexPath, {});
247
+ if (!index[noteId]) {
248
+ throw new Error(`Note '${noteId}' does not exist`);
249
+ }
250
+
251
+ // Step 2: Read all JSONL entries
252
+ const entries = this._readEntries(noteId);
253
+
254
+ // Step 3: Assemble full note
255
+ const meta = index[noteId];
256
+ return {
257
+ noteId: meta.noteId,
258
+ category: meta.category,
259
+ title: meta.title,
260
+ creator: meta.creator,
261
+ createdAt: meta.createdAt,
262
+ updatedAt: meta.updatedAt,
263
+ tags: meta.tags || [],
264
+ entries,
265
+ entryCount: meta.entryCount
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Search notes by title, tags, and content
271
+ * @param {string} query - Search string (case-insensitive)
272
+ * @param {Object} [filters={}] - Optional filters
273
+ * @param {string} [filters.category] - Filter by category
274
+ * @param {string} [filters.author] - Filter by creator
275
+ * @param {string} [filters.tag] - Filter by tag
276
+ * @returns {Object} { query, results: [...], total }
277
+ */
278
+ search(query, { category, author, tag } = {}) {
279
+ if (!query || typeof query !== 'string') {
280
+ throw new Error('Search query is required');
281
+ }
282
+
283
+ const index = readJsonSafe(this.indexPath, {});
284
+ const lowerQuery = query.toLowerCase();
285
+ const results = [];
286
+ const contentScanNeeded = [];
287
+
288
+ // Step 1: Search index first (title and tag matches)
289
+ for (const meta of Object.values(index)) {
290
+ // Apply filters
291
+ if (category && meta.category !== category) continue;
292
+ if (author && meta.creator !== author) continue;
293
+ if (tag && (!meta.tags || !meta.tags.includes(tag))) continue;
294
+
295
+ // Check title match
296
+ if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
297
+ results.push({
298
+ noteId: meta.noteId,
299
+ category: meta.category,
300
+ title: meta.title,
301
+ creator: meta.creator,
302
+ matchType: 'title'
303
+ });
304
+ continue;
305
+ }
306
+
307
+ // Check tag match
308
+ if (meta.tags && meta.tags.some(t => t.toLowerCase().includes(lowerQuery))) {
309
+ results.push({
310
+ noteId: meta.noteId,
311
+ category: meta.category,
312
+ title: meta.title,
313
+ creator: meta.creator,
314
+ matchType: 'tag'
315
+ });
316
+ continue;
317
+ }
318
+
319
+ // No index match — queue for content scan
320
+ contentScanNeeded.push(meta);
321
+ }
322
+
323
+ // Step 2: Scan JSONL files for content matches (only for unmatched notes)
324
+ for (const meta of contentScanNeeded) {
325
+ const entries = this._readEntries(meta.noteId);
326
+
327
+ const hasContentMatch = entries.some(entry =>
328
+ entry.content && entry.content.toLowerCase().includes(lowerQuery)
329
+ );
330
+
331
+ if (hasContentMatch) {
332
+ results.push({
333
+ noteId: meta.noteId,
334
+ category: meta.category,
335
+ title: meta.title,
336
+ creator: meta.creator,
337
+ matchType: 'content'
338
+ });
339
+ }
340
+ }
341
+
342
+ return { query, results, total: results.length };
343
+ }
344
+
345
+ /**
346
+ * List notes from index with optional filters
347
+ * @param {Object} [options={}] - List options
348
+ * @param {string} [options.category] - Filter by category
349
+ * @param {string} [options.author] - Filter by creator
350
+ * @param {string} [options.tag] - Filter by tag
351
+ * @param {number} [options.limit=50] - Max notes to return
352
+ * @returns {Object} { notes: [...], total }
353
+ */
354
+ list({ category, author, tag, limit = 50 } = {}) {
355
+ const index = readJsonSafe(this.indexPath, {});
356
+
357
+ // Convert index to array
358
+ let notes = Object.values(index);
359
+
360
+ // Apply filters
361
+ if (category) {
362
+ notes = notes.filter(n => n.category === category);
363
+ }
364
+
365
+ if (author) {
366
+ notes = notes.filter(n => n.creator === author);
367
+ }
368
+
369
+ if (tag) {
370
+ notes = notes.filter(n => n.tags && n.tags.includes(tag));
371
+ }
372
+
373
+ const total = notes.length;
374
+
375
+ // Sort by updatedAt descending (most recent first)
376
+ notes.sort((a, b) => {
377
+ const dateA = a.updatedAt || a.createdAt || '';
378
+ const dateB = b.updatedAt || b.createdAt || '';
379
+ return dateB.localeCompare(dateA);
380
+ });
381
+
382
+ // Apply limit
383
+ if (limit && notes.length > limit) {
384
+ notes = notes.slice(0, limit);
385
+ }
386
+
387
+ return { notes, total };
388
+ }
389
+ }
390
+
391
+ module.exports = { NotebookStore, NOTEBOOK_CATEGORIES };
package/src/prompts.js CHANGED
@@ -141,6 +141,28 @@ mcp__multi-session__team_broadcast({ from: "${name}", content: "Completed: <what
141
141
  | \`contract_start\` | Begin working on an assigned contract |
142
142
  | \`contract_complete\` | Mark your contract as done |
143
143
 
144
+ ### Channels (persistent topic-based discussion — use for decisions, discoveries, blockers):
145
+ | Tool | When to Use |
146
+ |------|-------------|
147
+ | \`channel_create\` | Create a new discussion channel (rarely needed — defaults exist) |
148
+ | \`channel_post\` | Post a message to a channel (\`#design-decisions\`, \`#blockers\`, \`#discoveries\`, \`#conventions\`, \`#questions\`) |
149
+ | \`channel_read\` | Read recent messages from a channel |
150
+ | \`channel_subscribe\` | Subscribe to a channel for updates |
151
+ | \`channel_list\` | List all available channels |
152
+
153
+ ### Notebook (shared knowledge scratchpad — use for research, gotchas, patterns):
154
+ | Tool | When to Use |
155
+ |------|-------------|
156
+ | \`notebook_write\` | Write or append to a note (categories: research, decision, gotcha, pattern, plan, investigation, reference) |
157
+ | \`notebook_read\` | Read a note with all its entries |
158
+ | \`notebook_search\` | Search notes by keyword, category, or tag |
159
+ | \`notebook_list\` | List all notebook entries |
160
+
161
+ ### Human Escalation (LAST RESORT — only when teammates + artifacts + channels can't resolve):
162
+ | Tool | When to Use |
163
+ |------|-------------|
164
+ | \`ask_user\` | Ask the human user a question when genuinely stuck on an ambiguous decision |
165
+
144
166
  ## RULES
145
167
 
146
168
  IMPORTANT: Follow these rules strictly. Violating them causes coordination failures.
@@ -213,6 +235,66 @@ This is wrong because no one knows you're done, your work isn't discoverable via
213
235
  - Do NOT publish artifacts for incomplete or draft work. Publish when a unit of work is DONE.
214
236
  - Do NOT create contracts for tasks you can do yourself. Only create contracts for work that belongs to another session's domain.
215
237
 
238
+ ## COGNITIVE PROTOCOL: Think Before You Code
239
+
240
+ You are not just a task executor — you are a thinking collaborator. Follow this protocol for every task:
241
+
242
+ ### Phase 1: Orient (before writing any code)
243
+ - Read your inbox (\`team_check_inbox\`) — what context exists?
244
+ - Read shared artifacts (\`artifact_get\`) — what conventions, schemas, contracts exist?
245
+ - Read relevant channels (\`channel_read\` on \`#design-decisions\`, \`#conventions\`, \`#discoveries\`)
246
+ - Read the project notebook (\`notebook_list\`, \`notebook_read\`) — what has the team learned?
247
+ - Understand the WHY behind your task, not just the WHAT
248
+
249
+ ### Phase 2: Think (research and analyze)
250
+ - If requirements are ambiguous, check channels and notebook first
251
+ - If still unclear, use \`team_ask\` to consult a teammate
252
+ - If genuinely stuck on a decision only a human can make, use \`ask_user\` (LAST RESORT)
253
+ - Document your research in the notebook (\`notebook_write\`, category: \`research\` or \`investigation\`)
254
+
255
+ ### Phase 3: Plan (share your approach)
256
+ - Post your implementation plan to \`#design-decisions\` channel before coding
257
+ - If your plan affects other workers, post to relevant channels
258
+ - Write your plan to the notebook (\`notebook_write\`, category: \`plan\`)
259
+ - Wait briefly — if a teammate has concerns, they'll respond in the channel
260
+
261
+ ### Phase 4: Execute (implement with awareness)
262
+ - Follow shared conventions from the conventions artifact
263
+ - When you discover something unexpected, post to \`#discoveries\` channel
264
+ - When you hit a blocker, post to \`#blockers\` channel immediately
265
+ - When you find a gotcha (something that could trip up others), write it to the notebook (category: \`gotcha\`)
266
+
267
+ ### Phase 5: Verify (self-review)
268
+ - Review your work against the conventions artifact
269
+ - Check that your output matches expected formats
270
+ - Verify your changes don't break assumptions other workers depend on
271
+
272
+ ### Phase 6: Deliver (publish and share)
273
+ - Publish your artifact (\`artifact_publish\`)
274
+ - Post a summary of what you built and any decisions you made to \`#design-decisions\`
275
+ - Document any patterns worth reusing (\`notebook_write\`, category: \`pattern\`)
276
+ - Broadcast completion (\`team_broadcast\`)
277
+
278
+ ### Channel Usage Guide
279
+
280
+ | Channel | Purpose |
281
+ |---------|---------|
282
+ | \`#design-decisions\` | Post plans, architectural choices, approach changes |
283
+ | \`#blockers\` | When you're stuck and need help (check here to help others too) |
284
+ | \`#discoveries\` | Unexpected findings, API behaviors, edge cases |
285
+ | \`#conventions\` | Convention questions, proposed changes, clarifications |
286
+ | \`#questions\` | General questions for the team |
287
+
288
+ ### When to Escalate to Human (\`ask_user\`)
289
+
290
+ ONLY use \`ask_user\` when ALL of these are true:
291
+ 1. The question involves a genuine ambiguity (not a technical problem)
292
+ 2. You've checked artifacts, conventions, channels, and notebook
293
+ 3. You've asked teammates via \`team_ask\` and they couldn't resolve it
294
+ 4. The decision has significant impact and guessing wrong would be costly
295
+
296
+ Examples: "Should this API be public or internal?", "Which payment provider to use?", "Is PII allowed in logs?"
297
+
216
298
  ## BLAST RADIUS AWARENESS
217
299
 
218
300
  Before making any change, evaluate its impact on your teammates' work:
@@ -691,6 +773,10 @@ When all workers are done:
691
773
  | List available skills | \`skill_list\` |
692
774
  | Read a skill's content | \`skill_get\` |
693
775
  | Spawn worker with skills | \`team_spawn\` with \`skills\` parameter |
776
+ | Monitor team discussions | \`channel_read\`, \`channel_list\` |
777
+ | Monitor team knowledge | \`notebook_read\`, \`notebook_list\` |
778
+ | Check pending user questions | \`user_list_pending\` |
779
+ | Answer a worker's question | \`user_respond\` |
694
780
  | Clean up between runs | \`team_reset\` |
695
781
 
696
782
  ## WHAT GOES WRONG (And How to Avoid It)
@@ -817,6 +903,29 @@ When spawning workers, assign relevant expertise skills to improve output qualit
817
903
  - devops → devops
818
904
  - security → security
819
905
  - fullstack → nodejs-backend, react-frontend, typescript
906
+
907
+ ### Rule 9: Surface worker questions to the user
908
+
909
+ Workers can ask the human user questions via \`ask_user\` when genuinely stuck on ambiguous decisions.
910
+
911
+ - After spawning workers, periodically check for pending questions: use \`user_list_pending\` to see unanswered escalations
912
+ - When you see a pending question, present it to the user clearly with the worker's context
913
+ - After the user answers, relay it with \`user_respond\`
914
+ - Do NOT answer on behalf of the user — relay their actual response
915
+ - If multiple questions are pending, prioritize by priority level (urgent > high > normal > low)
916
+
917
+ ### Monitoring Channels and Notebook
918
+
919
+ As orchestrator, you can monitor team collaboration through channels and the notebook:
920
+
921
+ | Tool | Purpose |
922
+ |------|---------|
923
+ | \`channel_list\` | See all active channels and subscriber counts |
924
+ | \`channel_read\` | Read channel messages to monitor discussions, decisions, and blockers |
925
+ | \`notebook_list\` | See all notebook entries by category |
926
+ | \`notebook_read\` | Read specific notes for research, decisions, or gotchas workers documented |
927
+ | \`user_list_pending\` | Check for unanswered worker questions that need human input |
928
+ | \`user_respond\` | Relay the human user's answer to a worker's question |
820
929
  `;
821
930
  }
822
931