tlc-claude-code 1.8.4 → 2.0.1
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/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/CLAUDE.md +84 -201
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +29 -4
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/memory-api.js +180 -0
- package/server/lib/memory-api.test.js +322 -0
- package/server/lib/memory-hooks-capture.test.js +350 -0
- package/server/lib/memory-hooks.js +101 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/plan-parser.js +33 -7
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/project-scanner.js +267 -0
- package/server/lib/project-scanner.test.js +389 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +96 -0
- package/server/lib/remember-command.test.js +265 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +446 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/workspace-api.js +811 -0
- package/server/lib/workspace-api.test.js +743 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +552 -0
- package/server/package.json +4 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -121,7 +121,108 @@ function createMemoryHooks(projectRoot) {
|
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Default number of exchanges before auto-triggering capture processing
|
|
126
|
+
* @type {number}
|
|
127
|
+
*/
|
|
128
|
+
const DEFAULT_CAPTURE_THRESHOLD = 5;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create capture hooks for rolling buffer conversation capture.
|
|
132
|
+
*
|
|
133
|
+
* Accumulates exchanges in a rolling buffer and processes them through
|
|
134
|
+
* chunking, rich capture writing, and vector indexing when a threshold
|
|
135
|
+
* is reached, a TLC command fires, or flush() is called explicitly.
|
|
136
|
+
*
|
|
137
|
+
* Processing is non-blocking (fire-and-forget via Promise microtask).
|
|
138
|
+
*
|
|
139
|
+
* @param {string} projectRoot - Project root directory
|
|
140
|
+
* @param {Object} deps - Injected dependencies
|
|
141
|
+
* @param {Object} deps.chunker - Chunker with chunkConversation(exchanges)
|
|
142
|
+
* @param {Object} deps.richCapture - Writer with writeConversationChunk(projectRoot, chunk)
|
|
143
|
+
* @param {Object} deps.vectorIndexer - Indexer with indexChunk(chunk)
|
|
144
|
+
* @returns {{ onExchange: Function, getBufferSize: Function, onTlcCommand: Function, flush: Function, processBuffer: Function }}
|
|
145
|
+
*/
|
|
146
|
+
function createCaptureHooks(projectRoot, deps) {
|
|
147
|
+
const { chunker, richCapture, vectorIndexer } = deps;
|
|
148
|
+
let buffer = [];
|
|
149
|
+
let processing = false;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Process the current buffer: chunk, write, index, then clear.
|
|
153
|
+
* Runs asynchronously in a microtask so it never blocks the caller.
|
|
154
|
+
* The buffer is cleared after processing completes, not before.
|
|
155
|
+
*/
|
|
156
|
+
function processBuffer() {
|
|
157
|
+
if (buffer.length === 0 || processing) return;
|
|
158
|
+
|
|
159
|
+
processing = true;
|
|
160
|
+
|
|
161
|
+
Promise.resolve().then(async () => {
|
|
162
|
+
try {
|
|
163
|
+
const exchanges = buffer.slice();
|
|
164
|
+
const chunks = chunker.chunkConversation(exchanges);
|
|
165
|
+
for (const chunk of chunks) {
|
|
166
|
+
await richCapture.writeConversationChunk(projectRoot, chunk);
|
|
167
|
+
await vectorIndexer.indexChunk(chunk);
|
|
168
|
+
}
|
|
169
|
+
buffer = [];
|
|
170
|
+
} catch (_err) {
|
|
171
|
+
// Error resilience: capture failures must not propagate.
|
|
172
|
+
// Hooks remain functional after errors.
|
|
173
|
+
buffer = [];
|
|
174
|
+
} finally {
|
|
175
|
+
processing = false;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Add an exchange to the rolling buffer.
|
|
182
|
+
* Automatically triggers processing when the buffer reaches the threshold.
|
|
183
|
+
* @param {{ user: string, assistant: string, timestamp: number }} exchange
|
|
184
|
+
*/
|
|
185
|
+
function onExchange(exchange) {
|
|
186
|
+
buffer.push(exchange);
|
|
187
|
+
|
|
188
|
+
if (buffer.length >= DEFAULT_CAPTURE_THRESHOLD) {
|
|
189
|
+
processBuffer();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @returns {number} Current number of exchanges in the buffer
|
|
195
|
+
*/
|
|
196
|
+
function getBufferSize() {
|
|
197
|
+
return buffer.length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A TLC command was invoked -- trigger immediate capture of buffered exchanges.
|
|
202
|
+
* @param {string} _command - The TLC command name (e.g. 'build', 'plan')
|
|
203
|
+
*/
|
|
204
|
+
function onTlcCommand(_command) {
|
|
205
|
+
processBuffer();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Force-flush the buffer regardless of threshold.
|
|
210
|
+
*/
|
|
211
|
+
function flush() {
|
|
212
|
+
processBuffer();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
onExchange,
|
|
217
|
+
getBufferSize,
|
|
218
|
+
onTlcCommand,
|
|
219
|
+
flush,
|
|
220
|
+
processBuffer,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
124
224
|
module.exports = {
|
|
125
225
|
createMemoryHooks,
|
|
126
226
|
MemoryHooks,
|
|
227
|
+
createCaptureHooks,
|
|
127
228
|
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Inheritance Engine
|
|
3
|
+
*
|
|
4
|
+
* When loading memory for a project, also loads workspace-level memory
|
|
5
|
+
* and merges with correct priority. Project-level items override
|
|
6
|
+
* workspace-level items that share the same filename slug (topic).
|
|
7
|
+
*
|
|
8
|
+
* @module memory-inheritance
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default relevance score for project-level memory items.
|
|
16
|
+
* @type {number}
|
|
17
|
+
*/
|
|
18
|
+
const PROJECT_RELEVANCE = 1.0;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default relevance score for workspace-level memory items.
|
|
22
|
+
* @type {number}
|
|
23
|
+
*/
|
|
24
|
+
const WORKSPACE_RELEVANCE = 0.5;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Memory categories that the inheritance engine reads.
|
|
28
|
+
* @type {string[]}
|
|
29
|
+
*/
|
|
30
|
+
const CATEGORIES = ['decisions', 'gotchas', 'preferences', 'conversations'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read all .md files from a directory, returning an array of memory items.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} dir - Absolute path to a memory category directory
|
|
36
|
+
* @param {string} source - 'project' or 'workspace'
|
|
37
|
+
* @returns {Object[]} Array of { filename, text, source, topic, relevance }
|
|
38
|
+
*/
|
|
39
|
+
function readMemoryFiles(dir, source) {
|
|
40
|
+
if (!fs.existsSync(dir)) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(dir);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const items = [];
|
|
52
|
+
const relevance = source === 'project' ? PROJECT_RELEVANCE : WORKSPACE_RELEVANCE;
|
|
53
|
+
|
|
54
|
+
for (const filename of entries.sort()) {
|
|
55
|
+
if (!filename.endsWith('.md')) continue;
|
|
56
|
+
|
|
57
|
+
const filepath = path.join(dir, filename);
|
|
58
|
+
try {
|
|
59
|
+
const stat = fs.statSync(filepath);
|
|
60
|
+
if (!stat.isFile()) continue;
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const text = fs.readFileSync(filepath, 'utf8');
|
|
66
|
+
const topic = filename.replace(/\.md$/, '');
|
|
67
|
+
|
|
68
|
+
items.push({
|
|
69
|
+
filename,
|
|
70
|
+
text,
|
|
71
|
+
source,
|
|
72
|
+
topic,
|
|
73
|
+
relevance,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return items;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Merge two arrays of memory items. Project items override workspace items
|
|
82
|
+
* when they share the same topic (filename slug). For categories where
|
|
83
|
+
* override semantics apply (decisions, preferences), matching topics cause
|
|
84
|
+
* the workspace item to be dropped. For union categories (gotchas,
|
|
85
|
+
* conversations), all items are included.
|
|
86
|
+
*
|
|
87
|
+
* @param {Object[]} projectItems - Items from the project memory
|
|
88
|
+
* @param {Object[]} workspaceItems - Items from the workspace memory
|
|
89
|
+
* @param {string} category - The category name
|
|
90
|
+
* @returns {Object[]} Merged items with project taking priority
|
|
91
|
+
*/
|
|
92
|
+
function mergeItems(projectItems, workspaceItems, category) {
|
|
93
|
+
const overrideCategories = ['decisions', 'preferences'];
|
|
94
|
+
|
|
95
|
+
if (!overrideCategories.includes(category)) {
|
|
96
|
+
// Union: include all items from both sources
|
|
97
|
+
return [...projectItems, ...workspaceItems];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Override: project items replace workspace items with the same topic
|
|
101
|
+
const projectTopics = new Set(projectItems.map((item) => item.topic));
|
|
102
|
+
const filtered = workspaceItems.filter(
|
|
103
|
+
(item) => !projectTopics.has(item.topic)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return [...projectItems, ...filtered];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a memory inheritance engine instance.
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} options
|
|
113
|
+
* @param {Object} options.workspaceDetector - A workspace detector instance
|
|
114
|
+
* with a detectWorkspace(projectDir) method.
|
|
115
|
+
* @returns {{ loadInheritedMemory: Function, getInheritedRoots: Function }}
|
|
116
|
+
*/
|
|
117
|
+
export function createMemoryInheritance({ workspaceDetector }) {
|
|
118
|
+
/**
|
|
119
|
+
* Load memory from a project directory, inheriting workspace-level memory
|
|
120
|
+
* when the project is inside a workspace.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
123
|
+
* @returns {Promise<Object>} Merged memory with decisions, gotchas,
|
|
124
|
+
* preferences, and conversations arrays
|
|
125
|
+
*/
|
|
126
|
+
async function loadInheritedMemory(projectDir) {
|
|
127
|
+
const resolved = path.resolve(projectDir);
|
|
128
|
+
const wsResult = workspaceDetector.detectWorkspace(resolved);
|
|
129
|
+
|
|
130
|
+
const projectMemoryRoot = path.join(resolved, 'memory');
|
|
131
|
+
const workspaceMemoryRoot = wsResult.isInWorkspace
|
|
132
|
+
? path.join(wsResult.workspaceRoot, 'memory')
|
|
133
|
+
: null;
|
|
134
|
+
|
|
135
|
+
const result = {
|
|
136
|
+
decisions: [],
|
|
137
|
+
gotchas: [],
|
|
138
|
+
preferences: [],
|
|
139
|
+
conversations: [],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
for (const category of CATEGORIES) {
|
|
143
|
+
const projectDir_ = path.join(projectMemoryRoot, category);
|
|
144
|
+
const projectItems = readMemoryFiles(projectDir_, 'project');
|
|
145
|
+
|
|
146
|
+
let workspaceItems = [];
|
|
147
|
+
if (workspaceMemoryRoot) {
|
|
148
|
+
const wsDir = path.join(workspaceMemoryRoot, category);
|
|
149
|
+
workspaceItems = readMemoryFiles(wsDir, 'workspace');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
result[category] = mergeItems(projectItems, workspaceItems, category);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get the list of memory root directories that would be consulted
|
|
160
|
+
* for the given project.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
163
|
+
* @returns {string[]} Array of memory root paths (project first, then workspace)
|
|
164
|
+
*/
|
|
165
|
+
function getInheritedRoots(projectDir) {
|
|
166
|
+
const resolved = path.resolve(projectDir);
|
|
167
|
+
const wsResult = workspaceDetector.detectWorkspace(resolved);
|
|
168
|
+
|
|
169
|
+
const roots = [path.join(resolved, 'memory')];
|
|
170
|
+
|
|
171
|
+
if (wsResult.isInWorkspace) {
|
|
172
|
+
roots.push(path.join(wsResult.workspaceRoot, 'memory'));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return roots;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { loadInheritedMemory, getInheritedRoots };
|
|
179
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Inheritance Engine Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for loading and merging memory from both project-level and
|
|
5
|
+
* workspace-level sources with correct priority.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { createMemoryInheritance } from './memory-inheritance.js';
|
|
13
|
+
|
|
14
|
+
/** Create a unique temp directory for each test */
|
|
15
|
+
function makeTmpDir() {
|
|
16
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'mem-inherit-'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Recursively remove a directory */
|
|
20
|
+
function rmDir(dir) {
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Helper: create a memory directory structure with .md files.
|
|
26
|
+
* @param {string} root - Root directory to create memory in
|
|
27
|
+
* @param {Object} contents - { decisions: [{filename, text}], gotchas: [...], preferences: [...], conversations: [...] }
|
|
28
|
+
*/
|
|
29
|
+
function createMemoryDir(root, contents = {}) {
|
|
30
|
+
const memoryDir = path.join(root, 'memory');
|
|
31
|
+
const categories = ['decisions', 'gotchas', 'preferences', 'conversations'];
|
|
32
|
+
|
|
33
|
+
for (const cat of categories) {
|
|
34
|
+
const catDir = path.join(memoryDir, cat);
|
|
35
|
+
fs.mkdirSync(catDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
if (contents[cat]) {
|
|
38
|
+
for (const item of contents[cat]) {
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
path.join(catDir, item.filename),
|
|
41
|
+
item.text,
|
|
42
|
+
'utf8'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return memoryDir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('memory-inheritance', () => {
|
|
52
|
+
let tmpDir;
|
|
53
|
+
let workspaceDir;
|
|
54
|
+
let projectDir;
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
tmpDir = makeTmpDir();
|
|
58
|
+
workspaceDir = path.join(tmpDir, 'workspace');
|
|
59
|
+
projectDir = path.join(workspaceDir, 'my-project');
|
|
60
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
if (tmpDir) rmDir(tmpDir);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a mock workspaceDetector that returns configured workspace info.
|
|
69
|
+
*/
|
|
70
|
+
function mockDetector(wsRoot) {
|
|
71
|
+
return {
|
|
72
|
+
detectWorkspace: vi.fn((dir) => {
|
|
73
|
+
if (wsRoot) {
|
|
74
|
+
return {
|
|
75
|
+
isInWorkspace: true,
|
|
76
|
+
workspaceRoot: wsRoot,
|
|
77
|
+
projectPath: dir,
|
|
78
|
+
relativeProjectPath: path.relative(wsRoot, dir),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
isInWorkspace: false,
|
|
83
|
+
workspaceRoot: null,
|
|
84
|
+
projectPath: dir,
|
|
85
|
+
relativeProjectPath: null,
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('loadInheritedMemory', () => {
|
|
92
|
+
it('loads project-level decisions from memory/decisions/', async () => {
|
|
93
|
+
createMemoryDir(projectDir, {
|
|
94
|
+
decisions: [
|
|
95
|
+
{ filename: 'use-postgres.md', text: '# Use Postgres\n\nWe chose Postgres for JSONB support.' },
|
|
96
|
+
{ filename: 'use-rest.md', text: '# Use REST\n\nREST is simpler for our use case.' },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const detector = mockDetector(null); // standalone
|
|
101
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
102
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
103
|
+
|
|
104
|
+
expect(merged.decisions).toHaveLength(2);
|
|
105
|
+
expect(merged.decisions[0].text).toContain('Postgres');
|
|
106
|
+
expect(merged.decisions[1].text).toContain('REST');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('loads workspace-level decisions from workspace memory/decisions/', async () => {
|
|
110
|
+
createMemoryDir(workspaceDir, {
|
|
111
|
+
decisions: [
|
|
112
|
+
{ filename: 'shared-auth.md', text: '# Shared Auth\n\nAll projects use OAuth2.' },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
createMemoryDir(projectDir, { decisions: [] });
|
|
116
|
+
|
|
117
|
+
const detector = mockDetector(workspaceDir);
|
|
118
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
119
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
120
|
+
|
|
121
|
+
expect(merged.decisions).toHaveLength(1);
|
|
122
|
+
expect(merged.decisions[0].text).toContain('OAuth2');
|
|
123
|
+
expect(merged.decisions[0].source).toBe('workspace');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('merges decisions from both sources (union)', async () => {
|
|
127
|
+
createMemoryDir(workspaceDir, {
|
|
128
|
+
decisions: [
|
|
129
|
+
{ filename: 'shared-auth.md', text: '# Shared Auth\n\nOAuth2 everywhere.' },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
createMemoryDir(projectDir, {
|
|
133
|
+
decisions: [
|
|
134
|
+
{ filename: 'use-postgres.md', text: '# Use Postgres\n\nJSONB support.' },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const detector = mockDetector(workspaceDir);
|
|
139
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
140
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
141
|
+
|
|
142
|
+
expect(merged.decisions).toHaveLength(2);
|
|
143
|
+
const sources = merged.decisions.map((d) => d.source);
|
|
144
|
+
expect(sources).toContain('project');
|
|
145
|
+
expect(sources).toContain('workspace');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('project decisions override workspace for same topic (matching filename slug)', async () => {
|
|
149
|
+
createMemoryDir(workspaceDir, {
|
|
150
|
+
decisions: [
|
|
151
|
+
{ filename: 'database-choice.md', text: '# Database\n\nWorkspace says MySQL.' },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
createMemoryDir(projectDir, {
|
|
155
|
+
decisions: [
|
|
156
|
+
{ filename: 'database-choice.md', text: '# Database\n\nProject says Postgres.' },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const detector = mockDetector(workspaceDir);
|
|
161
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
162
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
163
|
+
|
|
164
|
+
// Same filename slug = same topic; project wins
|
|
165
|
+
expect(merged.decisions).toHaveLength(1);
|
|
166
|
+
expect(merged.decisions[0].text).toContain('Postgres');
|
|
167
|
+
expect(merged.decisions[0].source).toBe('project');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('loads and merges gotchas (union of both)', async () => {
|
|
171
|
+
createMemoryDir(workspaceDir, {
|
|
172
|
+
gotchas: [
|
|
173
|
+
{ filename: 'auth-warmup.md', text: '# Auth Warmup\n\nNeeds 2s delay.' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
createMemoryDir(projectDir, {
|
|
177
|
+
gotchas: [
|
|
178
|
+
{ filename: 'db-timeout.md', text: '# DB Timeout\n\nIncrease pool size.' },
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const detector = mockDetector(workspaceDir);
|
|
183
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
184
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
185
|
+
|
|
186
|
+
expect(merged.gotchas).toHaveLength(2);
|
|
187
|
+
const texts = merged.gotchas.map((g) => g.text);
|
|
188
|
+
expect(texts.some((t) => t.includes('Auth Warmup'))).toBe(true);
|
|
189
|
+
expect(texts.some((t) => t.includes('DB Timeout'))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('project preferences override workspace preferences (same filename slug)', async () => {
|
|
193
|
+
createMemoryDir(workspaceDir, {
|
|
194
|
+
preferences: [
|
|
195
|
+
{ filename: 'code-style.md', text: '# Code Style\n\nWorkspace: use tabs.' },
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
createMemoryDir(projectDir, {
|
|
199
|
+
preferences: [
|
|
200
|
+
{ filename: 'code-style.md', text: '# Code Style\n\nProject: use spaces.' },
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const detector = mockDetector(workspaceDir);
|
|
205
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
206
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
207
|
+
|
|
208
|
+
// Same slug = same topic; project wins
|
|
209
|
+
expect(merged.preferences).toHaveLength(1);
|
|
210
|
+
expect(merged.preferences[0].text).toContain('spaces');
|
|
211
|
+
expect(merged.preferences[0].source).toBe('project');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('conversations from both sources are included', async () => {
|
|
215
|
+
createMemoryDir(workspaceDir, {
|
|
216
|
+
conversations: [
|
|
217
|
+
{ filename: 'ws-session-1.md', text: '# Session 1\n\nDiscussed architecture.' },
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
createMemoryDir(projectDir, {
|
|
221
|
+
conversations: [
|
|
222
|
+
{ filename: 'proj-session-1.md', text: '# Session 1\n\nDiscussed database.' },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const detector = mockDetector(workspaceDir);
|
|
227
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
228
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
229
|
+
|
|
230
|
+
expect(merged.conversations).toHaveLength(2);
|
|
231
|
+
const sources = merged.conversations.map((c) => c.source);
|
|
232
|
+
expect(sources).toContain('project');
|
|
233
|
+
expect(sources).toContain('workspace');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('each item is tagged with source: project or workspace', async () => {
|
|
237
|
+
createMemoryDir(workspaceDir, {
|
|
238
|
+
decisions: [
|
|
239
|
+
{ filename: 'ws-decision.md', text: '# WS Decision\n\nShared policy.' },
|
|
240
|
+
],
|
|
241
|
+
gotchas: [
|
|
242
|
+
{ filename: 'ws-gotcha.md', text: '# WS Gotcha\n\nWatch out.' },
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
createMemoryDir(projectDir, {
|
|
246
|
+
decisions: [
|
|
247
|
+
{ filename: 'proj-decision.md', text: '# Proj Decision\n\nLocal policy.' },
|
|
248
|
+
],
|
|
249
|
+
gotchas: [
|
|
250
|
+
{ filename: 'proj-gotcha.md', text: '# Proj Gotcha\n\nBe careful.' },
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const detector = mockDetector(workspaceDir);
|
|
255
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
256
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
257
|
+
|
|
258
|
+
for (const item of [...merged.decisions, ...merged.gotchas]) {
|
|
259
|
+
expect(item).toHaveProperty('source');
|
|
260
|
+
expect(['project', 'workspace']).toContain(item.source);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const projectItems = merged.decisions.filter((d) => d.source === 'project');
|
|
264
|
+
const workspaceItems = merged.decisions.filter((d) => d.source === 'workspace');
|
|
265
|
+
expect(projectItems).toHaveLength(1);
|
|
266
|
+
expect(workspaceItems).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('workspace items have lower relevance than project items', async () => {
|
|
270
|
+
createMemoryDir(workspaceDir, {
|
|
271
|
+
decisions: [
|
|
272
|
+
{ filename: 'ws-decision.md', text: '# WS Decision\n\nShared.' },
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
createMemoryDir(projectDir, {
|
|
276
|
+
decisions: [
|
|
277
|
+
{ filename: 'proj-decision.md', text: '# Proj Decision\n\nLocal.' },
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const detector = mockDetector(workspaceDir);
|
|
282
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
283
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
284
|
+
|
|
285
|
+
const projectItem = merged.decisions.find((d) => d.source === 'project');
|
|
286
|
+
const workspaceItem = merged.decisions.find((d) => d.source === 'workspace');
|
|
287
|
+
|
|
288
|
+
expect(projectItem).toHaveProperty('relevance');
|
|
289
|
+
expect(workspaceItem).toHaveProperty('relevance');
|
|
290
|
+
expect(workspaceItem.relevance).toBeLessThan(projectItem.relevance);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('standalone project returns only own memory (no workspace)', async () => {
|
|
294
|
+
createMemoryDir(projectDir, {
|
|
295
|
+
decisions: [
|
|
296
|
+
{ filename: 'local-only.md', text: '# Local Only\n\nNo workspace.' },
|
|
297
|
+
],
|
|
298
|
+
gotchas: [],
|
|
299
|
+
preferences: [],
|
|
300
|
+
conversations: [],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const detector = mockDetector(null); // standalone
|
|
304
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
305
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
306
|
+
|
|
307
|
+
expect(merged.decisions).toHaveLength(1);
|
|
308
|
+
expect(merged.decisions[0].source).toBe('project');
|
|
309
|
+
expect(merged.gotchas).toHaveLength(0);
|
|
310
|
+
expect(merged.preferences).toHaveLength(0);
|
|
311
|
+
expect(merged.conversations).toHaveLength(0);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('getInheritedRoots', () => {
|
|
316
|
+
it('returns both memory paths when in a workspace', () => {
|
|
317
|
+
createMemoryDir(workspaceDir);
|
|
318
|
+
createMemoryDir(projectDir);
|
|
319
|
+
|
|
320
|
+
const detector = mockDetector(workspaceDir);
|
|
321
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
322
|
+
const roots = inheritance.getInheritedRoots(projectDir);
|
|
323
|
+
|
|
324
|
+
expect(roots).toHaveLength(2);
|
|
325
|
+
expect(roots).toContain(path.join(projectDir, 'memory'));
|
|
326
|
+
expect(roots).toContain(path.join(workspaceDir, 'memory'));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns only project memory path for standalone project', () => {
|
|
330
|
+
createMemoryDir(projectDir);
|
|
331
|
+
|
|
332
|
+
const detector = mockDetector(null); // standalone
|
|
333
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
334
|
+
const roots = inheritance.getInheritedRoots(projectDir);
|
|
335
|
+
|
|
336
|
+
expect(roots).toHaveLength(1);
|
|
337
|
+
expect(roots[0]).toBe(path.join(projectDir, 'memory'));
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('error handling', () => {
|
|
342
|
+
it('handles missing workspace memory directory gracefully', async () => {
|
|
343
|
+
// Workspace exists but has no memory/ dir
|
|
344
|
+
createMemoryDir(projectDir, {
|
|
345
|
+
decisions: [
|
|
346
|
+
{ filename: 'local.md', text: '# Local\n\nOnly local memory.' },
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
// No createMemoryDir for workspace -- so workspace/memory/ does not exist
|
|
350
|
+
|
|
351
|
+
const detector = mockDetector(workspaceDir);
|
|
352
|
+
const inheritance = createMemoryInheritance({ workspaceDetector: detector });
|
|
353
|
+
const merged = await inheritance.loadInheritedMemory(projectDir);
|
|
354
|
+
|
|
355
|
+
// Should not throw; just return project-only memory
|
|
356
|
+
expect(merged.decisions).toHaveLength(1);
|
|
357
|
+
expect(merged.decisions[0].source).toBe('project');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -18,7 +18,7 @@ function parsePlan(projectDir) {
|
|
|
18
18
|
if (fs.existsSync(roadmapPath)) {
|
|
19
19
|
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
20
20
|
|
|
21
|
-
//
|
|
21
|
+
// Format 1: ## Phase N: Name [x] (heading format)
|
|
22
22
|
const phaseMatches = content.matchAll(/##\s+Phase\s+(\d+)(?:\.(\d+))?[:\s]+(.+?)(?:\s*\[([x ])\])?$/gm);
|
|
23
23
|
for (const match of phaseMatches) {
|
|
24
24
|
const phaseNum = match[2] ? `${match[1]}.${match[2]}` : match[1];
|
|
@@ -31,16 +31,42 @@ function parsePlan(projectDir) {
|
|
|
31
31
|
break;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// Format 2: Table format | 01 | [Name](link) | status | description |
|
|
36
|
+
if (!result.currentPhase) {
|
|
37
|
+
const tableMatches = content.matchAll(/\|\s*(\d+)\s*\|\s*\[([^\]]+)\][^\|]*\|\s*(\w+)\s*\|/g);
|
|
38
|
+
for (const match of tableMatches) {
|
|
39
|
+
const phaseNum = match[1].replace(/^0+/, '') || '0'; // strip leading zeros
|
|
40
|
+
const phaseName = match[2].trim();
|
|
41
|
+
const status = match[3].trim().toLowerCase();
|
|
42
|
+
const completed = status === 'complete' || status === 'done' || status === 'verified';
|
|
43
|
+
|
|
44
|
+
if (!completed) {
|
|
45
|
+
result.currentPhase = phaseNum;
|
|
46
|
+
result.currentPhaseName = phaseName;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
// Load current phase PLAN.md
|
|
37
54
|
if (result.currentPhase) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
const phasesDir = path.join(projectDir, '.planning', 'phases');
|
|
56
|
+
let planPath = path.join(phasesDir, `${result.currentPhase}-PLAN.md`);
|
|
57
|
+
|
|
58
|
+
// Try exact match first, then glob for prefixed names like "06-name-PLAN.md"
|
|
59
|
+
if (!fs.existsSync(planPath) && fs.existsSync(phasesDir)) {
|
|
60
|
+
const padded = result.currentPhase.toString().padStart(2, '0');
|
|
61
|
+
const files = fs.readdirSync(phasesDir);
|
|
62
|
+
const match = files.find(f =>
|
|
63
|
+
(f.startsWith(`${padded}-`) || f.startsWith(`${result.currentPhase}-`)) &&
|
|
64
|
+
f.endsWith('-PLAN.md')
|
|
65
|
+
);
|
|
66
|
+
if (match) {
|
|
67
|
+
planPath = path.join(phasesDir, match);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
44
70
|
|
|
45
71
|
if (fs.existsSync(planPath)) {
|
|
46
72
|
const content = fs.readFileSync(planPath, 'utf-8');
|