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.
@@ -2,15 +2,35 @@
2
2
  * Init Command — scaffold a `.codehere/` directory at the repo root.
3
3
  *
4
4
  * The user-side analog to `git init`: gives the project a small, conventional
5
- * shape codehere can read from when an agent runs here. Three files:
5
+ * shape codehere can read from when an agent runs here.
6
6
  *
7
+ * v0.3.2 surface (the original "three files"):
7
8
  * .codehere/CONTEXT.md — what this repo is, conventions, constraints
8
9
  * .codehere/HANDOVER.md — manual cross-session continuity (until F3 ships)
9
10
  * .codehere/PROMPTS.md — your library of recurring task prompts
10
11
  *
12
+ * v0.5 F3 surface — the four-layer memory tree per docs/design/f3_memory_spec.md:
13
+ * .codehere/memory/working/ — current-session scratch (gitignored)
14
+ * .codehere/memory/episodic/ — execution record (committable)
15
+ * .codehere/memory/semantic/ — graduated lessons (committed)
16
+ * .codehere/memory/personal/ — per-developer prefs (gitignored)
17
+ * .codehere/memory/candidates/ — staged patterns awaiting graduate (gitignored)
18
+ * .codehere/skills/ — task-shape library (committed)
19
+ * .codehere/protocols/ — safety + tool config (committed)
20
+ *
21
+ * F3 Task 1 = scaffold these directories + minimal seed files. Migration of
22
+ * the existing CONTEXT.md/HANDOVER.md/PROMPTS.md INTO the new layout is Task 2
23
+ * (`codehere memory init`). Until then, both layouts coexist — the v0.4.1
24
+ * files stay where they are; the F3 layout is added alongside.
25
+ *
26
+ * HOME is overridable via `CODEHERE_HOME` for test isolation (so the test
27
+ * suite never writes to the real user's home).
28
+ *
11
29
  * Conventions:
12
30
  * - CONTEXT.md and PROMPTS.md are committed (project config, shared)
13
31
  * - HANDOVER.md is git-ignored by default (per-machine working state)
32
+ * - F3 working/, personal/, candidates/ are git-ignored (per-machine state)
33
+ * - F3 episodic/, semantic/, skills/, protocols/ are committable
14
34
  *
15
35
  * Targets the repo root (via findCodebaseRoot), not cwd, so `cd src && codehere init`
16
36
  * still scaffolds at the repo root, not inside src/.
@@ -19,8 +39,9 @@
19
39
  * Refuses to follow symlinks (lstat check) so a hostile/misplaced symlink at
20
40
  * `.codehere/CONTEXT.md` or `.gitignore` cannot redirect writes outside the repo.
21
41
  */
22
- import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, lstatSync, statSync } from 'fs';
23
- import { join } from 'path';
42
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, lstatSync, statSync, symlinkSync, readlinkSync, rmSync } from 'fs';
43
+ import { homedir } from 'os';
44
+ import { join, dirname } from 'path';
24
45
  import { colors, success, info, warning, error, newline } from '../../../ui.js';
25
46
  import { findCodebaseRoot } from '../../../domain/services/codebase-detector.js';
