codehere 0.3.4 → 0.5.0-alpha.2
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/README.md +68 -11
- package/dist/index.js +43 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +18 -3
- package/dist/infrastructure/orchestration/aider-adapter.js +12 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +9 -1
- package/dist/infrastructure/orchestration/codex-adapter.js +6 -1
- package/dist/infrastructure/orchestration/conductor-adapter.js +12 -2
- package/dist/infrastructure/orchestration/gemini-adapter.js +425 -0
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +10 -2
- package/dist/infrastructure/orchestration/memory-injector.js +337 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +34 -4
- package/dist/infrastructure/orchestration/progress-aggregator.js +157 -0
- package/dist/infrastructure/orchestration/readiness.js +2 -5
- package/dist/infrastructure/orchestration/session-manager.js +2 -1
- package/dist/infrastructure/orchestration/session-persistence.js +13 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +30 -3
- package/dist/infrastructure/protocols/ui-page.js +468 -59
- package/dist/presentation/cli/commands/init-command.js +342 -24
- package/dist/presentation/cli/commands/memory-command.js +1017 -0
- package/dist/presentation/cli/commands/protocols-builder.js +254 -0
- package/dist/presentation/cli/commands/protocols-command.js +84 -0
- package/dist/presentation/cli/commands/repo-map-builder.js +247 -0
- package/package.json +4 -3
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Command — F3 v0.5 memory operations.
|
|
3
|
+
*
|
|
4
|
+
* `codehere memory init` migrates the v0.4.1 .codehere/CONTEXT.md /
|
|
5
|
+
* HANDOVER.md / PROMPTS.md into the four-layer F3 layout per
|
|
6
|
+
* docs/design/f3_memory_spec.md:
|
|
7
|
+
*
|
|
8
|
+
* CONTEXT.md → AGENTS.md (top-level rename;
|
|
9
|
+
* standard cross-tool
|
|
10
|
+
* project briefing path)
|
|
11
|
+
* HANDOVER.md → memory/working/session.md (move + path change)
|
|
12
|
+
* PROMPTS.md → skills/<slug>/SKILL.md per ^## section
|
|
13
|
+
* (split on level-2 markdown
|
|
14
|
+
* headers; intro before
|
|
15
|
+
* first ## is dropped — it
|
|
16
|
+
* belongs in skills/_index.md
|
|
17
|
+
* which `codehere init` already
|
|
18
|
+
* wrote)
|
|
19
|
+
*
|
|
20
|
+
* Back-compat default: leave a SYMLINK at each old path pointing at the new
|
|
21
|
+
* location, so any tool / muscle memory that still reads `.codehere/CONTEXT.md`
|
|
22
|
+
* continues to work transparently.
|
|
23
|
+
*
|
|
24
|
+
* For PROMPTS.md the symlink target is `skills/_index.md` because the source
|
|
25
|
+
* was split into multiple files — there's no single new file to point at.
|
|
26
|
+
*
|
|
27
|
+
* Future tasks (v0.5):
|
|
28
|
+
* - `codehere memory show [layer]` (Task 6)
|
|
29
|
+
* - `codehere memory graduate` (Task 5)
|
|
30
|
+
*
|
|
31
|
+
* This file currently implements only `init`. The action dispatch is built so
|
|
32
|
+
* future actions slot in without touching index.ts.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, lstatSync, symlinkSync, statSync, readdirSync, readlinkSync, openSync, writeSync, fsyncSync, closeSync, } from 'fs';
|
|
35
|
+
import { createHash } from 'crypto';
|
|
36
|
+
import { join, dirname } from 'path';
|
|
37
|
+
import { colors, success, info, warning, error, newline } from '../../../ui.js';
|
|
38
|
+
import { findCodebaseRoot } from '../../../domain/services/codebase-detector.js';
|
|
39
|
+
import { homedir } from 'os';
|
|
40
|
+
import { F3_TEMPLATES, ensureEpisodicSymlink } from './init-command.js';
|
|
41
|
+
import { writeRepoMap } from './repo-map-builder.js';
|
|
42
|
+
import { buildMemoryPrefix } from '../../../infrastructure/orchestration/memory-injector.js';
|
|
43
|
+
/**
|
|
44
|
+
* Lookup table: relative-path-under-.codehere → default seed body.
|
|
45
|
+
* If a target file matches its default seed byte-for-byte, the user hasn't
|
|
46
|
+
* edited it and migration is free to overwrite without --force.
|
|
47
|
+
*
|
|
48
|
+
* Built lazily from init-command.ts's F3_TEMPLATES so the seed bodies stay
|
|
49
|
+
* in one place — no duplication, no drift.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_SEED_BODIES = new Map(F3_TEMPLATES.map(t => [t.filename, t.body]));
|
|
52
|
+
function lessonIdForText(text) {
|
|
53
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
function readLessonsFile(path) {
|
|
56
|
+
if (!existsSync(path))
|
|
57
|
+
return [];
|
|
58
|
+
return readFileSync(path, 'utf8')
|
|
59
|
+
.trim()
|
|
60
|
+
.split('\n')
|
|
61
|
+
.filter(l => l.length > 0)
|
|
62
|
+
.map(l => {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(l);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Skip malformed lines rather than crash.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.filter((l) => l !== null);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Append a lesson to lessons.jsonl, deduped by id (content-derived).
|
|
75
|
+
* Returns 'added' on first write, 'duplicate' if the same id already exists.
|
|
76
|
+
*
|
|
77
|
+
* Single-user CLI write — no concurrent-writer atomicity is required. We
|
|
78
|
+
* open with `O_APPEND` (Node's `'a'` mode), `writeSync`, then `fsyncSync`
|
|
79
|
+
* to flush the kernel write buffer to stable storage so a power loss
|
|
80
|
+
* between the call returning and the next sync doesn't lose the lesson
|
|
81
|
+
* (K2 Round 1 crash-safety nit).
|
|
82
|
+
*/
|
|
83
|
+
function appendLessonIfNew(path, text, source) {
|
|
84
|
+
const id = lessonIdForText(text);
|
|
85
|
+
const existing = readLessonsFile(path);
|
|
86
|
+
const dup = existing.find(l => l.id === id);
|
|
87
|
+
if (dup)
|
|
88
|
+
return { result: 'duplicate', lesson: dup };
|
|
89
|
+
const lesson = {
|
|
90
|
+
id,
|
|
91
|
+
text,
|
|
92
|
+
graduatedAt: new Date().toISOString(),
|
|
93
|
+
source,
|
|
94
|
+
};
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
// O_APPEND open + write + fsync. fsync is the K2 crash-safety guarantee.
|
|
97
|
+
const fd = openSync(path, 'a');
|
|
98
|
+
try {
|
|
99
|
+
writeSync(fd, JSON.stringify(lesson) + '\n');
|
|
100
|
+
fsyncSync(fd);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
return { result: 'added', lesson };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Validate that a parsed candidate has the required fields.
|
|
109
|
+
* Returns null for any malformed shape — caller surfaces a clean error
|
|
110
|
+
* instead of crashing in `createHash().update(undefined)` (codex Round 1
|
|
111
|
+
* fix: candidate files are user-droppable + auto-dream-written, neither
|
|
112
|
+
* is fully trusted; v0.5 needs explicit validation).
|
|
113
|
+
*
|
|
114
|
+
* The candidate's identity is its FILENAME (passed as `filenameId`), never
|
|
115
|
+
* the JSON `id` field — that field is informational metadata only. Real-repo
|
|
116
|
+
* dogfood Apr 29 caught Bug 3: when JSON `id` differed from filename, the
|
|
117
|
+
* list view showed the JSON id but `--accept <id>` lookups by filename failed
|
|
118
|
+
* with "no candidate found." Filename is now the single source of truth so
|
|
119
|
+
* what the user sees in the list is exactly what `--accept` expects.
|
|
120
|
+
*/
|
|
121
|
+
function validateCandidate(parsed, filenameId) {
|
|
122
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
123
|
+
return null;
|
|
124
|
+
const obj = parsed;
|
|
125
|
+
const text = obj.text;
|
|
126
|
+
if (typeof text !== 'string' || text.trim().length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
const out = { id: filenameId, text };
|
|
129
|
+
if (typeof obj.createdAt === 'string')
|
|
130
|
+
out.createdAt = obj.createdAt;
|
|
131
|
+
if (typeof obj.source === 'string')
|
|
132
|
+
out.source = obj.source;
|
|
133
|
+
if (Array.isArray(obj.supportingEvents)) {
|
|
134
|
+
out.supportingEvents = obj.supportingEvents.filter((e) => typeof e === 'string');
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function readCandidate(candidatesDir, id) {
|
|
139
|
+
const path = join(candidatesDir, id + '.json');
|
|
140
|
+
if (!existsSync(path))
|
|
141
|
+
return null;
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return validateCandidate(parsed, id);
|
|
150
|
+
}
|
|
151
|
+
function listCandidates(candidatesDir) {
|
|
152
|
+
if (!existsSync(candidatesDir))
|
|
153
|
+
return [];
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const entry of readdirSync(candidatesDir, { withFileTypes: true })) {
|
|
156
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
157
|
+
continue;
|
|
158
|
+
const id = entry.name.slice(0, -'.json'.length);
|
|
159
|
+
const c = readCandidate(candidatesDir, id);
|
|
160
|
+
if (c)
|
|
161
|
+
out.push(c);
|
|
162
|
+
// Malformed candidates silently skipped from the LIST view — but
|
|
163
|
+
// --accept on a malformed file by id will surface a clean error
|
|
164
|
+
// (readCandidate returns null → the same not-found message fires).
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* F3 Task 5 — `codehere memory graduate`.
|
|
170
|
+
*
|
|
171
|
+
* Promotes patterns into project-wide lessons. Three explicit paths:
|
|
172
|
+
*
|
|
173
|
+
* --add "<text>" direct free-text lesson (most common in v0.5
|
|
174
|
+
* before auto-dream populates candidates)
|
|
175
|
+
* --accept <id> graduate a candidate file
|
|
176
|
+
* --reject <id> delete a candidate without graduating
|
|
177
|
+
*
|
|
178
|
+
* No flags: list any candidates (or print "no candidates" + --add hint).
|
|
179
|
+
*
|
|
180
|
+
* Lesson ids are content-derived (16-hex sha256 of text) so the same text
|
|
181
|
+
* graduated twice is a no-op. Candidate files are deleted on accept/reject
|
|
182
|
+
* regardless of dedup outcome — the user made a decision, the candidate
|
|
183
|
+
* shouldn't keep nagging.
|
|
184
|
+
*
|
|
185
|
+
* The auto-dream pipeline that POPULATES candidates/ ships in v0.6
|
|
186
|
+
* (`auto_dream.py` analog from agentic-stack, see ADR-006). v0.5 ships
|
|
187
|
+
* the graduation primitives so the eventual auto-dream output is
|
|
188
|
+
* actionable on day one.
|
|
189
|
+
*/
|
|
190
|
+
function executeMemoryGraduate(options) {
|
|
191
|
+
const codeInfo = findCodebaseRoot();
|
|
192
|
+
const root = codeInfo?.rootPath ?? process.cwd();
|
|
193
|
+
const codehereDir = join(root, '.codehere');
|
|
194
|
+
if (!existsSync(codehereDir)) {
|
|
195
|
+
newline();
|
|
196
|
+
console.log(` ${error('refused')} .codehere/ not found at this root.`);
|
|
197
|
+
console.log(` ${colors.dim('Run')} ${colors.cyan('codehere init')} ${colors.dim('first.')}`);
|
|
198
|
+
newline();
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const lessonsPath = join(codehereDir, 'memory/semantic/lessons.jsonl');
|
|
203
|
+
const candidatesDir = join(codehereDir, 'memory/candidates');
|
|
204
|
+
newline();
|
|
205
|
+
console.log(colors.bold('codehere memory graduate'));
|
|
206
|
+
newline();
|
|
207
|
+
// ── --add: direct free-text lesson ──────────────────────────────────────
|
|
208
|
+
if (options.add !== undefined) {
|
|
209
|
+
const text = options.add.trim();
|
|
210
|
+
if (text.length === 0) {
|
|
211
|
+
console.log(` ${error('failed ')} --add requires a non-empty lesson text`);
|
|
212
|
+
newline();
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const r = appendLessonIfNew(lessonsPath, text, 'manual');
|
|
217
|
+
if (r.result === 'added') {
|
|
218
|
+
console.log(` ${success('added ')} lesson ${colors.dim(r.lesson.id)} → ${lessonsPath}`);
|
|
219
|
+
console.log(` ${colors.dim('"' + r.lesson.text + '"')}`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
console.log(` ${info('skipped')} duplicate of existing lesson ${colors.dim(r.lesson.id)}`);
|
|
223
|
+
console.log(` ${colors.dim('(already in lessons.jsonl — graduate is a no-op)')}`);
|
|
224
|
+
}
|
|
225
|
+
newline();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// ── --accept <id>: graduate a candidate ─────────────────────────────────
|
|
229
|
+
if (options.accept !== undefined) {
|
|
230
|
+
const candidateId = options.accept;
|
|
231
|
+
const c = readCandidate(candidatesDir, candidateId);
|
|
232
|
+
if (c === null) {
|
|
233
|
+
console.log(` ${error('failed ')} no candidate found with id "${candidateId}"`);
|
|
234
|
+
console.log(` ${colors.dim('Inspect ')} ${colors.cyan('codehere memory show')} ${colors.dim('to see what candidates exist.')}`);
|
|
235
|
+
newline();
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const r = appendLessonIfNew(lessonsPath, c.text, `candidate:${candidateId}`);
|
|
240
|
+
// Either way, the user decided — delete the candidate.
|
|
241
|
+
rmSync(join(candidatesDir, candidateId + '.json'));
|
|
242
|
+
if (r.result === 'added') {
|
|
243
|
+
console.log(` ${success('graduated')} candidate ${candidateId} → lesson ${colors.dim(r.lesson.id)}`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
console.log(` ${info('skipped ')} candidate ${candidateId} text already in lessons (lesson ${colors.dim(r.lesson.id)})`);
|
|
247
|
+
console.log(` ${colors.dim('(candidate file deleted; decision recorded)')}`);
|
|
248
|
+
}
|
|
249
|
+
newline();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// ── --reject <id>: delete a candidate ──────────────────────────────────
|
|
253
|
+
if (options.reject !== undefined) {
|
|
254
|
+
const candidateId = options.reject;
|
|
255
|
+
const candidatePath = join(candidatesDir, candidateId + '.json');
|
|
256
|
+
if (!existsSync(candidatePath)) {
|
|
257
|
+
console.log(` ${error('failed ')} no candidate found with id "${candidateId}"`);
|
|
258
|
+
newline();
|
|
259
|
+
process.exitCode = 1;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
rmSync(candidatePath);
|
|
263
|
+
console.log(` ${success('rejected')} candidate ${candidateId} (deleted, not graduated)`);
|
|
264
|
+
newline();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// ── no flags: list candidates ──────────────────────────────────────────
|
|
268
|
+
const candidates = listCandidates(candidatesDir);
|
|
269
|
+
if (candidates.length === 0) {
|
|
270
|
+
console.log(` ${info('empty ')} no candidates to review`);
|
|
271
|
+
newline();
|
|
272
|
+
console.log(colors.dim(` Auto-clustering of episodic patterns into candidates ships in v0.6.`));
|
|
273
|
+
console.log(colors.dim(` For now, add lessons directly:`));
|
|
274
|
+
console.log(` ${colors.cyan('codehere memory graduate --add "your lesson text"')}`);
|
|
275
|
+
newline();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
console.log(` ${success('present')} ${candidates.length} candidate${candidates.length === 1 ? '' : 's'} pending review:`);
|
|
279
|
+
newline();
|
|
280
|
+
for (const c of candidates) {
|
|
281
|
+
console.log(` ${colors.bold(c.id)}`);
|
|
282
|
+
console.log(` ${c.text}`);
|
|
283
|
+
if (c.source)
|
|
284
|
+
console.log(` ${colors.dim('source: ' + c.source)}`);
|
|
285
|
+
newline();
|
|
286
|
+
}
|
|
287
|
+
console.log(colors.dim(' Act on each:'));
|
|
288
|
+
console.log(` ${colors.cyan('codehere memory graduate --accept <id>')}`);
|
|
289
|
+
console.log(` ${colors.cyan('codehere memory graduate --reject <id>')}`);
|
|
290
|
+
newline();
|
|
291
|
+
}
|
|
292
|
+
const SHOW_LAYERS = ['working', 'episodic', 'semantic', 'personal', 'skills', 'protocols', 'injection'];
|
|
293
|
+
/**
|
|
294
|
+
* Render bytes as a human-friendly size: "0", "412 B", "3.2 KB", "1.8 MB".
|
|
295
|
+
*/
|
|
296
|
+
function fmtSize(bytes) {
|
|
297
|
+
if (bytes === 0)
|
|
298
|
+
return '0';
|
|
299
|
+
if (bytes < 1024)
|
|
300
|
+
return bytes + ' B';
|
|
301
|
+
if (bytes < 1024 * 1024)
|
|
302
|
+
return (bytes / 1024).toFixed(1) + ' KB';
|
|
303
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Collect a one-line presence/size summary per layer for the overview view.
|
|
307
|
+
* Pure: only reads filesystem stats; no formatting yet.
|
|
308
|
+
*
|
|
309
|
+
* Takes BOTH `codehereDir` and `projectRoot` because the `injection` layer
|
|
310
|
+
* needs the project root (not just .codehere/) — buildMemoryPrefix walks
|
|
311
|
+
* upward from there. (codex Round 1 fix — the original signature only had
|
|
312
|
+
* codehereDir, so the injection summary line was missing from the overview.)
|
|
313
|
+
*/
|
|
314
|
+
function gatherLayerSummary(codehereDir, projectRoot) {
|
|
315
|
+
const out = [];
|
|
316
|
+
// working/session.md
|
|
317
|
+
const sessionPath = join(codehereDir, 'memory/working/session.md');
|
|
318
|
+
if (existsSync(sessionPath)) {
|
|
319
|
+
const sz = statSync(sessionPath).size;
|
|
320
|
+
out.push({ layer: 'working', line: `working · session.md (${fmtSize(sz)})` });
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
out.push({ layer: 'working', line: `working · (empty — no session.md)` });
|
|
324
|
+
}
|
|
325
|
+
// episodic — symlink + repo_map
|
|
326
|
+
const episodicLearnings = join(codehereDir, 'memory/episodic/AGENT_LEARNINGS.jsonl');
|
|
327
|
+
const repoMapPath = join(codehereDir, 'memory/episodic/repo_map.json');
|
|
328
|
+
const learningsLine = existsSync(episodicLearnings) || isSymlink(episodicLearnings)
|
|
329
|
+
? (isSymlink(episodicLearnings) ? `AGENT_LEARNINGS.jsonl → ${readlinkSync(episodicLearnings)}` : 'AGENT_LEARNINGS.jsonl (real file)')
|
|
330
|
+
: '(no AGENT_LEARNINGS.jsonl)';
|
|
331
|
+
const repoMapLine = existsSync(repoMapPath)
|
|
332
|
+
? `repo_map.json (${fmtSize(statSync(repoMapPath).size)})`
|
|
333
|
+
: 'no repo_map.json — run `codehere memory repo-map`';
|
|
334
|
+
out.push({ layer: 'episodic', line: `episodic · ${learningsLine} · ${repoMapLine}` });
|
|
335
|
+
// semantic/lessons.jsonl
|
|
336
|
+
const lessonsPath = join(codehereDir, 'memory/semantic/lessons.jsonl');
|
|
337
|
+
if (existsSync(lessonsPath)) {
|
|
338
|
+
const body = readFileSync(lessonsPath, 'utf8');
|
|
339
|
+
const count = body.trim().length === 0 ? 0 : body.trim().split('\n').length;
|
|
340
|
+
out.push({ layer: 'semantic', line: `semantic · lessons.jsonl (${count} lesson${count === 1 ? '' : 's'})` });
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
out.push({ layer: 'semantic', line: `semantic · (no lessons.jsonl)` });
|
|
344
|
+
}
|
|
345
|
+
// personal/preferences.json
|
|
346
|
+
const prefsPath = join(codehereDir, 'memory/personal/preferences.json');
|
|
347
|
+
if (existsSync(prefsPath)) {
|
|
348
|
+
out.push({ layer: 'personal', line: `personal · preferences.json (${fmtSize(statSync(prefsPath).size)})` });
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
out.push({ layer: 'personal', line: `personal · (no preferences.json)` });
|
|
352
|
+
}
|
|
353
|
+
// skills/<name>/SKILL.md
|
|
354
|
+
const skillsDir = join(codehereDir, 'skills');
|
|
355
|
+
if (existsSync(skillsDir)) {
|
|
356
|
+
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
357
|
+
.filter(e => e.isDirectory())
|
|
358
|
+
.filter(e => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
359
|
+
out.push({ layer: 'skills', line: `skills · ${subdirs.length} skill folder${subdirs.length === 1 ? '' : 's'}` });
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
out.push({ layer: 'skills', line: `skills · (no skills/ dir)` });
|
|
363
|
+
}
|
|
364
|
+
// protocols/hook_patterns.json
|
|
365
|
+
const patternsPath = join(codehereDir, 'protocols/hook_patterns.json');
|
|
366
|
+
if (existsSync(patternsPath)) {
|
|
367
|
+
try {
|
|
368
|
+
const parsed = JSON.parse(readFileSync(patternsPath, 'utf8'));
|
|
369
|
+
const builtin = Array.isArray(parsed.patterns_builtin) ? parsed.patterns_builtin.length : 0;
|
|
370
|
+
const project = Array.isArray(parsed.patterns_project) ? parsed.patterns_project.length : 0;
|
|
371
|
+
out.push({ layer: 'protocols', line: `protocols · hook_patterns.json (${builtin} built-in + ${project} project)` });
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
out.push({ layer: 'protocols', line: `protocols · hook_patterns.json (parse error)` });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
out.push({ layer: 'protocols', line: `protocols · (no hook_patterns.json — run \`codehere protocols export\`)` });
|
|
379
|
+
}
|
|
380
|
+
// injection — codex Round 1 fix: every entry in SHOW_LAYERS must appear
|
|
381
|
+
// in the overview, including the special-case `injection` layer.
|
|
382
|
+
// buildMemoryPrefix is cheap (it reads the same files we already read above)
|
|
383
|
+
// and gives the user a single-line answer to "what would actually inject?"
|
|
384
|
+
// — the most useful thing on the overview for someone debugging their
|
|
385
|
+
// setup.
|
|
386
|
+
const inj = buildMemoryPrefix(projectRoot);
|
|
387
|
+
if (inj.prefix === '') {
|
|
388
|
+
out.push({ layer: 'injection', line: `injection · (nothing — empty / placeholder layers)` });
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const truncNote = inj.truncated ? ' TRUNCATED' : '';
|
|
392
|
+
out.push({ layer: 'injection', line: `injection · ${inj.tokens} tokens · ${inj.included.join(', ')}${truncNote}` });
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* `codehere memory show [layer]` — read-only inspector for the F3 layers.
|
|
398
|
+
* Renders the layer's content (no edits, no spawn).
|
|
399
|
+
*
|
|
400
|
+
* Special layer `injection` previews the prefix that WOULD be injected for
|
|
401
|
+
* a spawn from cwd. Addresses the K2 Round 1 UX concern from F3 Task 8 —
|
|
402
|
+
* until now the user had no CLI surface to see the auto-injected memory.
|
|
403
|
+
*/
|
|
404
|
+
function executeMemoryShow(layerArg) {
|
|
405
|
+
const codeInfo = findCodebaseRoot();
|
|
406
|
+
const root = codeInfo?.rootPath ?? process.cwd();
|
|
407
|
+
const codehereDir = join(root, '.codehere');
|
|
408
|
+
// Special case: `memory show injection` is meaningful even outside a
|
|
409
|
+
// codehere project — the honest answer is "nothing would inject from
|
|
410
|
+
// here." The buildMemoryPrefix() helper handles the no-ancestor case
|
|
411
|
+
// by returning an empty prefix, so we let it run and report "empty".
|
|
412
|
+
if (layerArg === 'injection') {
|
|
413
|
+
newline();
|
|
414
|
+
console.log(colors.bold('codehere memory show injection'));
|
|
415
|
+
newline();
|
|
416
|
+
const r = buildMemoryPrefix(root);
|
|
417
|
+
if (r.prefix === '') {
|
|
418
|
+
console.log(` ${info('empty ')} nothing to inject from this cwd`);
|
|
419
|
+
console.log(` ${colors.dim('(no .codehere/ ancestor, or no AGENTS.md content / lessons / repo_map symbols / edited session)')}`);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
console.log(` ${success('would inject')} ${r.included.join(', ')} · ${r.tokens} tokens${r.truncated ? ' (TRUNCATED)' : ''}`);
|
|
423
|
+
newline();
|
|
424
|
+
console.log(colors.dim('─── prefix preview ────────────────────────────────────────────────'));
|
|
425
|
+
console.log(r.prefix.trimEnd());
|
|
426
|
+
console.log(colors.dim('─── end of prefix · user prompt would follow ──────────────────────'));
|
|
427
|
+
}
|
|
428
|
+
newline();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!existsSync(codehereDir)) {
|
|
432
|
+
newline();
|
|
433
|
+
console.log(` ${error('refused')} .codehere/ not found at this root.`);
|
|
434
|
+
console.log(` ${colors.dim('Run')} ${colors.cyan('codehere init')} ${colors.dim('first.')}`);
|
|
435
|
+
newline();
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// No-arg: overview of every layer.
|
|
440
|
+
if (!layerArg) {
|
|
441
|
+
newline();
|
|
442
|
+
console.log(colors.bold('codehere memory'));
|
|
443
|
+
console.log(colors.dim(` inspecting ${codehereDir}`));
|
|
444
|
+
newline();
|
|
445
|
+
const summary = gatherLayerSummary(codehereDir, root);
|
|
446
|
+
for (const s of summary) {
|
|
447
|
+
console.log(` ${s.line}`);
|
|
448
|
+
}
|
|
449
|
+
newline();
|
|
450
|
+
console.log(colors.dim(` Use 'codehere memory show <layer>' for details. Layers: ${SHOW_LAYERS.join(', ')}.`));
|
|
451
|
+
newline();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Per-layer detail.
|
|
455
|
+
const layer = layerArg;
|
|
456
|
+
if (!SHOW_LAYERS.includes(layer)) {
|
|
457
|
+
console.error(`\n ${error('failed')} unknown layer: ${layerArg}\n Valid layers: ${SHOW_LAYERS.join(', ')}\n`);
|
|
458
|
+
process.exitCode = 1;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
newline();
|
|
462
|
+
console.log(colors.bold(`codehere memory show ${layer}`));
|
|
463
|
+
newline();
|
|
464
|
+
switch (layer) {
|
|
465
|
+
case 'working': {
|
|
466
|
+
const path = join(codehereDir, 'memory/working/session.md');
|
|
467
|
+
if (!existsSync(path)) {
|
|
468
|
+
console.log(` ${info('empty ')} no session.md yet`);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
console.log(readFileSync(path, 'utf8').trimEnd());
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case 'episodic': {
|
|
476
|
+
const learnings = join(codehereDir, 'memory/episodic/AGENT_LEARNINGS.jsonl');
|
|
477
|
+
const repoMap = join(codehereDir, 'memory/episodic/repo_map.json');
|
|
478
|
+
if (isSymlink(learnings)) {
|
|
479
|
+
const target = readlinkSync(learnings);
|
|
480
|
+
console.log(` ${success('linked ')} AGENT_LEARNINGS.jsonl → ${target}`);
|
|
481
|
+
try {
|
|
482
|
+
const body = readFileSync(learnings, 'utf8');
|
|
483
|
+
const count = body.trim().length === 0 ? 0 : body.trim().split('\n').length;
|
|
484
|
+
console.log(` ${colors.dim(`(${count} session record${count === 1 ? '' : 's'})`)}`);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
console.log(` ${colors.dim('(unreadable)')}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else if (existsSync(learnings)) {
|
|
491
|
+
console.log(` ${warning('real-file')} AGENT_LEARNINGS.jsonl is a regular file, not a symlink to ~/.codehere/sessions.jsonl.`);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
console.log(` ${info('empty ')} no AGENT_LEARNINGS.jsonl yet`);
|
|
495
|
+
}
|
|
496
|
+
newline();
|
|
497
|
+
if (existsSync(repoMap)) {
|
|
498
|
+
try {
|
|
499
|
+
const m = JSON.parse(readFileSync(repoMap, 'utf8'));
|
|
500
|
+
const fileCount = Object.keys(m.files ?? {}).length;
|
|
501
|
+
const symbolCount = Object.values(m.files ?? {}).reduce((acc, f) => {
|
|
502
|
+
const symbols = f?.symbols;
|
|
503
|
+
return acc + (Array.isArray(symbols) ? symbols.length : 0);
|
|
504
|
+
}, 0);
|
|
505
|
+
const topCount = Array.isArray(m.topSymbols) ? m.topSymbols.length : 0;
|
|
506
|
+
console.log(` ${success('present')} repo_map.json — ${fileCount} files, ${symbolCount} symbols, top ${topCount} ranked`);
|
|
507
|
+
if (Array.isArray(m.topSymbols) && m.topSymbols.length > 0) {
|
|
508
|
+
console.log(` ${colors.dim('top 5:')}`);
|
|
509
|
+
for (const s of m.topSymbols.slice(0, 5)) {
|
|
510
|
+
console.log(` ${s.references}× ${s.name} ${colors.dim(`(${s.file}:${s.line})`)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
console.log(` ${error('failed ')} repo_map.json present but unparseable`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
console.log(` ${info('absent ')} no repo_map.json — run ${colors.cyan('codehere memory repo-map')}`);
|
|
520
|
+
}
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case 'semantic': {
|
|
524
|
+
const path = join(codehereDir, 'memory/semantic/lessons.jsonl');
|
|
525
|
+
if (!existsSync(path)) {
|
|
526
|
+
console.log(` ${info('empty ')} no lessons.jsonl yet`);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
const lines = readFileSync(path, 'utf8').trim().split('\n').filter(l => l.length > 0);
|
|
530
|
+
if (lines.length === 0) {
|
|
531
|
+
console.log(` ${info('empty ')} lessons.jsonl is empty — run ${colors.cyan('codehere memory graduate')} (coming in v0.5)`);
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
console.log(` ${success('present')} ${lines.length} lesson${lines.length === 1 ? '' : 's'}:`);
|
|
535
|
+
newline();
|
|
536
|
+
for (let i = 0; i < lines.length; i++) {
|
|
537
|
+
try {
|
|
538
|
+
const obj = JSON.parse(lines[i]);
|
|
539
|
+
const text = obj.text ?? obj.lesson ?? JSON.stringify(obj);
|
|
540
|
+
console.log(` ${(i + 1).toString().padStart(3)}. ${text}`);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
console.log(` ${(i + 1).toString().padStart(3)}. ${colors.dim('(unparseable JSON)')} ${lines[i].slice(0, 100)}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case 'personal': {
|
|
549
|
+
const path = join(codehereDir, 'memory/personal/preferences.json');
|
|
550
|
+
if (!existsSync(path)) {
|
|
551
|
+
console.log(` ${info('empty ')} no preferences.json yet`);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
console.log(readFileSync(path, 'utf8').trimEnd());
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case 'skills': {
|
|
558
|
+
const skillsDir = join(codehereDir, 'skills');
|
|
559
|
+
if (!existsSync(skillsDir)) {
|
|
560
|
+
console.log(` ${info('empty ')} no skills/ dir`);
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
564
|
+
.filter(e => e.isDirectory())
|
|
565
|
+
.filter(e => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
566
|
+
if (subdirs.length === 0) {
|
|
567
|
+
console.log(` ${info('empty ')} no skill folders yet (skills/<name>/SKILL.md)`);
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
console.log(` ${success('present')} ${subdirs.length} skill folder${subdirs.length === 1 ? '' : 's'}:`);
|
|
571
|
+
for (const d of subdirs) {
|
|
572
|
+
console.log(` - ${d.name}`);
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case 'protocols': {
|
|
577
|
+
const path = join(codehereDir, 'protocols/hook_patterns.json');
|
|
578
|
+
if (!existsSync(path)) {
|
|
579
|
+
console.log(` ${info('empty ')} no hook_patterns.json — run ${colors.cyan('codehere protocols export')}`);
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
584
|
+
const builtin = Array.isArray(parsed.patterns_builtin) ? parsed.patterns_builtin.length : 0;
|
|
585
|
+
const project = Array.isArray(parsed.patterns_project) ? parsed.patterns_project.length : 0;
|
|
586
|
+
console.log(` ${success('present')} ${builtin} built-in patterns + ${project} project patterns`);
|
|
587
|
+
if (parsed.patterns_builtin && parsed.patterns_builtin.length > 0) {
|
|
588
|
+
// Group by category
|
|
589
|
+
const byCategory = new Map();
|
|
590
|
+
for (const p of parsed.patterns_builtin) {
|
|
591
|
+
byCategory.set(p.category, (byCategory.get(p.category) ?? 0) + 1);
|
|
592
|
+
}
|
|
593
|
+
newline();
|
|
594
|
+
console.log(` ${colors.dim('by category:')}`);
|
|
595
|
+
for (const [cat, n] of [...byCategory.entries()].sort((a, b) => b[1] - a[1])) {
|
|
596
|
+
console.log(` ${n}× ${cat}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
console.log(` ${error('failed ')} hook_patterns.json present but unparseable`);
|
|
602
|
+
}
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
case 'injection': {
|
|
606
|
+
// Already handled in the early-return branch above (no .codehere/
|
|
607
|
+
// required for this layer). This case is unreachable but kept so the
|
|
608
|
+
// exhaustive-switch pattern doesn't drift.
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
newline();
|
|
613
|
+
}
|
|
614
|
+
function isSymlink(path) {
|
|
615
|
+
try {
|
|
616
|
+
return lstatSync(path).isSymbolicLink();
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Convert a section title to a filesystem-safe slug.
|
|
624
|
+
*
|
|
625
|
+
* "Implement a change" → "implement-a-change"
|
|
626
|
+
* "Refactor (old style) — careful" → "refactor-old-style-careful"
|
|
627
|
+
* " Trim spaces " → "trim-spaces"
|
|
628
|
+
*/
|
|
629
|
+
function slugify(title) {
|
|
630
|
+
return title
|
|
631
|
+
.toLowerCase()
|
|
632
|
+
.replace(/[^a-z0-9]+/g, '-') // any non-alphanumeric run → single hyphen
|
|
633
|
+
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
|
634
|
+
}
|
|
635
|
+
function parsePromptsSections(content) {
|
|
636
|
+
// CRLF-tolerant split (K2 review nit).
|
|
637
|
+
const lines = content.split(/\r?\n/);
|
|
638
|
+
const sections = [];
|
|
639
|
+
let current = null;
|
|
640
|
+
// Fence awareness — CommonMark-correct (codex Round 2 fix).
|
|
641
|
+
//
|
|
642
|
+
// Open: 3+ identical fence chars (` or ~), up to 3 leading spaces, optional
|
|
643
|
+
// info string after.
|
|
644
|
+
// Close: SAME fence char, >= opening length, up to 3 leading spaces, ONLY
|
|
645
|
+
// trailing whitespace allowed (no info string on closer).
|
|
646
|
+
//
|
|
647
|
+
// Without the length+whitespace constraint a line like `````oops``` would
|
|
648
|
+
// falsely close an opening ```` fence, and the parser would re-enable header
|
|
649
|
+
// matching mid-block — corrupting the migration.
|
|
650
|
+
let inFence = false;
|
|
651
|
+
let fenceChar = ''; // ` or ~
|
|
652
|
+
let fenceLength = 0; // number of fence chars on the opening line
|
|
653
|
+
for (const line of lines) {
|
|
654
|
+
// Try open: 3+ chars, then anything (info string allowed).
|
|
655
|
+
const openMatch = !inFence ? line.match(/^ {0,3}(`{3,}|~{3,})/) : null;
|
|
656
|
+
// Try close: SAME char, >= opening length, ONLY trailing whitespace.
|
|
657
|
+
const closeRegex = inFence
|
|
658
|
+
? new RegExp(`^ {0,3}(${fenceChar === '`' ? '`' : '~'}{${fenceLength},})[ \\t]*$`)
|
|
659
|
+
: null;
|
|
660
|
+
const closeMatch = inFence && closeRegex ? line.match(closeRegex) : null;
|
|
661
|
+
if (openMatch) {
|
|
662
|
+
inFence = true;
|
|
663
|
+
fenceChar = openMatch[1][0];
|
|
664
|
+
fenceLength = openMatch[1].length;
|
|
665
|
+
if (current)
|
|
666
|
+
current.body += line + '\n';
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (closeMatch) {
|
|
670
|
+
inFence = false;
|
|
671
|
+
fenceChar = '';
|
|
672
|
+
fenceLength = 0;
|
|
673
|
+
if (current)
|
|
674
|
+
current.body += line + '\n';
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (!inFence) {
|
|
678
|
+
const headerMatch = line.match(/^##\s+(.+?)\s*$/);
|
|
679
|
+
if (headerMatch) {
|
|
680
|
+
// Push the previous section before starting a new one.
|
|
681
|
+
if (current)
|
|
682
|
+
sections.push(current);
|
|
683
|
+
const title = headerMatch[1];
|
|
684
|
+
const slug = slugify(title);
|
|
685
|
+
// Skip sections whose title slugifies to empty (defensive — shouldn't
|
|
686
|
+
// happen with reasonable headers, but covers edge inputs).
|
|
687
|
+
if (slug.length > 0) {
|
|
688
|
+
current = { slug, title, body: '' };
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
current = null;
|
|
692
|
+
}
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (current) {
|
|
697
|
+
current.body += line + '\n';
|
|
698
|
+
}
|
|
699
|
+
// Lines before the first ## (intro) are dropped — that text belongs in
|
|
700
|
+
// skills/_index.md, which init already wrote.
|
|
701
|
+
}
|
|
702
|
+
if (current)
|
|
703
|
+
sections.push(current);
|
|
704
|
+
// Trim trailing blank lines from each section body.
|
|
705
|
+
for (const s of sections) {
|
|
706
|
+
s.body = s.body.replace(/\n+$/, '\n');
|
|
707
|
+
}
|
|
708
|
+
return sections;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Migrate a single source file to a new target path. Returns outcome string.
|
|
712
|
+
* Behavior:
|
|
713
|
+
* - source missing → 'missing'
|
|
714
|
+
* - source is already a symlink → 'already-migrated' (don't double-migrate)
|
|
715
|
+
* - target exists & not --force → 'skipped'
|
|
716
|
+
* - else → write target with source body, then
|
|
717
|
+
* delete source (or leave symlink)
|
|
718
|
+
*/
|
|
719
|
+
/**
|
|
720
|
+
* "Is this target file just the default F3 seed?" — true if its body is
|
|
721
|
+
* byte-identical to the seed init writes. Lets migration overwrite the
|
|
722
|
+
* placeholder without --force, while still respecting user edits.
|
|
723
|
+
*
|
|
724
|
+
* targetRelative is the path under .codehere (e.g. 'memory/working/session.md').
|
|
725
|
+
*/
|
|
726
|
+
function isDefaultSeed(targetRelative, targetAbsPath) {
|
|
727
|
+
const seed = DEFAULT_SEED_BODIES.get(targetRelative);
|
|
728
|
+
if (seed === undefined)
|
|
729
|
+
return false;
|
|
730
|
+
try {
|
|
731
|
+
return readFileSync(targetAbsPath, 'utf8') === seed;
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function migrateFile(codehereDir, sourcePath, targetRelative, symlinkRel, force, dryRun) {
|
|
738
|
+
const targetPath = join(codehereDir, targetRelative);
|
|
739
|
+
if (!existsSync(sourcePath) && !isSymlink(sourcePath))
|
|
740
|
+
return 'missing';
|
|
741
|
+
if (isSymlink(sourcePath))
|
|
742
|
+
return 'already-migrated';
|
|
743
|
+
// Target exists AND user has edited it AND not --force → skip.
|
|
744
|
+
// Target exists but is the default seed → safe to overwrite (placeholder).
|
|
745
|
+
if (existsSync(targetPath) && !force && !isDefaultSeed(targetRelative, targetPath)) {
|
|
746
|
+
return 'skipped';
|
|
747
|
+
}
|
|
748
|
+
// Dry-run: report what WOULD happen, touch nothing.
|
|
749
|
+
if (dryRun)
|
|
750
|
+
return 'migrated';
|
|
751
|
+
const body = readFileSync(sourcePath, 'utf8');
|
|
752
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
753
|
+
writeFileSync(targetPath, body);
|
|
754
|
+
rmSync(sourcePath);
|
|
755
|
+
if (symlinkRel !== null) {
|
|
756
|
+
symlinkSync(symlinkRel, sourcePath);
|
|
757
|
+
}
|
|
758
|
+
return 'migrated';
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Migrate PROMPTS.md → skills/<slug>/SKILL.md per section.
|
|
762
|
+
* Returns outcome + count of skill folders created.
|
|
763
|
+
*/
|
|
764
|
+
function migratePrompts(codehereDir, force, noSymlinks, dryRun) {
|
|
765
|
+
const sourcePath = join(codehereDir, 'PROMPTS.md');
|
|
766
|
+
if (!existsSync(sourcePath) && !isSymlink(sourcePath)) {
|
|
767
|
+
return { outcome: 'missing', skillsCreated: 0, legacyFallback: false };
|
|
768
|
+
}
|
|
769
|
+
if (isSymlink(sourcePath)) {
|
|
770
|
+
return { outcome: 'already-migrated', skillsCreated: 0, legacyFallback: false };
|
|
771
|
+
}
|
|
772
|
+
const body = readFileSync(sourcePath, 'utf8');
|
|
773
|
+
const sections = parsePromptsSections(body);
|
|
774
|
+
// Fallback: if no ## sections, preserve the whole file as skills/_legacy.md.
|
|
775
|
+
if (sections.length === 0) {
|
|
776
|
+
const legacyTarget = join(codehereDir, 'skills/_legacy.md');
|
|
777
|
+
if (existsSync(legacyTarget) && !force) {
|
|
778
|
+
return { outcome: 'skipped', skillsCreated: 0, legacyFallback: true };
|
|
779
|
+
}
|
|
780
|
+
if (dryRun)
|
|
781
|
+
return { outcome: 'migrated', skillsCreated: 0, legacyFallback: true };
|
|
782
|
+
mkdirSync(dirname(legacyTarget), { recursive: true });
|
|
783
|
+
writeFileSync(legacyTarget, body);
|
|
784
|
+
rmSync(sourcePath);
|
|
785
|
+
if (!noSymlinks)
|
|
786
|
+
symlinkSync('skills/_legacy.md', sourcePath);
|
|
787
|
+
return { outcome: 'migrated', skillsCreated: 0, legacyFallback: true };
|
|
788
|
+
}
|
|
789
|
+
// Split path: write each section into skills/<slug>/SKILL.md.
|
|
790
|
+
// Conflict policy: if ANY target SKILL.md exists and not --force, abort the
|
|
791
|
+
// PROMPTS migration entirely (don't half-write). This preserves user state.
|
|
792
|
+
for (const s of sections) {
|
|
793
|
+
const target = join(codehereDir, 'skills', s.slug, 'SKILL.md');
|
|
794
|
+
if (existsSync(target) && !force) {
|
|
795
|
+
return { outcome: 'skipped', skillsCreated: 0, legacyFallback: false };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (dryRun) {
|
|
799
|
+
return { outcome: 'migrated', skillsCreated: sections.length, legacyFallback: false };
|
|
800
|
+
}
|
|
801
|
+
let count = 0;
|
|
802
|
+
for (const s of sections) {
|
|
803
|
+
const target = join(codehereDir, 'skills', s.slug, 'SKILL.md');
|
|
804
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
805
|
+
// Body is the raw section content. Don't re-add a `# Title` because the
|
|
806
|
+
// SKILL.md convention is to be focused-content; the slug folder itself
|
|
807
|
+
// names the skill.
|
|
808
|
+
writeFileSync(target, s.body);
|
|
809
|
+
count++;
|
|
810
|
+
}
|
|
811
|
+
rmSync(sourcePath);
|
|
812
|
+
if (!noSymlinks) {
|
|
813
|
+
// Symlink old path → skills/_index.md (the navigable entry point that
|
|
814
|
+
// codehere init already wrote, explaining where skills now live).
|
|
815
|
+
symlinkSync('skills/_index.md', sourcePath);
|
|
816
|
+
}
|
|
817
|
+
return { outcome: 'migrated', skillsCreated: count, legacyFallback: false };
|
|
818
|
+
}
|
|
819
|
+
function executeMemoryInit(options) {
|
|
820
|
+
const info_ = findCodebaseRoot();
|
|
821
|
+
const root = info_?.rootPath ?? process.cwd();
|
|
822
|
+
const codehereDir = join(root, '.codehere');
|
|
823
|
+
newline();
|
|
824
|
+
console.log(colors.bold('codehere memory init'));
|
|
825
|
+
console.log(colors.dim(` migrating .codehere/ at ${root}`));
|
|
826
|
+
newline();
|
|
827
|
+
if (!existsSync(codehereDir)) {
|
|
828
|
+
console.log(` ${error('refused')} .codehere/ not found at this root.`);
|
|
829
|
+
console.log(` ${colors.dim('Run')} ${colors.cyan('codehere init')} ${colors.dim('first to scaffold the layout, then re-run.')}`);
|
|
830
|
+
newline();
|
|
831
|
+
process.exitCode = 1;
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const force = !!options.force;
|
|
835
|
+
const noSymlinks = !!options.noSymlinks;
|
|
836
|
+
const dryRun = !!options.dryRun;
|
|
837
|
+
if (dryRun) {
|
|
838
|
+
console.log(` ${colors.dim('[--dry-run]')} no files will be touched · planned moves only`);
|
|
839
|
+
newline();
|
|
840
|
+
}
|
|
841
|
+
// ── CONTEXT.md → AGENTS.md ──────────────────────────────────────────────
|
|
842
|
+
const contextOutcome = migrateFile(codehereDir, join(codehereDir, 'CONTEXT.md'), 'AGENTS.md', noSymlinks ? null : 'AGENTS.md', force, dryRun);
|
|
843
|
+
// ── HANDOVER.md → memory/working/session.md ─────────────────────────────
|
|
844
|
+
const handoverOutcome = migrateFile(codehereDir, join(codehereDir, 'HANDOVER.md'), 'memory/working/session.md', noSymlinks ? null : 'memory/working/session.md', force, dryRun);
|
|
845
|
+
// ── PROMPTS.md → skills/<slug>/SKILL.md ─────────────────────────────────
|
|
846
|
+
const promptsResult = migratePrompts(codehereDir, force, noSymlinks, dryRun);
|
|
847
|
+
// ── F3 Task 3 — episodic AGENT_LEARNINGS.jsonl bridge ───────────────────
|
|
848
|
+
// Even v0.4.1 users who skipped re-running `codehere init` get the bridge
|
|
849
|
+
// wired so any future agent spawn can read their full session history.
|
|
850
|
+
// (--dry-run inhibits the actual symlink write but we still report it.)
|
|
851
|
+
const homePath = process.env.CODEHERE_HOME || homedir();
|
|
852
|
+
let episodicResult = null;
|
|
853
|
+
if (!dryRun) {
|
|
854
|
+
episodicResult = ensureEpisodicSymlink(codehereDir, homePath);
|
|
855
|
+
}
|
|
856
|
+
// ── report ──────────────────────────────────────────────────────────────
|
|
857
|
+
const report = (label, outcome, extra = '') => {
|
|
858
|
+
switch (outcome) {
|
|
859
|
+
case 'migrated':
|
|
860
|
+
console.log(` ${success('migrated')} ${label}${extra ? ' ' + colors.dim(extra) : ''}`);
|
|
861
|
+
break;
|
|
862
|
+
case 'skipped':
|
|
863
|
+
console.log(` ${warning('skipped ')} ${label} ${colors.dim('(target already exists — use --force)')}`);
|
|
864
|
+
break;
|
|
865
|
+
case 'missing':
|
|
866
|
+
console.log(` ${info('skipped ')} ${label} ${colors.dim('(source not found)')}`);
|
|
867
|
+
break;
|
|
868
|
+
case 'already-migrated':
|
|
869
|
+
console.log(` ${info('exists ')} ${label} ${colors.dim('(already migrated — symlink in place)')}`);
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
report('CONTEXT.md → AGENTS.md', contextOutcome);
|
|
874
|
+
report('HANDOVER.md → memory/working/session.md', handoverOutcome);
|
|
875
|
+
// PROMPTS.md gets a richer report because of the split semantics.
|
|
876
|
+
if (promptsResult.outcome === 'migrated') {
|
|
877
|
+
if (promptsResult.legacyFallback) {
|
|
878
|
+
console.log(` ${success('migrated')} PROMPTS.md → skills/_legacy.md ${colors.dim('(no ^## sections found — preserved as-is for manual split)')}`);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
console.log(` ${success('migrated')} PROMPTS.md → ${promptsResult.skillsCreated} skill folder${promptsResult.skillsCreated === 1 ? '' : 's'} ${colors.dim('(skills/<slug>/SKILL.md per ^## section)')}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
report('PROMPTS.md → skills/', promptsResult.outcome);
|
|
886
|
+
}
|
|
887
|
+
// F3 Task 3 — episodic symlink result.
|
|
888
|
+
if (episodicResult) {
|
|
889
|
+
switch (episodicResult.result) {
|
|
890
|
+
case 'created':
|
|
891
|
+
console.log(` ${success('linked ')} memory/episodic/AGENT_LEARNINGS.jsonl → ${episodicResult.targetPath}`);
|
|
892
|
+
break;
|
|
893
|
+
case 're-linked':
|
|
894
|
+
console.log(` ${success('re-linked')} memory/episodic/AGENT_LEARNINGS.jsonl → ${episodicResult.targetPath}`);
|
|
895
|
+
break;
|
|
896
|
+
case 'refused':
|
|
897
|
+
console.log(` ${error('refused ')} memory/episodic/AGENT_LEARNINGS.jsonl ${colors.dim('(real file or symlinked ancestor — remove first)')}`);
|
|
898
|
+
break;
|
|
899
|
+
case 'root-skipped':
|
|
900
|
+
console.log(` ${warning('skipped ')} episodic symlink ${colors.dim('(running as root — re-run as your normal user)')}`);
|
|
901
|
+
break;
|
|
902
|
+
case 'failed':
|
|
903
|
+
console.log(` ${warning('skipped ')} episodic symlink ${colors.dim('(HOME read-only?)')}`);
|
|
904
|
+
break;
|
|
905
|
+
// 'exists' is intentionally silent on memory init too.
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Idempotency hint when nothing was actually migrated.
|
|
909
|
+
const allOutcomes = [contextOutcome, handoverOutcome, promptsResult.outcome];
|
|
910
|
+
const anyMigrated = allOutcomes.some(o => o === 'migrated');
|
|
911
|
+
const allAlreadyOrMissing = allOutcomes.every(o => o === 'already-migrated' || o === 'missing');
|
|
912
|
+
if (!anyMigrated && allAlreadyOrMissing) {
|
|
913
|
+
newline();
|
|
914
|
+
console.log(` ${info('no-op')} everything already migrated or no source files to migrate.`);
|
|
915
|
+
}
|
|
916
|
+
newline();
|
|
917
|
+
console.log(colors.bold('Next:'));
|
|
918
|
+
console.log(` 1. Open ${colors.cyan('.codehere/AGENTS.md')} — your project briefing (renamed from CONTEXT.md)`);
|
|
919
|
+
console.log(` 2. Edit ${colors.cyan('.codehere/memory/working/session.md')} as you work — supersedes HANDOVER.md`);
|
|
920
|
+
console.log(` 3. Each ${colors.cyan('.codehere/skills/<name>/SKILL.md')} is a task-shape template — copy and adapt`);
|
|
921
|
+
if (!noSymlinks && anyMigrated) {
|
|
922
|
+
console.log(` ${colors.dim('(Old paths kept as symlinks for back-compat; safe to delete once teammates have pulled.)')}`);
|
|
923
|
+
}
|
|
924
|
+
newline();
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* F3 Task 4 — build the project's repo_map.json.
|
|
928
|
+
*
|
|
929
|
+
* Emits .codehere/memory/episodic/repo_map.json — a top-level symbol map
|
|
930
|
+
* (functions, classes, etc.) with reference counts, ranked. Spawned agents
|
|
931
|
+
* can read this once on entry instead of cold-grepping the codebase.
|
|
932
|
+
*
|
|
933
|
+
* v0.5 ships TS-native regex extraction (TS/JS/Python). v0.6 swaps in
|
|
934
|
+
* proper tree-sitter parsing. The schema is forward-compatible.
|
|
935
|
+
*/
|
|
936
|
+
function executeMemoryRepoMap(options) {
|
|
937
|
+
const codeInfo = findCodebaseRoot();
|
|
938
|
+
const root = codeInfo?.rootPath ?? process.cwd();
|
|
939
|
+
const codehereDir = join(root, '.codehere');
|
|
940
|
+
newline();
|
|
941
|
+
console.log(colors.bold('codehere memory repo-map'));
|
|
942
|
+
console.log(colors.dim(` scanning ${root}`));
|
|
943
|
+
newline();
|
|
944
|
+
if (!existsSync(codehereDir)) {
|
|
945
|
+
console.log(` ${error('refused')} .codehere/ not found at this root.`);
|
|
946
|
+
console.log(` ${colors.dim('Run')} ${colors.cyan('codehere init')} ${colors.dim('first.')}`);
|
|
947
|
+
newline();
|
|
948
|
+
process.exitCode = 1;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const result = writeRepoMap(root, codehereDir, { rebuild: !!options.rebuild });
|
|
952
|
+
switch (result.result) {
|
|
953
|
+
case 'written': {
|
|
954
|
+
const skipNote = result.skippedLargeFiles && result.skippedLargeFiles > 0
|
|
955
|
+
? ` · ${result.skippedLargeFiles} file${result.skippedLargeFiles === 1 ? '' : 's'} skipped (>1MB)`
|
|
956
|
+
: '';
|
|
957
|
+
console.log(` ${success('written ')} ${result.path} ${colors.dim(`(${result.fileCount} files · ${result.symbolCount} symbols${skipNote})`)}`);
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
case 'exists':
|
|
961
|
+
console.log(` ${info('exists ')} ${result.path} ${colors.dim('(use --rebuild to overwrite)')}`);
|
|
962
|
+
break;
|
|
963
|
+
case 'failed':
|
|
964
|
+
console.log(` ${error('failed ')} ${result.path} ${colors.dim(`(${result.error})`)}`);
|
|
965
|
+
process.exitCode = 1;
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
newline();
|
|
969
|
+
console.log(colors.bold('Next:'));
|
|
970
|
+
console.log(` - Spawned agents now read this file as part of project context`);
|
|
971
|
+
console.log(` - Re-run ${colors.cyan('codehere memory repo-map --rebuild')} after large refactors`);
|
|
972
|
+
console.log(` ${colors.dim('(v0.6 will refresh incrementally on file change via the per-file hash.)')}`);
|
|
973
|
+
newline();
|
|
974
|
+
}
|
|
975
|
+
export function executeMemoryCommand(action, options = {}) {
|
|
976
|
+
try {
|
|
977
|
+
switch (action) {
|
|
978
|
+
case 'init':
|
|
979
|
+
executeMemoryInit(options);
|
|
980
|
+
return;
|
|
981
|
+
case 'repo-map':
|
|
982
|
+
executeMemoryRepoMap(options);
|
|
983
|
+
return;
|
|
984
|
+
case 'show':
|
|
985
|
+
executeMemoryShow(options.layer);
|
|
986
|
+
return;
|
|
987
|
+
case 'graduate':
|
|
988
|
+
executeMemoryGraduate(options);
|
|
989
|
+
return;
|
|
990
|
+
case undefined:
|
|
991
|
+
case 'help':
|
|
992
|
+
console.log(`
|
|
993
|
+
codehere memory <action> — F3 project memory operations
|
|
994
|
+
|
|
995
|
+
init Migrate v0.4.1 CONTEXT/HANDOVER/PROMPTS into the F3 layout
|
|
996
|
+
repo-map Build .codehere/memory/episodic/repo_map.json (symbol map)
|
|
997
|
+
show [layer] Render any memory layer (working / episodic / semantic /
|
|
998
|
+
personal / skills / protocols / injection)
|
|
999
|
+
graduate Promote patterns into project-wide lessons:
|
|
1000
|
+
--add "<text>" add free-text lesson directly
|
|
1001
|
+
--accept <id> graduate a candidate
|
|
1002
|
+
--reject <id> delete a candidate without graduating
|
|
1003
|
+
(no flag) list any candidates pending review
|
|
1004
|
+
`);
|
|
1005
|
+
return;
|
|
1006
|
+
default:
|
|
1007
|
+
console.error(`\n ${error('failed')} unknown memory action: ${action}\n Try: codehere memory help\n`);
|
|
1008
|
+
process.exitCode = 1;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
catch (e) {
|
|
1012
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1013
|
+
console.error(`\n ${error('failed')} codehere memory ${action ?? ''} — ${msg}\n`);
|
|
1014
|
+
process.exitCode = 1;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
//# sourceMappingURL=memory-command.js.map
|