clearctx 3.0.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.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +1006 -0
- package/STRATEGY.md +485 -0
- package/bin/cli.js +1756 -0
- package/bin/continuity-hook.js +118 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +929 -0
- package/package.json +56 -0
- package/src/artifact-store.js +710 -0
- package/src/atomic-io.js +99 -0
- package/src/briefing-generator.js +451 -0
- package/src/continuity-hooks.js +253 -0
- package/src/contract-store.js +525 -0
- package/src/decision-journal.js +229 -0
- package/src/delegate.js +348 -0
- package/src/dependency-resolver.js +453 -0
- package/src/diff-engine.js +473 -0
- package/src/file-lock.js +161 -0
- package/src/index.js +61 -0
- package/src/lineage-graph.js +402 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +3501 -0
- package/src/pattern-registry.js +221 -0
- package/src/pipeline-engine.js +618 -0
- package/src/prompts.js +1217 -0
- package/src/safety-net.js +170 -0
- package/src/session-snapshot.js +508 -0
- package/src/snapshot-engine.js +490 -0
- package/src/stale-detector.js +169 -0
- package/src/store.js +131 -0
- package/src/stream-session.js +463 -0
- package/src/team-hub.js +615 -0
package/src/atomic-io.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic I/O utilities for safe file operations.
|
|
3
|
+
*
|
|
4
|
+
* These functions ensure data integrity during concurrent access:
|
|
5
|
+
* - atomicWriteJson: Write-then-rename prevents partial reads
|
|
6
|
+
* - writeImmutable: Exclusive create prevents overwrites
|
|
7
|
+
* - readJsonSafe: Never throws, returns fallback on error
|
|
8
|
+
* - appendJsonl: Append-only log format for event streams
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write JSON data atomically using write-then-rename.
|
|
17
|
+
* Prevents other processes from reading partial/corrupt data.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} filePath - Target file path
|
|
20
|
+
* @param {any} data - Data to serialize as JSON
|
|
21
|
+
*/
|
|
22
|
+
function atomicWriteJson(filePath, data) {
|
|
23
|
+
// Ensure parent directory exists
|
|
24
|
+
const dir = path.dirname(filePath);
|
|
25
|
+
if (!fs.existsSync(dir)) {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Write to temp file in same directory (ensures same filesystem for atomic rename)
|
|
30
|
+
const tempSuffix = crypto.randomBytes(8).toString('hex');
|
|
31
|
+
const tempPath = `${filePath}.tmp.${tempSuffix}`;
|
|
32
|
+
|
|
33
|
+
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
34
|
+
|
|
35
|
+
// Atomic rename — replaces target file in a single filesystem operation
|
|
36
|
+
fs.renameSync(tempPath, filePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write JSON data with exclusive create (fails if file exists).
|
|
41
|
+
* Used for immutable artifacts like versioned snapshots.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} filePath - Target file path
|
|
44
|
+
* @param {any} data - Data to serialize as JSON
|
|
45
|
+
* @throws {Error} Throws EEXIST if file already exists
|
|
46
|
+
*/
|
|
47
|
+
function writeImmutable(filePath, data) {
|
|
48
|
+
// Ensure parent directory exists
|
|
49
|
+
const dir = path.dirname(filePath);
|
|
50
|
+
if (!fs.existsSync(dir)) {
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 'wx' flag = exclusive create — atomic check-and-create operation
|
|
55
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { flag: 'wx' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read and parse JSON file safely, never throws.
|
|
60
|
+
* Returns fallback value if file doesn't exist or parse fails.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} filePath - File to read
|
|
63
|
+
* @param {any} [fallback={}] - Value to return on error
|
|
64
|
+
* @returns {any} Parsed JSON or fallback
|
|
65
|
+
*/
|
|
66
|
+
function readJsonSafe(filePath, fallback = {}) {
|
|
67
|
+
try {
|
|
68
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
69
|
+
return JSON.parse(content);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// File doesn't exist, permission denied, invalid JSON, etc.
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Append a JSON object to a JSONL (JSON Lines) file.
|
|
78
|
+
* Each line is a complete JSON object for append-only event logs.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} filePath - JSONL file path
|
|
81
|
+
* @param {any} obj - Object to append (serialized as one line)
|
|
82
|
+
*/
|
|
83
|
+
function appendJsonl(filePath, obj) {
|
|
84
|
+
// Ensure parent directory exists
|
|
85
|
+
const dir = path.dirname(filePath);
|
|
86
|
+
if (!fs.existsSync(dir)) {
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Append as single line (no pretty printing)
|
|
91
|
+
fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
atomicWriteJson,
|
|
96
|
+
writeImmutable,
|
|
97
|
+
readJsonSafe,
|
|
98
|
+
appendJsonl
|
|
99
|
+
};
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const DiffEngine = require('./diff-engine');
|
|
6
|
+
const SessionSnapshot = require('./session-snapshot');
|
|
7
|
+
const DecisionJournal = require('./decision-journal');
|
|
8
|
+
const PatternRegistry = require('./pattern-registry');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* BriefingGenerator
|
|
12
|
+
*
|
|
13
|
+
* Generates markdown briefings for session continuity. This module creates
|
|
14
|
+
* comprehensive summaries of what happened since the last session, including
|
|
15
|
+
* git changes, file modifications, decisions made, and active patterns.
|
|
16
|
+
*
|
|
17
|
+
* Part of the Session Continuity Engine (Layer 0).
|
|
18
|
+
*/
|
|
19
|
+
class BriefingGenerator {
|
|
20
|
+
/**
|
|
21
|
+
* Creates a new BriefingGenerator instance
|
|
22
|
+
* @param {string} projectPath - Absolute path to the project root directory
|
|
23
|
+
*/
|
|
24
|
+
constructor(projectPath) {
|
|
25
|
+
// Initialize all the engines we need to generate a briefing
|
|
26
|
+
this.diff = new DiffEngine(projectPath);
|
|
27
|
+
this.snapshot = new SessionSnapshot(projectPath);
|
|
28
|
+
this.journal = new DecisionJournal(projectPath);
|
|
29
|
+
this.patterns = new PatternRegistry(projectPath);
|
|
30
|
+
this.projectPath = projectPath;
|
|
31
|
+
|
|
32
|
+
// Create a unique hash for this project to store briefings
|
|
33
|
+
// Uses the same hash and path as SessionSnapshot and other modules:
|
|
34
|
+
// ~/.clearctx/continuity/{projectHash}/briefings/
|
|
35
|
+
const hash = crypto.createHash('sha256').update(projectPath).digest('hex').slice(0, 16);
|
|
36
|
+
const storageDir = path.join(os.homedir(), '.clearctx', 'continuity', hash);
|
|
37
|
+
this.briefingsDir = path.join(storageDir, 'briefings');
|
|
38
|
+
|
|
39
|
+
// Make sure the briefings directory exists
|
|
40
|
+
if (!fs.existsSync(this.briefingsDir)) {
|
|
41
|
+
fs.mkdirSync(this.briefingsDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generates a complete markdown briefing from the latest snapshot and current diff
|
|
47
|
+
* @param {Object} options - Configuration options
|
|
48
|
+
* @param {number} options.maxTokens - Approximate max character length (default: 4000)
|
|
49
|
+
* @param {boolean} options.includeDecisions - Include recent decisions (default: true)
|
|
50
|
+
* @param {boolean} options.includePatterns - Include pattern rules (default: true)
|
|
51
|
+
* @param {number} options.decisionLimit - How many recent decisions (default: 10)
|
|
52
|
+
* @returns {Object} Contains markdown, briefingId, savedTo, and sections
|
|
53
|
+
*/
|
|
54
|
+
generate(options = {}) {
|
|
55
|
+
// Set up default options
|
|
56
|
+
const maxTokens = options.maxTokens || 4000;
|
|
57
|
+
const includeDecisions = options.includeDecisions !== false;
|
|
58
|
+
const includePatterns = options.includePatterns !== false;
|
|
59
|
+
const decisionLimit = options.decisionLimit || 10;
|
|
60
|
+
|
|
61
|
+
// Get the latest snapshot to compare against
|
|
62
|
+
const latestSnapshot = this.snapshot.getLatest();
|
|
63
|
+
|
|
64
|
+
// If no snapshot exists, this is the first session
|
|
65
|
+
if (!latestSnapshot) {
|
|
66
|
+
return this._generateFirstSessionBriefing(includePatterns);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Get the diff from the latest snapshot to now
|
|
70
|
+
const diff = this.diff.diffFromLatest();
|
|
71
|
+
|
|
72
|
+
// If diff has no git state, use safe defaults
|
|
73
|
+
const gitChanges = diff.gitChanges || { newCommitCount: 0, commits: [], branchChanged: false, oldBranch: 'unknown', newBranch: 'unknown' };
|
|
74
|
+
const fileChanges = diff.fileChanges || { added: [], modified: [], deleted: [], renamed: [] };
|
|
75
|
+
|
|
76
|
+
// Build all the sections of the briefing
|
|
77
|
+
const sections = {
|
|
78
|
+
header: this._formatHeader(latestSnapshot, diff),
|
|
79
|
+
timeSummary: this._formatTimeSummary(diff, gitChanges, fileChanges),
|
|
80
|
+
gitSection: this._formatGitSection(gitChanges),
|
|
81
|
+
fileSection: this._formatFileSection(fileChanges, (latestSnapshot.workingContext && latestSnapshot.workingContext.activeFiles) || []),
|
|
82
|
+
contextSection: this._formatContextSection(latestSnapshot),
|
|
83
|
+
decisionsSection: includeDecisions ? this._formatDecisions(decisionLimit) : '',
|
|
84
|
+
patternsSection: includePatterns ? this._formatPatterns() : '',
|
|
85
|
+
staleSection: this._formatStaleWarnings(fileChanges, (latestSnapshot.workingContext && latestSnapshot.workingContext.activeFiles) || [])
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Combine all sections into one markdown document
|
|
89
|
+
let markdown = [
|
|
90
|
+
sections.header,
|
|
91
|
+
sections.timeSummary,
|
|
92
|
+
sections.gitSection,
|
|
93
|
+
sections.fileSection,
|
|
94
|
+
sections.contextSection,
|
|
95
|
+
sections.decisionsSection,
|
|
96
|
+
sections.patternsSection,
|
|
97
|
+
sections.staleSection
|
|
98
|
+
].filter(s => s).join('\n\n'); // Filter out empty sections
|
|
99
|
+
|
|
100
|
+
// Trim if we exceed maxTokens (rough approximation: 1 token ≈ 1 character)
|
|
101
|
+
if (markdown.length > maxTokens) {
|
|
102
|
+
markdown = this._trimToTokenLimit(sections, maxTokens);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Save the briefing to disk
|
|
106
|
+
const timestamp = Date.now();
|
|
107
|
+
const briefingId = `brief_${timestamp}`;
|
|
108
|
+
const savedTo = path.join(this.briefingsDir, `${briefingId}.md`);
|
|
109
|
+
fs.writeFileSync(savedTo, markdown, 'utf8');
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
markdown,
|
|
113
|
+
briefingId,
|
|
114
|
+
savedTo,
|
|
115
|
+
sections
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generates a briefing for the very first session
|
|
121
|
+
* @param {boolean} includePatterns - Whether to include patterns
|
|
122
|
+
* @returns {Object} Briefing object
|
|
123
|
+
* @private
|
|
124
|
+
*/
|
|
125
|
+
_generateFirstSessionBriefing(includePatterns) {
|
|
126
|
+
let markdown = '## Session Briefing\n\n';
|
|
127
|
+
markdown += 'This is the first tracked session for this project. No previous context available.\n';
|
|
128
|
+
|
|
129
|
+
// Include patterns if requested
|
|
130
|
+
if (includePatterns) {
|
|
131
|
+
const patternsSection = this._formatPatterns();
|
|
132
|
+
if (patternsSection) {
|
|
133
|
+
markdown += '\n' + patternsSection;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Save the briefing
|
|
138
|
+
const timestamp = Date.now();
|
|
139
|
+
const briefingId = `brief_${timestamp}`;
|
|
140
|
+
const savedTo = path.join(this.briefingsDir, `${briefingId}.md`);
|
|
141
|
+
fs.writeFileSync(savedTo, markdown, 'utf8');
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
markdown,
|
|
145
|
+
briefingId,
|
|
146
|
+
savedTo,
|
|
147
|
+
sections: { header: markdown }
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Formats the header section with date and time
|
|
153
|
+
* @param {Object} snapshot - The latest snapshot
|
|
154
|
+
* @param {Object} diff - The diff object
|
|
155
|
+
* @returns {string} Formatted header
|
|
156
|
+
* @private
|
|
157
|
+
*/
|
|
158
|
+
_formatHeader(snapshot, diff) {
|
|
159
|
+
const now = new Date();
|
|
160
|
+
const dateStr = now.toLocaleDateString('en-US', {
|
|
161
|
+
month: 'short',
|
|
162
|
+
day: 'numeric',
|
|
163
|
+
year: 'numeric'
|
|
164
|
+
});
|
|
165
|
+
const timeStr = now.toLocaleTimeString('en-US', {
|
|
166
|
+
hour: 'numeric',
|
|
167
|
+
minute: '2-digit',
|
|
168
|
+
hour12: true
|
|
169
|
+
});
|
|
170
|
+
return `## Session Briefing — ${dateStr}, ${timeStr}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Formats the time summary section
|
|
175
|
+
* @param {Object} diff - The diff object
|
|
176
|
+
* @param {Object} gitChanges - Safe git changes object
|
|
177
|
+
* @param {Object} fileChanges - Safe file changes object
|
|
178
|
+
* @returns {string} Formatted time summary
|
|
179
|
+
* @private
|
|
180
|
+
*/
|
|
181
|
+
_formatTimeSummary(diff, gitChanges, fileChanges) {
|
|
182
|
+
const timeDelta = this._formatTimeDelta(diff.timeDelta || diff.timeDeltaMs || 0);
|
|
183
|
+
const commitCount = gitChanges.commits ? gitChanges.commits.length : gitChanges.newCommitCount || 0;
|
|
184
|
+
const addedCount = fileChanges.added.length;
|
|
185
|
+
const modifiedCount = fileChanges.modified.length;
|
|
186
|
+
const deletedCount = fileChanges.deleted.length;
|
|
187
|
+
|
|
188
|
+
let summary = `### Since your last session (${timeDelta}):\n`;
|
|
189
|
+
summary += `- **${commitCount} new commit${commitCount !== 1 ? 's' : ''}** on \`${gitChanges.newBranch || 'unknown'}\`\n`;
|
|
190
|
+
summary += `- **${addedCount} file${addedCount !== 1 ? 's' : ''} added**, **${modifiedCount} modified**, **${deletedCount} deleted**`;
|
|
191
|
+
|
|
192
|
+
return summary;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Formats a time delta in milliseconds to human-readable format
|
|
197
|
+
* @param {number} ms - Milliseconds
|
|
198
|
+
* @returns {string} Human-readable time delta
|
|
199
|
+
* @private
|
|
200
|
+
*/
|
|
201
|
+
_formatTimeDelta(ms) {
|
|
202
|
+
const seconds = Math.floor(ms / 1000);
|
|
203
|
+
const minutes = Math.floor(seconds / 60);
|
|
204
|
+
const hours = Math.floor(minutes / 60);
|
|
205
|
+
const days = Math.floor(hours / 24);
|
|
206
|
+
|
|
207
|
+
if (days > 0) return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
208
|
+
if (hours > 0) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
209
|
+
if (minutes > 0) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
210
|
+
return 'just now';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Formats the git changes section
|
|
215
|
+
* @param {Object} gitChanges - Git changes from diff
|
|
216
|
+
* @returns {string} Formatted git section
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
_formatGitSection(gitChanges) {
|
|
220
|
+
const commits = gitChanges.commits || [];
|
|
221
|
+
if (!commits.length && !gitChanges.branchChanged) {
|
|
222
|
+
return ''; // No git changes to report
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let section = '### Git Changes:\n';
|
|
226
|
+
|
|
227
|
+
// List new commits
|
|
228
|
+
if (commits.length > 0) {
|
|
229
|
+
commits.forEach(commit => {
|
|
230
|
+
section += `- \`${commit.hash}\` ${commit.message}\n`;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Report branch changes
|
|
235
|
+
if (gitChanges.branchChanged) {
|
|
236
|
+
section += `- Branch changed from \`${gitChanges.oldBranch || 'unknown'}\` to \`${gitChanges.newBranch || 'unknown'}\``;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return section.trim();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Formats the file changes section
|
|
244
|
+
* @param {Object} fileChanges - File changes from diff
|
|
245
|
+
* @param {Array} activeFiles - Files that were actively being worked on
|
|
246
|
+
* @returns {string} Formatted file section
|
|
247
|
+
* @private
|
|
248
|
+
*/
|
|
249
|
+
_formatFileSection(fileChanges, activeFiles) {
|
|
250
|
+
let section = '### File Changes:\n';
|
|
251
|
+
|
|
252
|
+
// Added files
|
|
253
|
+
section += '**Added:**\n';
|
|
254
|
+
if (fileChanges.added.length > 0) {
|
|
255
|
+
fileChanges.added.forEach(file => {
|
|
256
|
+
section += `- \`${file}\`\n`;
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
section += '- (none)\n';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
section += '\n**Modified:**\n';
|
|
263
|
+
if (fileChanges.modified.length > 0) {
|
|
264
|
+
fileChanges.modified.forEach(file => {
|
|
265
|
+
// Mark files that were being actively worked on
|
|
266
|
+
const isActive = activeFiles.includes(file);
|
|
267
|
+
section += `- \`${file}\`${isActive ? ' ⚠️ (you were working on this!)' : ''}\n`;
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
section += '- (none)\n';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
section += '\n**Deleted:**\n';
|
|
274
|
+
if (fileChanges.deleted.length > 0) {
|
|
275
|
+
fileChanges.deleted.forEach(file => {
|
|
276
|
+
section += `- \`${file}\`\n`;
|
|
277
|
+
});
|
|
278
|
+
} else {
|
|
279
|
+
section += '- (none)\n';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return section.trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Formats the previous context section
|
|
287
|
+
* @param {Object} snapshot - The latest snapshot
|
|
288
|
+
* @returns {string} Formatted context section
|
|
289
|
+
* @private
|
|
290
|
+
*/
|
|
291
|
+
_formatContextSection(snapshot) {
|
|
292
|
+
// The snapshot stores working context in snapshot.workingContext
|
|
293
|
+
const ctx = snapshot.workingContext;
|
|
294
|
+
if (!ctx) {
|
|
295
|
+
return '';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let section = '### Your Previous Context:\n';
|
|
299
|
+
|
|
300
|
+
// What were we working on?
|
|
301
|
+
if (ctx.taskSummary) {
|
|
302
|
+
section += `- **Working on:** ${ctx.taskSummary}\n`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Any open questions?
|
|
306
|
+
if (ctx.openQuestions && ctx.openQuestions.length > 0) {
|
|
307
|
+
section += '- **Open questions:**\n';
|
|
308
|
+
ctx.openQuestions.forEach(q => {
|
|
309
|
+
section += ` - ${q}\n`;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return section.trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Formats the decisions section
|
|
318
|
+
* @param {number} limit - Maximum number of decisions to include
|
|
319
|
+
* @returns {string} Formatted decisions section
|
|
320
|
+
* @private
|
|
321
|
+
*/
|
|
322
|
+
_formatDecisions(limit) {
|
|
323
|
+
const decisions = this.journal.list({ limit });
|
|
324
|
+
if (decisions.length === 0) {
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let section = '### Recent Decisions:\n';
|
|
329
|
+
decisions.forEach((decision, index) => {
|
|
330
|
+
const date = new Date(decision.createdAt).toLocaleDateString('en-US', {
|
|
331
|
+
month: 'short',
|
|
332
|
+
day: 'numeric'
|
|
333
|
+
});
|
|
334
|
+
section += `${index + 1}. **${decision.decision}** — ${decision.reason} _(${date})_\n`;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return section.trim();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Formats the patterns section
|
|
342
|
+
* @returns {string} Formatted patterns section
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
_formatPatterns() {
|
|
346
|
+
const patterns = this.patterns.list();
|
|
347
|
+
if (patterns.length === 0) {
|
|
348
|
+
return '';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let section = '### Patterns to Follow:\n';
|
|
352
|
+
patterns.forEach(pattern => {
|
|
353
|
+
const contextStr = pattern.context ? ` _(context: ${pattern.context})_` : '';
|
|
354
|
+
section += `- ${pattern.rule}${contextStr}\n`;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return section.trim();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Formats the stale warnings section
|
|
362
|
+
* @param {Object} fileChanges - File changes from diff
|
|
363
|
+
* @param {Array} activeFiles - Files that were actively being worked on
|
|
364
|
+
* @returns {string} Formatted stale warnings section
|
|
365
|
+
* @private
|
|
366
|
+
*/
|
|
367
|
+
_formatStaleWarnings(fileChanges, activeFiles) {
|
|
368
|
+
// Files that were active and have been modified are stale
|
|
369
|
+
const staleFiles = fileChanges.modified.filter(file => activeFiles.includes(file));
|
|
370
|
+
|
|
371
|
+
if (staleFiles.length === 0) {
|
|
372
|
+
return '';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let section = '### ⚠ Stale Context Warnings:\n';
|
|
376
|
+
staleFiles.forEach(file => {
|
|
377
|
+
section += `- \`${file}\` was modified since your last session. Re-read before assuming previous context is correct.\n`;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return section.trim();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Trims the briefing to fit within token limit
|
|
385
|
+
* @param {Object} sections - All sections of the briefing
|
|
386
|
+
* @param {number} maxTokens - Maximum allowed characters
|
|
387
|
+
* @returns {string} Trimmed markdown
|
|
388
|
+
* @private
|
|
389
|
+
*/
|
|
390
|
+
_trimToTokenLimit(sections, maxTokens) {
|
|
391
|
+
// Priority order: header > timeSummary > gitSection > fileSection > contextSection > decisionsSection > patternsSection > staleSection
|
|
392
|
+
// We trim from least important to most important
|
|
393
|
+
const priorityOrder = [
|
|
394
|
+
'patternsSection',
|
|
395
|
+
'decisionsSection',
|
|
396
|
+
'staleSection',
|
|
397
|
+
'fileSection',
|
|
398
|
+
'gitSection',
|
|
399
|
+
'contextSection',
|
|
400
|
+
'timeSummary',
|
|
401
|
+
'header'
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
let result = '';
|
|
405
|
+
let currentLength = 0;
|
|
406
|
+
|
|
407
|
+
// Start from most important and add sections until we hit the limit
|
|
408
|
+
for (let i = priorityOrder.length - 1; i >= 0; i--) {
|
|
409
|
+
const sectionName = priorityOrder[i];
|
|
410
|
+
const sectionContent = sections[sectionName];
|
|
411
|
+
|
|
412
|
+
if (!sectionContent) continue;
|
|
413
|
+
|
|
414
|
+
const sectionLength = sectionContent.length + 2; // +2 for the newlines
|
|
415
|
+
if (currentLength + sectionLength > maxTokens) {
|
|
416
|
+
break; // Can't fit any more sections
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
result = result ? sectionContent + '\n\n' + result : sectionContent;
|
|
420
|
+
currentLength += sectionLength;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return result.trim();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Gets the most recently generated briefing markdown
|
|
428
|
+
* @returns {string|null} The briefing markdown, or null if none exist
|
|
429
|
+
*/
|
|
430
|
+
getLatestBriefing() {
|
|
431
|
+
// Read all briefing files in the directory
|
|
432
|
+
if (!fs.existsSync(this.briefingsDir)) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const files = fs.readdirSync(this.briefingsDir)
|
|
437
|
+
.filter(f => f.startsWith('brief_') && f.endsWith('.md'))
|
|
438
|
+
.sort()
|
|
439
|
+
.reverse(); // Most recent first
|
|
440
|
+
|
|
441
|
+
if (files.length === 0) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Read and return the most recent briefing
|
|
446
|
+
const latestFile = path.join(this.briefingsDir, files[0]);
|
|
447
|
+
return fs.readFileSync(latestFile, 'utf8');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
module.exports = BriefingGenerator;
|