26
47
  const TEMPLATES = [
@@ -121,8 +142,116 @@ Things you've been burned by.]
121
142
  `,
122
143
  },
123
144
  ];
145
+ /**
146
+ * F3 v0.5 seed files — coexist with the v0.4.1 TEMPLATES above.
147
+ *
148
+ * Bodies are deliberately minimal — these are scaffolds, not docs. The richer
149
+ * content lands when memory accumulates (episodic auto-fill from sessions,
150
+ * semantic graduate via `codehere memory graduate`, skills authored by hand).
151
+ *
152
+ * EXPORTED so `memory-command.ts` can recognize "this file is still the
153
+ * default seed (user hasn't edited)" and overwrite it during migration —
154
+ * without that check, an init run before memory init would block migration
155
+ * (target exists ≠ user-edited target exists).
156
+ */
157
+ export const F3_TEMPLATES = [
158
+ {
159
+ filename: 'memory/working/session.md',
160
+ body: `# Working session
161
+
162
+ > Per-machine, gitignored. What's in flight right now.
163
+ >
164
+ > codehere reads this on agent spawn. Keep it short.
165
+ > When the session ends, optionally graduate useful patterns into
166
+ > \`memory/semantic/lessons.jsonl\` via \`codehere memory graduate\`.
167
+
168
+ ## In flight
169
+
170
+ - [ ]
171
+
172
+ ## Decisions made this session
173
+
174
+ -
175
+
176
+ ## Files touched
177
+
178
+ -
179
+ `,
180
+ },
181
+ {
182
+ filename: 'memory/semantic/lessons.jsonl',
183
+ body: '',
184
+ },
185
+ {
186
+ filename: 'memory/personal/preferences.json',
187
+ body: '{}\n',
188
+ },
189
+ {
190
+ filename: 'skills/_index.md',
191
+ body: `# Skills
192
+
193
+ > One folder per skill. Each \`<skill-name>/SKILL.md\` is a task-shape
194
+ > template — the prompt formula plus example inputs/outputs.
195
+ >
196
+ > Migrating from the flat \`PROMPTS.md\`? Run \`codehere memory init\` (Task 2
197
+ > in the F3 spec). Until then both formats coexist — agents read whichever
198
+ > is present.
199
+ `,
200
+ },
201
+ {
202
+ filename: 'skills/_manifest.jsonl',
203
+ body: '',
204
+ },
205
+ {
206
+ filename: 'protocols/permissions.md',
207
+ body: `# Permissions
208
+
209
+ > Human-readable allow/deny policy. The codehere PreToolUse hook reads
210
+ > \`hook_patterns.json\` next to this file for the machine-readable rules;
211
+ > this Markdown is the rationale and exceptions.
212
+
213
+ ## Default policy
214
+
215
+ [Allow: read, build, test. Confirm: write, network. Deny: secret access.]
216
+
217
+ ## Project-specific exceptions
218
+
219
+ -
220
+ `,
221
+ },
222
+ {
223
+ filename: 'protocols/hook_patterns.json',
224
+ body: `{
225
+ "version": "v0.5",
226
+ "patterns_builtin": [],
227
+ "patterns_project": [],
228
+ "_note": "Run 'codehere protocols export' to populate patterns_builtin[] from the trust-scorer pattern bank. Add project-specific patterns to patterns_project[] — those are preserved across re-exports. Do NOT hand-edit patterns_builtin[] — it's regenerated on each export."
229
+ }
230
+ `,
231
+ },
232
+ ];
233
+ /**
234
+ * Subdirectories created blindly (regardless of seed files). These ensure
235
+ * the F3 memory tree is fully present even before the first auto-populated
236
+ * artifact lands (e.g. episodic/AGENT_LEARNINGS.jsonl is a Task-3 symlink,
237
+ * not seeded here).
238
+ */
239
+ const F3_DIRECTORIES = [
240
+ 'memory/working',
241
+ 'memory/episodic',
242
+ 'memory/semantic',
243
+ 'memory/personal',
244
+ 'memory/candidates',
245
+ 'skills',
246
+ 'protocols',
247
+ ];
124
248
  const GITIGNORE_MARKER = '# Added by codehere init';
