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.
Files changed (77) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/recall.md +87 -0
  4. package/.claude/commands/tlc/remember.md +71 -0
  5. package/CLAUDE.md +84 -201
  6. package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
  7. package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
  8. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
  9. package/dashboard-web/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/server/index.js +29 -4
  12. package/server/lib/bug-writer.js +204 -0
  13. package/server/lib/bug-writer.test.js +279 -0
  14. package/server/lib/claude-cascade.js +247 -0
  15. package/server/lib/claude-cascade.test.js +245 -0
  16. package/server/lib/context-injection.js +121 -0
  17. package/server/lib/context-injection.test.js +340 -0
  18. package/server/lib/conversation-chunker.js +320 -0
  19. package/server/lib/conversation-chunker.test.js +573 -0
  20. package/server/lib/embedding-client.js +160 -0
  21. package/server/lib/embedding-client.test.js +243 -0
  22. package/server/lib/global-config.js +198 -0
  23. package/server/lib/global-config.test.js +288 -0
  24. package/server/lib/inherited-search.js +184 -0
  25. package/server/lib/inherited-search.test.js +343 -0
  26. package/server/lib/memory-api.js +180 -0
  27. package/server/lib/memory-api.test.js +322 -0
  28. package/server/lib/memory-hooks-capture.test.js +350 -0
  29. package/server/lib/memory-hooks.js +101 -0
  30. package/server/lib/memory-inheritance.js +179 -0
  31. package/server/lib/memory-inheritance.test.js +360 -0
  32. package/server/lib/plan-parser.js +33 -7
  33. package/server/lib/plan-writer.js +196 -0
  34. package/server/lib/plan-writer.test.js +298 -0
  35. package/server/lib/project-scanner.js +267 -0
  36. package/server/lib/project-scanner.test.js +389 -0
  37. package/server/lib/project-status.js +302 -0
  38. package/server/lib/project-status.test.js +470 -0
  39. package/server/lib/projects-registry.js +237 -0
  40. package/server/lib/projects-registry.test.js +275 -0
  41. package/server/lib/recall-command.js +207 -0
  42. package/server/lib/recall-command.test.js +306 -0
  43. package/server/lib/remember-command.js +96 -0
  44. package/server/lib/remember-command.test.js +265 -0
  45. package/server/lib/rich-capture.js +221 -0
  46. package/server/lib/rich-capture.test.js +312 -0
  47. package/server/lib/roadmap-api.js +200 -0
  48. package/server/lib/roadmap-api.test.js +318 -0
  49. package/server/lib/semantic-recall.js +242 -0
  50. package/server/lib/semantic-recall.test.js +446 -0
  51. package/server/lib/setup-generator.js +315 -0
  52. package/server/lib/setup-generator.test.js +303 -0
  53. package/server/lib/test-inventory.js +112 -0
  54. package/server/lib/test-inventory.test.js +360 -0
  55. package/server/lib/vector-indexer.js +246 -0
  56. package/server/lib/vector-indexer.test.js +459 -0
  57. package/server/lib/vector-store.js +260 -0
  58. package/server/lib/vector-store.test.js +706 -0
  59. package/server/lib/workspace-api.js +811 -0
  60. package/server/lib/workspace-api.test.js +743 -0
  61. package/server/lib/workspace-bootstrap.js +164 -0
  62. package/server/lib/workspace-bootstrap.test.js +503 -0
  63. package/server/lib/workspace-context.js +129 -0
  64. package/server/lib/workspace-context.test.js +214 -0
  65. package/server/lib/workspace-detector.js +162 -0
  66. package/server/lib/workspace-detector.test.js +193 -0
  67. package/server/lib/workspace-init.js +307 -0
  68. package/server/lib/workspace-init.test.js +244 -0
  69. package/server/lib/workspace-snapshot.js +236 -0
  70. package/server/lib/workspace-snapshot.test.js +444 -0
  71. package/server/lib/workspace-watcher.js +162 -0
  72. package/server/lib/workspace-watcher.test.js +257 -0
  73. package/server/package-lock.json +552 -0
  74. package/server/package.json +4 -0
  75. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  76. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  77. 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
- // Find first incomplete phase
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 planPath = path.join(
39
- projectDir,
40
- '.planning',
41
- 'phases',
42
- `${result.currentPhase}-PLAN.md`
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');