125
- const GITIGNORE_BLOCK = `\n${GITIGNORE_MARKER}\n.codehere/HANDOVER.md\n`;
249
+ const GITIGNORE_LINES = [
250
+ '.codehere/HANDOVER.md',
251
+ '.codehere/memory/working/',
252
+ '.codehere/memory/personal/',
253
+ '.codehere/memory/candidates/',
254
+ ];
126
255
  /**
127
256
  * Resolve the directory to scaffold. Prefers git/package root over cwd so
128
257
  * `cd repo/src && codehere init` still lands at repo/.
@@ -133,6 +262,14 @@ function resolveTargetRoot() {
133
262
  return { root: info.rootPath, type: info.type };
134
263
  return { root: process.cwd(), type: 'directory' };
135
264
  }
265
+ /**
266
+ * Resolve the user "home" for HOME-scoped state. Override-able via
267
+ * `CODEHERE_HOME` for test isolation (so test suites never write to the real
268
+ * user's home).
269
+ */
270
+ function resolveHome() {
271
+ return process.env.CODEHERE_HOME || homedir();
272
+ }
136
273
  /**
137
274
  * Reject a path that exists AND is a symlink. Refusing on symlinks (rather
138
275
  * than following) prevents an init run from clobbering files outside the
@@ -146,18 +283,151 @@ function isSymlink(path) {
146
283
  return false;
147
284
  }
148
285
  }
286
+ /**
287
+ * Walk every intermediate ancestor BELOW codehereDir up to (but not
288
+ * including) the leaf, refusing if any segment is a symlink. The outer
289
+ * .codehere symlink check in executeInitCommand handles codehereDir itself;
290
+ * the leaf is checked separately by writeTemplate / the F3_DIRECTORIES loop.
291
+ *
292
+ * Without this, an attacker can plant
293
+ * `.codehere/memory` as a symlink to `/etc` BEFORE init runs, and the
294
+ * subsequent `mkdirSync({ recursive: true })` + `writeFileSync` would happily
295
+ * follow it — bypassing the leaf-level symlink check that only guards the
296
+ * final filename.
297
+ *
298
+ * Caught by codex Round 1 review of F3 Task 1: the original v0.3.2 init
299
+ * only had one level of nesting (.codehere/CONTEXT.md), so checking
300
+ * .codehere itself + the leaf was enough. Nested F3 paths
301
+ * (memory/working/session.md, protocols/permissions.md, ...) need every
302
+ * intermediate dir checked.
303
+ *
304
+ * Returns the offending segment if any is a symlink, or null if the path is
305
+ * safe to mkdir/write into.
306
+ */
307
+ function findSymlinkAncestor(codehereDir, relativePath) {
308
+ // Split on both POSIX and Windows separators (codex Round 2 fix). Without
309
+ // this, on Windows where `sep === '\\'`, the slash-delimited literals our
310
+ // callers pass (e.g. 'memory/episodic/AGENT_LEARNINGS.jsonl') would yield a
311
+ // single-segment array, the loop would never iterate, and the entire
312
+ // ancestor-symlink defense would silently bypass. POSIX-only is the stated
313
+ // assumption, but defending the security check against the easy
314
+ // platform-separator footgun costs one regex.
315
+ const segments = relativePath.split(/[/\\]/);
316
+ // Defensive: reject any relative path containing .. — even though templates
317
+ // are hard-coded, this protects against future schema additions slipping a
318
+ // traversal through.
319
+ if (segments.some(seg => seg === '..' || seg === '')) {
320
+ return relativePath;
321
+ }
322
+ // Walk codehereDir, codehereDir/seg1, codehereDir/seg1/seg2, ... (excluding
323
+ // the final leaf — writeTemplate handles the leaf separately).
324
+ let current = codehereDir;
325
+ for (let i = 0; i < segments.length - 1; i++) {
326
+ current = join(current, segments[i]);
327
+ if (isSymlink(current))
328
+ return current;
329
+ }
330
+ return null;
331
+ }
149
332
  function ensureGitignore(repoRoot) {
150
333
  const gitDir = join(repoRoot, '.git');
151
334
  if (!existsSync(gitDir))
152
- return 'no-git';
335
+ return { result: 'no-git', addedLines: 0 };
153
336
  const gitignorePath = join(repoRoot, '.gitignore');
154
337
  if (isSymlink(gitignorePath))
155
- return 'refused-symlink';
338
+ return { result: 'refused-symlink', addedLines: 0 };
156
339
  const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
157
- if (existing.includes('.codehere/HANDOVER.md'))
158
- return 'already-present';
159
- appendFileSync(gitignorePath, GITIGNORE_BLOCK);
160
- return 'added';
340
+ // De-duplicate: only append lines that aren't already present.
341
+ const missing = GITIGNORE_LINES.filter(line => !existing.includes(line));
342
+ if (missing.length === 0)
343
+ return { result: 'already-present', addedLines: 0 };
344
+ const block = (existing.length > 0 && !existing.endsWith('\n') ? '\n' : '') +
345
+ `\n${GITIGNORE_MARKER}\n` +
346
+ missing.join('\n') + '\n';
347
+ appendFileSync(gitignorePath, block);
348
+ return { result: 'updated', addedLines: missing.length };
349
+ }
350
+ /**
351
+ * F3 Task 3 — bridge `.codehere/memory/episodic/AGENT_LEARNINGS.jsonl`
352
+ * to the user-scoped `~/.codehere/sessions.jsonl` execution log via a
353
+ * symlink. Lets any spawned agent read the project's full execution
354
+ * history through a single conventional path under .codehere/.
355
+ *
356
+ * Behavior:
357
+ * - source already a symlink pointing at the correct target → 'exists'
358
+ * - source already a symlink pointing elsewhere → re-link → 're-linked'
359
+ * (handles HOME-moved scenarios, fresh clone on a new machine, etc.)
360
+ * - source is a real file → 'refused' (DO NOT clobber user data)
361
+ * - target file missing → touch it (zero-byte; the real session log
362
+ * accumulates from agent runs)
363
+ * - else → create symlink → 'created'
364
+ *
365
+ * The symlink target is ABSOLUTE because the source lives inside the repo
366
+ * and the target lives in HOME — a relative path would be a long, fragile
367
+ * climb across many parent directories.
368
+ */
369
+ export function ensureEpisodicSymlink(codehereDir, home) {
370
+ const sourcePath = join(codehereDir, 'memory/episodic/AGENT_LEARNINGS.jsonl');
371
+ const targetPath = join(home, '.codehere/sessions.jsonl');
372
+ // K2 Round 1 review fix — refuse if running as root. Otherwise this would
373
+ // touch ~/.codehere/sessions.jsonl as root, leaving a root-owned file the
374
+ // user's normal codehere invocation can't read or rotate.
375
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
376
+ return { result: 'root-skipped', sourcePath, targetPath };
377
+ }
378
+ // Codex Round 1 BLOCKER fix — same nested-symlink defense Task 1 applied
379
+ // to writeTemplate / F3_DIRECTORIES. Without this, an attacker plants
380
+ // `.codehere/memory` → `/etc` and `mkdirSync({recursive:true})` followed by
381
+ // `symlinkSync` would happily write outside the repo. Reuses the helper.
382
+ if (findSymlinkAncestor(codehereDir, 'memory/episodic/AGENT_LEARNINGS.jsonl') !== null) {
383
+ return { result: 'refused', sourcePath, targetPath };
384
+ }
385
+ try {
386
+ // 1. Refuse if source is a real file (would silently destroy user content).
387
+ if (existsSync(sourcePath) && !isSymlink(sourcePath)) {
388
+ return { result: 'refused', sourcePath, targetPath };
389
+ }
390
+ // 2. Already a symlink — check its target.
391
+ let wasReLinked = false;
392
+ if (isSymlink(sourcePath)) {
393
+ const current = readlinkSync(sourcePath);
394
+ if (current === targetPath) {
395
+ return { result: 'exists', sourcePath, targetPath };
396
+ }
397
+ // Wrong target — re-link. Track so we report 're-linked', not 'created'.
398
+ // (Codex Round 1 fix — the previous version returned 'created' here,
399
+ // making the documented 're-linked' outcome unreachable.)
400
+ rmSync(sourcePath);
401
+ wasReLinked = true;
402
+ }
403
+ // 3. Ensure target file exists (touch zero-byte if missing).
404
+ mkdirSync(dirname(targetPath), { recursive: true });
405
+ if (!existsSync(targetPath))
406
+ writeFileSync(targetPath, '');
407
+ // 4. Ensure source's parent dir exists (F3_DIRECTORIES creates this in
408
+ // init, but memory-command may call us before/without that path).
409
+ mkdirSync(dirname(sourcePath), { recursive: true });
410
+ // 5. Create the symlink with an absolute target.
411
+ symlinkSync(targetPath, sourcePath);
412
+ return { result: wasReLinked ? 're-linked' : 'created', sourcePath, targetPath };
413
+ }
414
+ catch {
415
+ return { result: 'failed', sourcePath, targetPath };
416
+ }
417
+ }
418
+ function writeTemplate(codehereDir, tmpl, force) {
419
+ const target = join(codehereDir, tmpl.filename);
420
+ // Refuse if ANY ancestor under .codehere/ is a symlink (codex Round 1 fix).
421
+ if (findSymlinkAncestor(codehereDir, tmpl.filename) !== null)
422
+ return 'refused';
423
+ if (isSymlink(target))
424
+ return 'refused';
425
+ if (existsSync(target) && !force)
426
+ return 'skipped';
427
+ // Ensure parent dir for nested seeds. Safe now that ancestors are verified.
428
+ mkdirSync(dirname(target), { recursive: true });
429
+ writeFileSync(target, tmpl.body);
430
+ return 'created';
161
431
  }
162
432
  export function executeInitCommand(options = {}) {
163
433
  try {
@@ -186,21 +456,41 @@ export function executeInitCommand(options = {}) {
186
456
  return;
187
457
  }
188
458
  }
459
+ // F3 layout: ensure all directories exist before writing any seeds.
460
+ // Per-segment symlink check on EACH directory's ancestors; if .codehere/memory
461
+ // (or skills, or protocols, etc.) is a symlink, refuse to mkdir into it.
462
+ const refusedDirs = [];
463
+ for (const dir of F3_DIRECTORIES) {
464
+ const offending = findSymlinkAncestor(codehereDir, dir);
465
+ const fullPath = join(codehereDir, dir);
466
+ if (offending !== null || isSymlink(fullPath)) {
467
+ refusedDirs.push(dir);
468
+ continue;
469
+ }
470
+ mkdirSync(fullPath, { recursive: true });
471
+ }
189
472
  const created = [];
190
473
  const skipped = [];
191
474
  const refused = [];
475
+ // v0.4.1 templates first (CONTEXT/HANDOVER/PROMPTS at the top level).
192
476
  for (const tmpl of TEMPLATES) {
193
- const target = join(codehereDir, tmpl.filename);
194
- if (isSymlink(target)) {
477
+ const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
478
+ if (outcome === 'created')
479
+ created.push(tmpl.filename);
480
+ else if (outcome === 'skipped')
481
+ skipped.push(tmpl.filename);
482
+ else
195
483
  refused.push(tmpl.filename);
196
- continue;
197
- }
198
- if (existsSync(target) && !options.force) {
484
+ }
485
+ // F3 v0.5 seed files (memory layers + skills + protocols).
486
+ for (const tmpl of F3_TEMPLATES) {
487
+ const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
488
+ if (outcome === 'created')
489
+ created.push(tmpl.filename);
490
+ else if (outcome === 'skipped')
199
491
  skipped.push(tmpl.filename);
200
- continue;
201
- }
202
- writeFileSync(target, tmpl.body);
203
- created.push(tmpl.filename);
492
+ else
493
+ refused.push(tmpl.filename);
204
494
  }
205
495
  for (const f of created) {
206
496
  console.log(` ${success(`created`)} .codehere/${f}`);
@@ -211,15 +501,41 @@ export function executeInitCommand(options = {}) {
211
501
  for (const f of refused) {
212
502
  console.log(` ${error(`refused`)} .codehere/${f} ${colors.dim('(symlink — remove it first)')}`);
213
503
  }
504
+ for (const d of refusedDirs) {
505
+ console.log(` ${error(`refused`)} .codehere/${d}/ ${colors.dim('(intermediate symlink — remove it first)')}`);
506
+ }
507
+ // F3 Task 3 — episodic AGENT_LEARNINGS.jsonl symlink to user's session log.
508
+ const home = resolveHome();
509
+ const episodic = ensureEpisodicSymlink(codehereDir, home);
510
+ switch (episodic.result) {
511
+ case 'created':
512
+ console.log(` ${success('linked ')} .codehere/memory/episodic/AGENT_LEARNINGS.jsonl → ${episodic.targetPath}`);
513
+ break;
514
+ case 're-linked':
515
+ console.log(` ${success('re-linked')} .codehere/memory/episodic/AGENT_LEARNINGS.jsonl → ${episodic.targetPath} ${colors.dim('(target updated)')}`);
516
+ break;
517
+ case 'exists':
518
+ // Quiet on the no-op case to avoid output noise on re-runs.
519
+ break;
520
+ case 'refused':
521
+ console.log(` ${error('refused ')} .codehere/memory/episodic/AGENT_LEARNINGS.jsonl ${colors.dim('(real file or symlinked ancestor present — remove it first to enable the symlink)')}`);
522
+ break;
523
+ case 'root-skipped':
524
+ console.log(` ${warning('skipped ')} episodic symlink ${colors.dim('(running as root — would create root-owned files in HOME; re-run as your normal user)')}`);
525
+ break;
526
+ case 'failed':
527
+ console.log(` ${warning('skipped ')} episodic symlink ${colors.dim('(could not write — HOME may be read-only)')}`);
528
+ break;
529
+ }
214
530
  if (!options.noGitignore) {
215
531
  const gitignoreResult = ensureGitignore(root);
216
532
  newline();
217
- switch (gitignoreResult) {
218
- case 'added':
219
- console.log(` ${success('updated')} .gitignore ${colors.dim('(.codehere/HANDOVER.md ignored — per-machine working state)')}`);
533
+ switch (gitignoreResult.result) {
534
+ case 'updated':
535
+ console.log(` ${success('updated')} .gitignore ${colors.dim(`(+${gitignoreResult.addedLines} entr${gitignoreResult.addedLines === 1 ? 'y' : 'ies'} for HANDOVER + memory/working/, /personal/, /candidates/)`)}`);
220
536
  break;
221
537
  case 'already-present':
222
- console.log(` ${info('exists ')} .gitignore ${colors.dim('(HANDOVER.md already ignored)')}`);
538
+ console.log(` ${info('exists ')} .gitignore ${colors.dim('(codehere entries already ignored)')}`);
223
539
  break;
224
540
  case 'no-git':
225
541
  console.log(` ${warning('skipped')} .gitignore ${colors.dim('(no .git directory at root)')}`);
@@ -233,7 +549,9 @@ export function executeInitCommand(options = {}) {
233
549
  console.log(colors.bold('Next:'));
234
550
  console.log(` 1. Open ${colors.cyan('.codehere/CONTEXT.md')} and fill in what this repo is`);
235
551
  console.log(` 2. Run ${colors.cyan('codehere start')} and delegate your first task`);
236
- console.log(` 3. Update ${colors.cyan('.codehere/HANDOVER.md')} at the end of the session`);
552
+ console.log(` 3. Track in-flight work in ${colors.cyan('.codehere/HANDOVER.md')} as you go`);
553
+ console.log(` ${colors.dim('Inspect what gets injected at spawn:')} ${colors.cyan('codehere memory show injection')}`);
554
+ console.log(` ${colors.dim('To adopt the F3 canonical layout (AGENTS.md / memory/working/session.md / skills/), run:')} ${colors.cyan('codehere memory init')}`);
237
555
  newline();
238
556
  }
239
557
  catch (e) {