codehere 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,7 +79,7 @@ Write the paragraph now. Do not preface with "Update:" or anything similar.
79
79
  Plain prose. No "I'm currently…" — narrate the AGENT, not yourself.
80
80
  `;
81
81
  function templateFile() {
82
- return join(homedir(), '.codehere', 'narrator-prompt.md');
82
+ return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'narrator-prompt.md');
83
83
  }
84
84
  /** Read the user-customized template if present and valid. Falls back to
85
85
  * baked-in default on missing file, parse failure, or missing required
package/dist/index.js CHANGED
@@ -661,8 +661,8 @@ program
661
661
  try {
662
662
  const { migrate } = await import('./infrastructure/storage/project-thread-migration.js');
663
663
  const result = migrate({
664
- sourceJsonl: join(homedir(), '.codehere/sessions.jsonl'),
665
- targetDir: join(homedir(), '.codehere/projects'),
664
+ sourceJsonl: join(process.env.CODEHERE_HOME || homedir(), '.codehere/sessions.jsonl'),
665
+ targetDir: join(process.env.CODEHERE_HOME || homedir(), '.codehere/projects'),
666
666
  });
667
667
  if (result.threadsWritten > 0) {
668
668
  console.log(colors.dim(` v0.6 migration: created ${result.threadsWritten} project thread(s) from ${result.sessionsProcessed} sessions (${result.durationMs}ms)`));
@@ -765,8 +765,12 @@ program
765
765
  console.log(colors.dim(` version: ${existing.version}`));
766
766
  }
767
767
  newline();
768
- console.log(colors.dim(' Opening browser...'));
769
- await openBrowser();
768
+ // Honor --no-open here too — found via the v1 stranger walk: a second
769
+ // `start --no-open` against a running daemon popped a browser anyway.
770
+ if (shouldOpen) {
771
+ console.log(colors.dim(' Opening browser...'));
772
+ await openBrowser();
773
+ }
770
774
  process.exit(0);
771
775
  }
772
776
  console.error(error(`Port ${port} is in use by another process.`));
@@ -947,8 +951,8 @@ program
947
951
  program
948
952
  .command('migrate-threads', { hidden: true }) // hidden from --help (v0.6.0 internal — auto-runs on daemon start once wired)
949
953
  .description('Migrate sessions.jsonl into per-project thread.jsonl files (v0.6.0 schema)')
950
- .option('--source <path>', 'Source sessions.jsonl', `${homedir()}/.codehere/sessions.jsonl`)
951
- .option('--target <dir>', 'Target projects dir', `${homedir()}/.codehere/projects`)
954
+ .option('--source <path>', 'Source sessions.jsonl', `${process.env.CODEHERE_HOME || homedir()}/.codehere/sessions.jsonl`)
955
+ .option('--target <dir>', 'Target projects dir', `${process.env.CODEHERE_HOME || homedir()}/.codehere/projects`)
952
956
  .option('--dry-run', 'Report what migrate WOULD do, write nothing', false)
953
957
  .option('--rollback', 'Remove the projects dir; sessions.jsonl untouched', false)
954
958
  .option('--keep-temp-dirs', 'Include /tmp + /var/folders cwds (default skips them)', false)
@@ -29,7 +29,7 @@ const DEFAULT_CONFIG = {
29
29
  warnAtPct: 70,
30
30
  };
31
31
  function configFile() {
32
- return join(homedir(), '.codehere', 'budget.yaml');
32
+ return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'budget.yaml');
33
33
  }
34
34
  /** Parse a tiny subset of YAML — `key: value` pairs, # comments, blank lines.
35
35
  * No nesting, no lists, no multi-line. Same approach as permissions-config:
@@ -25,7 +25,7 @@ import { join } from 'path';
25
25
  /** Resolved at call time, not module load, so test setups that swap $HOME
26
26
  * after import still get a fresh path on each invocation. */
27
27
  function sessionsFile() {
28
- return join(homedir(), '.codehere', 'sessions.jsonl');
28
+ return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
29
29
  }
30
30
  /** USD per million OUTPUT tokens at the agent's typical 2026 tier. Input is
31
31
  * estimated at 1/5 of output rate (Anthropic + OpenAI both publish ~5x
@@ -396,8 +396,14 @@ export function calculateRiskScore(findings) {
396
396
  /**
397
397
  * Audit log path — defaults to ~/.codehere/audit/hook-events.jsonl.
398
398
  * Override at process start with CODEHERE_AUDIT_FILE env var (used by tests, optional for users).
399
+ * Honors CODEHERE_HOME like sessions/config/threads — found via the v1 stranger
400
+ * walk: an "isolated" daemon (verify-all's :3779, the walk's scratch home) was
401
+ * writing mock audit entries into the REAL ~/.codehere/audit while verify-all
402
+ * printed "Real ~/.codehere untouched" (the footer showed 162 entries · 51
403
+ * agents in a brand-new home — all of them pollution from past mock runs).
399
404
  */
400
- export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE || join(homedir(), '.codehere', 'audit', 'hook-events.jsonl');
405
+ export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE ||
406
+ join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'audit', 'hook-events.jsonl');
401
407
  const GENESIS_PREV_HASH = '0'.repeat(64);
402
408
  /**
403
409
  * Canonicalize an audit entry for hashing. We sort keys alphabetically and
@@ -15,7 +15,7 @@
15
15
  import { existsSync, mkdirSync, writeFileSync } from 'fs';
16
16
  import { homedir } from 'os';
17
17
  import { join, dirname } from 'path';
18
- const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
18
+ const CONFIG_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'agents.yaml');
19
19
  const DEFAULT_YAML = `# codehere — pluggable agent adapters
20
20
  #
21
21
  # Built-in adapters (no configuration needed — just install the CLI):
@@ -38,7 +38,7 @@ const NULL_RECORDER = {
38
38
  export function startRecording(opts) {
39
39
  if (process.env[ENV_FLAG] !== '1')
40
40
  return NULL_RECORDER;
41
- const outDir = opts.outDir ?? join(homedir(), '.codehere', 'cassettes', opts.adapter);
41
+ const outDir = opts.outDir ?? join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'cassettes', opts.adapter);
42
42
  const scenario = `organic-${opts.sessionId.slice(0, 8)}`;
43
43
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
44
44
  const path = join(outDir, `${scenario}-${stamp}.jsonl`);
@@ -23,7 +23,7 @@ import { readFileSync, existsSync } from 'fs';
23
23
  import { homedir } from 'os';
24
24
  import { join } from 'path';
25
25
  import { validateFailoverChain } from './failover-policy.js';
26
- const GLOBAL_CONFIG_PATH = join(homedir(), '.codehere', 'config.json');
26
+ const GLOBAL_CONFIG_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'config.json');
27
27
  /**
28
28
  * Load failover configuration from ~/.codehere/config.json.
29
29
  * Returns undefined if the config file does not exist or has no failover section.
@@ -38,8 +38,8 @@ import { randomUUID } from 'crypto';
38
38
  import { ProgressAggregator } from './progress-aggregator.js';
39
39
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
40
40
  import { applyMemoryPrefix } from './memory-injector.js';
41
- const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
42
- const CONFIG_PATH_JSON = join(homedir(), '.codehere', 'agents.json');
41
+ const CONFIG_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'agents.yaml');
42
+ const CONFIG_PATH_JSON = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'agents.json');
43
43
  /** Minimal YAML parser for the narrow shape above. Intentionally NOT a full YAML
44
44
  * parser — avoids dragging in a dep. Handles: `key: value`, lists with `- key: val`,
45
45
  * nested lists of simple strings via `[a, b]`, comments after `#`. Errors loud.
@@ -279,8 +279,11 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
279
279
  *
280
280
  * Stops at the filesystem root. Symlink-following on read is the right
281
281
  * asymmetry per ADR-012 (the symlink defense applies to writes, not reads).
282
+ *
283
+ * Exported for the daemon's /api/memory/status route (the room's init nudge
284
+ * must report the SAME readiness the injector itself would act on).
282
285
  */
283
- function findProjectRoot(startPath) {
286
+ export function findProjectRoot(startPath) {
284
287
  let current;
285
288
  try {
286
289
  current = realpathSync(resolve(startPath));
@@ -194,7 +194,10 @@ async function runOpencode() {
194
194
  out({ type: 'tool_use', part: { tool, state: { input: s.input, output: s.result, status: 'completed' } } });
195
195
  }
196
196
  await sleep(120);
197
- out({ type: 'text', text: 'Completed (mock): ' + prompt });
197
+ // Real opencode carries narrative text in part.text (the adapter's summary
198
+ // extraction and the aggregator's say capture both read that shape) — the
199
+ // prior top-level `text` field was mock drift no real parser ever saw.
200
+ out({ type: 'text', part: { text: 'Completed (mock): ' + prompt } });
198
201
  }
199
202
  async function main() {
200
203
  if (!prompt)
@@ -169,6 +169,14 @@ export class ProgressAggregator {
169
169
  // real gap surfaced by the mock harness). Build a step on the terminal
170
170
  // (status==='completed') event so opencode is a first-class agent.
171
171
  ingestOpencode(type, data) {
172
+ // OpenCode's narrative text rides `text` events with part.text (the same
173
+ // shape the adapter's summary extraction reads) — cross-vendor say capture
174
+ // per the v1 spec, so opencode's Final Report stops degrading to the
175
+ // deterministic fallback.
176
+ if (type === 'text') {
177
+ const part = (data.part ?? {});
178
+ return this.pushSay(String(part.text ?? ''));
179
+ }
172
180
  if (type !== 'tool_use')
173
181
  return false;
174
182
  const part = (data.part ?? {});
@@ -213,6 +221,24 @@ export class ProgressAggregator {
213
221
  const idx = this._steps.push({ label, state, startedAt: Date.now() }) - 1;
214
222
  return idx;
215
223
  }
224
+ /** The agent's own narration as a kind:'say' step — the "watch it work"
225
+ * signal AND what the Final Report engine reuses as the summary. Cross-
226
+ * vendor by decision of the v1 spec (the room thread is "tool steps woven
227
+ * with the agent's plain-English narration" — for every agent, not just
228
+ * claude/aider). Same 500-char cap as the original claude capture. */
229
+ pushSay(text) {
230
+ const said = String(text ?? '').trim();
231
+ if (!said)
232
+ return false;
233
+ this._steps.push({
234
+ kind: 'say',
235
+ label: said.length > 500 ? said.slice(0, 500) + '\\u2026' : said,
236
+ state: 'done',
237
+ startedAt: Date.now(),
238
+ completedAt: Date.now(),
239
+ });
240
+ return true;
241
+ }
216
242
  ingestClaude(type, data) {
217
243
  if (type === 'assistant') {
218
244
  const msg = (data.message ?? {});
@@ -322,6 +348,12 @@ export class ProgressAggregator {
322
348
  return true;
323
349
  }
324
350
  }
351
+ // Gemini's narrative text rides `content` events — same cross-vendor say
352
+ // capture as claude/aider/codex (v1 spec: narration woven into the thread,
353
+ // and the Final Report engine reuses it as the summary).
354
+ if (type === 'content' && typeof data.text === 'string') {
355
+ return this.pushSay(data.text);
356
+ }
325
357
  return false;
326
358
  }
327
359
  /**
@@ -642,12 +674,15 @@ export class ProgressAggregator {
642
674
  });
643
675
  return true;
644
676
  }
645
- // NOTE (mock-currency 2026-06-02): codex `agent_message` is intentionally NOT
646
- // captured here — a prior decision (pm-layers.test.ts "just chatter, not a
647
- // step") drops it. This is INCONSISTENT with ingestClaude/ingestAider, which
648
- // DO capture agent narration as kind:'say' (per #45 + A1 "watch it work").
649
- // Resolving that inconsistency is a product taste call → flagged for Ega in
650
- // mock-currency-2026-06-02.md, not changed unilaterally by the autonomous loop.
677
+ // agent_message = codex's own narration. Captured as kind:'say' since the
678
+ // v1 spec settled the taste call that used to be flagged here (pm-layers
679
+ // "just chatter" decision): the room thread is "tool steps woven with the
680
+ // agent's plain-English narration" for EVERY vendor, and the Final Report
681
+ // engine reuses 'say' steps as the summary — without this, codex always
682
+ // degraded to the deterministic fallback.
683
+ if (itemType === 'agent_message') {
684
+ return this.pushSay(String(item.text ?? ''));
685
+ }
651
686
  return false;
652
687
  }
653
688
  }
@@ -16,8 +16,8 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
16
16
  import { join, dirname } from 'path';
17
17
  import { homedir, hostname } from 'os';
18
18
  const CLOUD_URL = 'https://codehere.uk/api/sync';
19
- const TOKEN_PATH = join(homedir(), '.codehere', 'cloud-token');
20
- const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
19
+ const TOKEN_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'cloud-token');
20
+ const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
21
21
  // Lazy-loaded session manager — imported once to avoid circular deps
22
22
  let _sessionManager = null;
23
23
  async function getSessionManager() {
@@ -46,6 +46,8 @@ import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuth
46
46
  import { saveToken, clearToken, connectionStatus } from '../context/oauth-store.js';
47
47
  import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
48
48
  import { isAbsolute } from 'path';
49
+ import { findProjectRoot } from '../orchestration/memory-injector.js';
50
+ import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
49
51
  import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
50
52
  import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
51
53
  import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
@@ -545,7 +547,7 @@ export function startMCPServer(options = {}) {
545
547
  // Tails sessions.jsonl and pushes "change" events so the /v2 UI can
546
548
  // re-fetch task state instantly instead of waiting 8s for the next poll.
547
549
  // Same pattern as /events (audit log tail) but for sessions.
548
- const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
550
+ const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
549
551
  if (req.method === 'GET' && req.url === '/api/events/sessions') {
550
552
  res.writeHead(200, {
551
553
  'Content-Type': 'text/event-stream',
@@ -1211,6 +1213,66 @@ export function startMCPServer(options = {}) {
1211
1213
  }
1212
1214
  return;
1213
1215
  }
1216
+ // ── v1 project-memory routes (the room's init nudge) ─────────────────────
1217
+ // Found by the stranger walk: web-only users never run `codehere init`, so
1218
+ // the spec's "agent remembers the project" half of nothing-lost silently
1219
+ // doesn't apply. Status reports the SAME readiness the injector acts on
1220
+ // (shared findProjectRoot); init runs the SAME hardened scaffold the CLI
1221
+ // does (shared scaffoldProjectAt — symlink defenses included).
1222
+ if (req.method === 'GET' && req.url?.startsWith('/api/memory/status')) {
1223
+ try {
1224
+ const u = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
1225
+ const cwd = u.searchParams.get('cwd') || '';
1226
+ if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) {
1227
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1228
+ res.end(JSON.stringify({ error: 'cwd (absolute path) required' }));
1229
+ return;
1230
+ }
1231
+ const root = findProjectRoot(cwd);
1232
+ const ready = !!root && existsSync(join(root, '.codehere', 'memory', 'working'));
1233
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1234
+ res.end(JSON.stringify({ ready, projectRoot: root }));
1235
+ }
1236
+ catch (e) {
1237
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1238
+ res.end(JSON.stringify({ error: 'memory_status_failed', detail: String(e) }));
1239
+ }
1240
+ return;
1241
+ }
1242
+ if (req.method === 'POST' && req.url === '/api/memory/init') {
1243
+ (async () => {
1244
+ try {
1245
+ const body = await readBody(req, res);
1246
+ let cwd = '';
1247
+ try {
1248
+ cwd = String(JSON.parse(body || '{}').cwd || '');
1249
+ }
1250
+ catch {
1251
+ /* falls through to the validation below */
1252
+ }
1253
+ if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
1254
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1255
+ res.end(JSON.stringify({ error: 'cwd (absolute path to an existing directory) required' }));
1256
+ return;
1257
+ }
1258
+ const r = scaffoldProjectAt(cwd);
1259
+ if (!r.ok) {
1260
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1261
+ res.end(JSON.stringify({ ok: false, reason: r.reason }));
1262
+ return;
1263
+ }
1264
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1265
+ res.end(JSON.stringify({ ok: true, created: r.created.length, skipped: r.skipped.length, refused: r.refused.length + r.refusedDirs.length }));
1266
+ }
1267
+ catch (e) {
1268
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1269
+ res.end(JSON.stringify({ error: 'memory_init_failed', detail: String(e) }));
1270
+ }
1271
+ })().catch(() => {
1272
+ /* handled above — keep the route from rejecting unhandled */
1273
+ });
1274
+ return;
1275
+ }
1214
1276
  // ── v0.6.0 M1 project thread routes ──────────────────────────────────────
1215
1277
  // GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
1216
1278
  // GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
@@ -662,6 +662,23 @@ const SHARED_STYLES = `
662
662
  .audit-line b { color: var(--fg-90); font-weight: 600; }
663
663
  .audit-line code { background: hsl(0 0% 9%); padding: 1px 5px; border-radius: 3px; color: var(--fg-70); }
664
664
 
665
+ /* project-memory nudge · same quiet family as .audit-line (trust present-
666
+ but-quiet); .on = the post-click confirmation state */
667
+ .mem-nudge {
668
+ font-family: var(--mono); font-size: 10.5px; color: var(--fg-40);
669
+ padding: 6px 12px; margin: 8px 0;
670
+ background: rgba(0,0,0,0.2);
671
+ border-left: 2px solid var(--border); border-radius: 3px;
672
+ line-height: 1.55;
673
+ }
674
+ .mem-nudge .mem-btn {
675
+ background: none; border: 1px solid var(--border); border-radius: 4px;
676
+ color: var(--fg-70); font-family: var(--mono); font-size: 10.5px;
677
+ padding: 2px 8px; cursor: pointer; margin-left: 4px;
678
+ }
679
+ .mem-nudge .mem-btn:hover { border-color: var(--accent); color: var(--fg); }
680
+ .mem-nudge.on { border-left-color: rgba(40,200,64,0.4); color: var(--fg-70); }
681
+
665
682
  /* auditor persona · audit-chain + verifier + hook-badges amplified,
666
683
  summary muted (per demo v12 line 2462: "amplify audit-lines +
667
684
  hook-badges + trust-grades") */
@@ -1545,8 +1562,9 @@ const SHARED_STYLES = `
1545
1562
  .input-ta::placeholder { color: var(--fg-40); }
1546
1563
  .input-ta:disabled { color: var(--fg-30); }
1547
1564
  .input-meta { font-family: var(--mono); font-size: 10px; color: var(--fg-40); white-space: nowrap; display: inline-flex; gap: 8px; align-items: center; }
1548
- .input-meta .pick-agent { color: var(--fg-70); cursor: pointer; padding: 2px 6px; border-radius: 3px; transition: background 0.15s; }
1565
+ .input-meta .pick-agent { color: var(--fg-70); cursor: pointer; padding: 2px 6px; border-radius: 3px; transition: background 0.15s; background: transparent; border: none; font: inherit; -webkit-appearance: none; appearance: none; }
1549
1566
  .input-meta .pick-agent:hover { background: rgba(255,255,255,0.05); color: var(--fg); }
1567
+ .input-meta select.pick-agent option { background: var(--bg-2, #1a1a1f); color: var(--fg); }
1550
1568
  .input-meta .cost-est { color: var(--allow); padding: 2px 6px; border-radius: 3px; background: rgba(40,200,64,0.06); }
1551
1569
  .send-btn {
1552
1570
  background: var(--accent); color: #000; border: none; border-radius: 6px;
@@ -1567,6 +1585,10 @@ const SHARED_STYLES = `
1567
1585
  const APP_SCRIPT = `
1568
1586
  const _cached = {};
1569
1587
  let _filter = 'all', _detail = null, _room = null, _offline = false, _autoOpened = false;
1588
+ // Project-memory state for the open room · null = unknown (fetch in flight),
1589
+ // false = agents forget this project (show the nudge), true = memory already
1590
+ // on (stay quiet), 'just-on' = turned on from the nudge this visit (confirm).
1591
+ let _roomMemory = null;
1570
1592
  const ICONS = { queued:'\\u23F3', running:'\\u25CF', paused:'\\u23F8', blocked:'\\uD83D\\uDD12', done:'\\u2713', failed:'\\u2715', crashed:'!', killed:'\\u2298' };
1571
1593
  const STAT_ORDER = ['running','queued','paused','blocked','done','failed','crashed','killed'];
1572
1594
  const FILTERS = { all: s=>true, running: s=>['running','queued','paused','blocked'].includes(s.status), done: s=>['done','completed'].includes(s.status), failed: s=>['failed','crashed','killed','error'].includes(s.status) };
@@ -1733,12 +1755,48 @@ function render() {
1733
1755
  // agent turns. Renders into #task-list (same surface as the goals list).
1734
1756
  function openRoom(enc){
1735
1757
  _room=decodeURIComponent(enc||''); _detail=null;
1758
+ // Project-memory status for the nudge — fetched once per entry from the
1759
+ // daemon (the SAME readiness the injector acts on, not a guess).
1760
+ _roomMemory=null;
1761
+ (function(forCwd){
1762
+ fetch('/api/memory/status?cwd='+encodeURIComponent(forCwd)).then(x=>x.json()).then(j=>{
1763
+ if(_room===forCwd){ _roomMemory=!!j.ready; render(); }
1764
+ }).catch(()=>{});
1765
+ })(_room);
1736
1766
  document.getElementById('task-list-view').style.display='';
1737
1767
  document.getElementById('task-detail-view').style.display='none';
1738
1768
  document.body.classList.remove('detail-open');
1769
+ // Hand-off affordance: inside a room the composer targets THIS goal (cwd is
1770
+ // pinned in submit) and the picker defaults to whoever worked it last —
1771
+ // continuing is zero clicks, handing to a different agent is one visible
1772
+ // click. Set once on entry (not in renderRoom, which re-runs every poll and
1773
+ // would stomp the user's choice).
1774
+ const ta=document.getElementById('main-ta');
1775
+ if(ta) ta.placeholder='Add the next task in this goal\\u2026 any agent picks up where the last one left off';
1776
+ const ap=document.getElementById('agent-pick');
1777
+ if(ap){
1778
+ const turns=allS(_cached.tasks||{}).filter(x=>!/^demo-/.test(x.sessionId||'')&&(x.cwd||'')===_room&&x.agent).sort((a,b)=>(a.startedAt||0)-(b.startedAt||0));
1779
+ const raw=turns.length?String(turns[turns.length-1].agent):'';
1780
+ ap.value=['claude-code','codex','aider','gemini','opencode'].indexOf(raw)>=0?raw:'auto';
1781
+ }
1782
+ render();
1783
+ }
1784
+ function closeRoom(){
1785
+ _room=null; _roomMemory=null;
1786
+ const ta=document.getElementById('main-ta');
1787
+ if(ta) ta.placeholder='Describe a task or ask a question...';
1788
+ const ap=document.getElementById('agent-pick'); if(ap) ap.value='auto';
1739
1789
  render();
1740
1790
  }
1741
- function closeRoom(){ _room=null; render(); }
1791
+ async function initRoomMemory(btn){
1792
+ if(!_room) return;
1793
+ if(btn){ btn.disabled=true; btn.textContent='turning on\\u2026'; }
1794
+ try{
1795
+ const r=await fetch('/api/memory/init',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cwd:_room})});
1796
+ if(r.ok){ _roomMemory='just-on'; render(); }
1797
+ else { const e=await r.json().catch(()=>({})); al('Could not turn on memory: '+(e.error||e.reason||'unknown')); if(btn){btn.disabled=false;btn.textContent='turn on project memory';} }
1798
+ }catch(e){ al('Network error: '+e.message); if(btn){btn.disabled=false;btn.textContent='turn on project memory';} }
1799
+ }
1742
1800
  function renderRoom(){
1743
1801
  const c=document.getElementById('task-list');
1744
1802
  const _hs=document.getElementById('host-sessions-strip'); if(_hs){ _hs.style.display='none'; }
@@ -1769,6 +1827,16 @@ function renderRoom(){
1769
1827
  } else {
1770
1828
  html+='<div class="empty-state"><h2>No tasks in this goal yet.</h2></div>';
1771
1829
  }
1830
+ // Project-memory nudge (stranger-walk finding 2026-06-06): web-only users
1831
+ // never run codehere-init, so agents silently get NO memory of the
1832
+ // project. Honest + quiet: rendered ONLY when the daemon says memory is
1833
+ // off; the confirmation says "from the next task" because injection
1834
+ // happens at spawn — no fake instant effect.
1835
+ if(_roomMemory===false){
1836
+ html+='<div class="mem-nudge">&#129504; agents forget this project between runs \\u00b7 <button class="mem-btn" onclick="initRoomMemory(this)">turn on project memory</button></div>';
1837
+ } else if(_roomMemory==='just-on'){
1838
+ html+='<div class="mem-nudge on">\\u2713 project memory is on \\u2014 agents will remember this project from the next task</div>';
1839
+ }
1772
1840
  c.innerHTML=html;
1773
1841
  }
1774
1842
 
@@ -1852,14 +1920,11 @@ function renderL() {
1852
1920
  const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch]));
1853
1921
  const fl=all.filter(FILTERS[_filter]||FILTERS.all);
1854
1922
  let html='';
1855
- // Pin demo-shape sessions (id prefix 'demo-') to the very top — they
1856
- // exist to demonstrate what the product does · always relevant.
1923
+ // Demo-shape sessions (id prefix 'demo-') render AFTER the goals — they
1924
+ // demonstrate the product, but the home leads with YOUR goals (spec:
1925
+ // "Home is a list of your goals"; demos were burying it).
1857
1926
  const demo=fl.filter(x=>/^demo-/.test(x.sessionId||''));
1858
1927
  const rest=fl.filter(x=>!/^demo-/.test(x.sessionId||''));
1859
- if(demo.length){
1860
- html+='<div class="section-label featured">Featured</div>';
1861
- for(const t of demo) html+=cardH(t);
1862
- }
1863
1928
  // Goal-first home: group the rest by project (the goal/room), not by status.
1864
1929
  // cwd → project title (from /api/projects); fall back to the folder name.
1865
1930
  const projects=_cached.projects||[];
@@ -1891,7 +1956,7 @@ function renderL() {
1891
1956
  g.items.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
1892
1957
  const running=g.items.filter(x=>['running','queued','paused','blocked'].includes(x.status)).length;
1893
1958
  const meta=g.items.length+' task'+(g.items.length===1?'':'s')+(running?' · '+running+' running':'');
1894
- html+='<div class="goal-group"><div class="goal-head" onclick="openRoom(\\''+encodeURIComponent(g.cwd||'')+'\\')"><span class="goal-title">'+esc(titleFor(g.cwd))+'</span><span class="goal-meta">'+esc(meta)+' \\u00b7 open \\u2192</span></div>';
1959
+ html+='<div class="goal-group"><div class="goal-head" data-goal-cwd="'+encodeURIComponent(g.cwd||'')+'" onclick="openRoom(\\''+encodeURIComponent(g.cwd||'')+'\\')"><span class="goal-title">'+esc(titleFor(g.cwd))+'</span><span class="goal-meta">'+esc(meta)+' \\u00b7 open \\u2192</span></div>';
1895
1960
  // Time-gap separator within a goal · a big gap between consecutive tasks
1896
1961
  // in the same goal is meaningful ("picked this back up 3 days later").
1897
1962
  let prevTs=null;
@@ -1906,6 +1971,10 @@ function renderL() {
1906
1971
  }
1907
1972
  html+='</div>';
1908
1973
  }
1974
+ if(demo.length){
1975
+ html+='<div class="section-label featured">Featured</div>';
1976
+ for(const t of demo) html+=cardH(t);
1977
+ }
1909
1978
  if(!html){
1910
1979
  const h={all:'No goals yet. Type a task below to start one.',running:'No running tasks.',done:'No completed tasks.',failed:'No failed tasks.'};
1911
1980
  html='<div class="empty-state"><h2>'+(h[_filter]||'No tasks found.')+'</h2></div>';
@@ -3316,8 +3385,11 @@ async function fetchAll() {
3316
3385
  if(_all.length){
3317
3386
  _autoOpened=true;
3318
3387
  if(_detail===null){
3319
- const _act=_all.filter(x=>['running','queued','paused','blocked'].includes(x.status));
3320
- const _pool=_act.length?_act:_all.slice();
3388
+ // Demos never hijack first load — auto-open is for YOUR work
3389
+ // (a seeded demo stuck on "running" was stealing it · 2026-06-06 walk).
3390
+ const _real=_all.filter(x=>!/^demo-/.test(x.sessionId||''));
3391
+ const _act=_real.filter(x=>['running','queued','paused','blocked'].includes(x.status));
3392
+ const _pool=_act.length?_act:_real.slice();
3321
3393
  _pool.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
3322
3394
  const _pick=_pool[0];
3323
3395
  if(_pick&&_pick.sessionId){ showD(_pick.sessionId); }
@@ -3352,7 +3424,20 @@ async function submit(ta,btn,taskId){
3352
3424
  // which is the disabled-composer bug's mirror image: composer enabled but
3353
3425
  // routed wrong.
3354
3426
  const url = taskId ? '/api/task/'+taskId+'/message' : '/api/task';
3355
- const body = taskId ? JSON.stringify({text:p}) : JSON.stringify({prompt:p});
3427
+ let body;
3428
+ if(taskId){ body=JSON.stringify({text:p}); }
3429
+ else {
3430
+ // Room-aware spawn: typing while a goal room is open adds the task TO
3431
+ // that goal (cwd pinned to the room) — before this, the room composer
3432
+ // silently spawned OUTSIDE the room, the same routing lie as the
3433
+ // follow-up bug above. Agent comes from the visible picker; 'auto'
3434
+ // omits it so the server's conductor default applies unchanged.
3435
+ const payload={prompt:p};
3436
+ if(_room) payload.cwd=_room;
3437
+ const ap=document.getElementById('agent-pick');
3438
+ if(ap&&ap.value&&ap.value!=='auto') payload.agent=ap.value;
3439
+ body=JSON.stringify(payload);
3440
+ }
3356
3441
  const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body});
3357
3442
  if(!r.ok){const e=await r.json();al('Failed: '+(e.error||'unknown'));btn.disabled=false;ta.disabled=false;_submitting=false;}else{
3358
3443
  ta.value='';btn.textContent='\\u2713';setTimeout(()=>{btn.textContent='\\u2191';btn.disabled=false;ta.disabled=false;_submitting=false;ta.focus();},1200);
@@ -4129,7 +4214,6 @@ export const TASK_HTML_V2 = `<!doctype html>
4129
4214
 
4130
4215
  <div class="content">
4131
4216
  <div id="task-list-view" class="content-inner">
4132
- <div id="host-sessions-strip" style="display:none"></div>
4133
4217
  <div id="task-list">
4134
4218
  <div class="skeleton" id="skeleton">
4135
4219
  <div class="skeleton-card"></div>
@@ -4137,6 +4221,10 @@ export const TASK_HTML_V2 = `<!doctype html>
4137
4221
  <div class="skeleton-card"></div>
4138
4222
  </div>
4139
4223
  </div>
4224
+ <!-- Observed host sessions live BELOW the goals: the spec's home is
4225
+ "a list of your goals" — ambient observation must not bury the
4226
+ thesis surface (found burying it during the 2026-06-06 walk). -->
4227
+ <div id="host-sessions-strip" style="display:none"></div>
4140
4228
  </div>
4141
4229
  <div id="task-detail-view" style="display:none" class="content-inner">
4142
4230
  <div id="detail-view"></div>
@@ -4153,7 +4241,7 @@ export const TASK_HTML_V2 = `<!doctype html>
4153
4241
  <div class="input-area">
4154
4242
  <div class="input-bar">
4155
4243
  <textarea class="input-ta composer-bar" id="main-ta" placeholder="Describe a task or ask a question..." rows="1"></textarea>
4156
- <div class="input-meta composer-meta"><span class="pick-agent" title="Auto-pick · best agent for this task">↳ Auto</span><span class="cost-est" title="Estimated cost · based on prompt length + recent agent pricing">~$0.02</span></div>
4244
+ <div class="input-meta composer-meta"><select class="pick-agent" id="agent-pick" title="Who does this task — Auto picks the best fit" aria-label="Pick the agent for this task"><option value="auto">↳ Auto</option><option value="claude-code">claude-code</option><option value="codex">codex</option><option value="aider">aider</option><option value="gemini">gemini</option><option value="opencode">opencode</option></select><span class="cost-est" title="Estimated cost · based on prompt length + recent agent pricing">~$0.02</span></div>
4157
4245
  <button class="send-btn composer-send" id="main-btn">&#8593;</button>
4158
4246
  </div>
4159
4247
  <div class="kbd-hint">
@@ -298,6 +298,23 @@ export const VERIFY_HTML = `<!doctype html>
298
298
  { id:"F-TASK-CARD", name:"Task card", surface:"list", sel:".task-card", status:"shipped", tier:"v0.7" },
299
299
  { id:"F-TIME-GAP-PILL", name:"Brand pill + relative-time sub", surface:"list", sel:".time-gap", status:"shipped", tier:"v0.7" },
300
300
  { id:"F-WORKTREE-ALERT", name:"Worktree alert", surface:"list", sel:".worktree-alert", status:"partial", tier:"v0.7" },
301
+ /* goal-first home + goal room (v1 spec · shared-room hand-off). Goal groups
302
+ render from REAL non-demo tasks (same data dependency as F-TIME-GAP-PILL);
303
+ room features are observed by driving the REAL openRoom() on the first
304
+ goal-head's data-goal-cwd, then closeRoom() — same pattern as showD(). */
305
+ { id:"F-GOAL-GROUP", name:"Goal-first home · tasks grouped by goal", surface:"list", sel:".goal-group", status:"shipped", tier:"v1" },
306
+ { id:"F-GOAL-HEAD-OPEN", name:"Goal head opens its room (carries cwd)", surface:"list", sel:".goal-head[data-goal-cwd]", status:"shipped", tier:"v1" },
307
+ { id:"F-AGENT-PICK", name:"Composer agent picker (Auto + 5 agents)", surface:"list", sel:"select#agent-pick", status:"shipped", tier:"v1" },
308
+ /* room selectors are scoped to #task-list: the hidden detail view's DOM
309
+ persists (display:none still matches querySelectorAll) and also has an
310
+ .audit-line — unscoped, a dead room would false-OBSERVE off it. */
311
+ { id:"F-ROOM-HEAD", name:"Goal room header (title + outcome sub)", surface:"room", sel:"#task-list .room-head .room-title", status:"shipped", tier:"v1" },
312
+ { id:"F-ROOM-BACK", name:"Room back-to-all-goals affordance", surface:"room", sel:"#task-list .room-back", status:"shipped", tier:"v1" },
313
+ { id:"F-ROOM-TURNS", name:"Turns in order (how agents worked the goal)", surface:"room", sel:"#task-list .turns-label", status:"partial", tier:"v1" },
314
+ { id:"F-ROOM-AUDIT", name:"Room audit line (steps in local chain)", surface:"room", sel:"#task-list .audit-line", status:"partial", tier:"v1" },
315
+ /* partial by nature: renders ONLY when the daemon reports project memory
316
+ OFF for the opened room (initialized projects correctly show nothing). */
317
+ { id:"F-ROOM-MEM-NUDGE", name:"Project-memory init nudge (turn on memory)", surface:"room", sel:"#task-list .mem-nudge", status:"partial", tier:"v1" },
301
318
  { id:"M-OVERLAY-LINEAR", name:"Overlay-renderer Linear", surface:"list", sel:".or-overlay", status:"shipped", tier:"v0.9" },
302
319
  { id:"M-PRIMITIVES-NOTE", name:"Primitives note", surface:"list", sel:".primitives-note .prims .p", status:"shipped", tier:"v0.8" },
303
320
  { id:"M-RESHAPE-AGENT-COMPOSE", name:"Agent-compose animation", surface:"list", sel:".agent-compose .line", status:"shipped", tier:"v0.8" },
@@ -651,6 +668,31 @@ export const VERIFY_HTML = `<!doctype html>
651
668
  });
652
669
  });
653
670
  }, Promise.resolve());
671
+ }).then(function(){
672
+ // room-surface features · drive the REAL openRoom() (same pattern as
673
+ // showD above) on the first goal-head's data-goal-cwd, observe, close.
674
+ // No goal-head on the home = no real tasks; the room genuinely cannot
675
+ // be opened, so room features go MISSING/PARTIAL-GAP per their status —
676
+ // honest, and F-GOAL-GROUP fails alongside so the cause is visible.
677
+ var room = FEATURES.filter(function(f){ return f.surface==="room" && f.status!=="roadmap" && f.status!=="future"; });
678
+ if (!room.length) return;
679
+ var w0 = frameWin();
680
+ var gh = frameDoc().querySelector(".goal-head[data-goal-cwd]");
681
+ var check = function(){
682
+ var d = frameDoc();
683
+ room.forEach(function(f){
684
+ var present = d.querySelectorAll(f.sel).length > 0;
685
+ var r = { id:f.id, present:present, verdict:covVerdict(f, present) };
686
+ covResults.push(r); paintCov(r);
687
+ });
688
+ };
689
+ if (!gh){ check(); return; }
690
+ try { w0.openRoom(gh.getAttribute("data-goal-cwd")); } catch(e){}
691
+ return sleep(200).then(function(){
692
+ check();
693
+ try { if (w0.closeRoom) w0.closeRoom(); } catch(e){}
694
+ return sleep(120);
695
+ });
654
696
  }).then(function(){
655
697
  // roadmap / future: SKIP (correctly absent), no observation
656
698
  FEATURES.filter(function(f){ return f.status==="roadmap" || f.status==="future"; }).forEach(function(f){
@@ -31,7 +31,7 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
31
31
  import { homedir } from 'os';
32
32
  import { join } from 'path';
33
33
  import { createHash } from 'crypto';
34
- const PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
34
+ const PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
35
35
  /** Match migration module's defaults. Same regex set kept in sync. */
36
36
  const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
37
37
  function isTempDir(cwd) {
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
22
22
  import { join } from 'path';
23
23
  import { homedir } from 'os';
24
- const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
24
+ const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
25
25
  /** List all known projects with summary stats. Cheap; no full task content. */
26
26
  export function listProjects(options) {
27
27
  const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
@@ -24,7 +24,7 @@ import { existsSync, readdirSync, readFileSync, appendFileSync, statSync } from
24
24
  import { join } from 'path';
25
25
  import { homedir } from 'os';
26
26
  import { generateThreadTitle } from '../../application/use-cases/generate-thread-title.js';
27
- const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
27
+ const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
28
28
  function scanThread(slug, file) {
29
29
  let title = null;
30
30
  let firstPrompt = null;
@@ -430,69 +430,81 @@ function writeTemplate(codehereDir, tmpl, force) {
430
430
  writeFileSync(target, tmpl.body);
431
431
  return 'created';
432
432
  }
433
+ export function scaffoldProjectAt(root, options = {}) {
434
+ const codehereDir = join(root, '.codehere');
435
+ const empty = { created: [], skipped: [], refused: [], refusedDirs: [] };
436
+ const noEpisodic = { result: 'failed', sourcePath: '', targetPath: '' };
437
+ // Refuse if .codehere itself is a symlink — would redirect every subsequent write.
438
+ if (existsSync(codehereDir) && isSymlink(codehereDir)) {
439
+ return { ok: false, reason: 'codehere-dir-is-symlink', ...empty, episodic: noEpisodic, gitignore: { result: 'skipped' } };
440
+ }
441
+ if (!existsSync(codehereDir)) {
442
+ mkdirSync(codehereDir, { recursive: true });
443
+ }
444
+ else if (!statSync(codehereDir).isDirectory()) {
445
+ return { ok: false, reason: 'codehere-not-a-directory', ...empty, episodic: noEpisodic, gitignore: { result: 'skipped' } };
446
+ }
447
+ // F3 layout: ensure all directories exist before writing any seeds.
448
+ // Per-segment symlink check on EACH directory's ancestors; if .codehere/memory
449
+ // (or skills, or protocols, etc.) is a symlink, refuse to mkdir into it.
450
+ const refusedDirs = [];
451
+ for (const dir of F3_DIRECTORIES) {
452
+ const offending = findSymlinkAncestor(codehereDir, dir);
453
+ const fullPath = join(codehereDir, dir);
454
+ if (offending !== null || isSymlink(fullPath)) {
455
+ refusedDirs.push(dir);
456
+ continue;
457
+ }
458
+ mkdirSync(fullPath, { recursive: true });
459
+ }
460
+ const created = [];
461
+ const skipped = [];
462
+ const refused = [];
463
+ // v0.4.1 templates first (CONTEXT/HANDOVER/PROMPTS at the top level).
464
+ for (const tmpl of TEMPLATES) {
465
+ const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
466
+ if (outcome === 'created')
467
+ created.push(tmpl.filename);
468
+ else if (outcome === 'skipped')
469
+ skipped.push(tmpl.filename);
470
+ else
471
+ refused.push(tmpl.filename);
472
+ }
473
+ // F3 v0.5 seed files (memory layers + skills + protocols).
474
+ for (const tmpl of F3_TEMPLATES) {
475
+ const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
476
+ if (outcome === 'created')
477
+ created.push(tmpl.filename);
478
+ else if (outcome === 'skipped')
479
+ skipped.push(tmpl.filename);
480
+ else
481
+ refused.push(tmpl.filename);
482
+ }
483
+ // F3 Task 3 — episodic AGENT_LEARNINGS.jsonl symlink to user's session log.
484
+ const episodic = ensureEpisodicSymlink(codehereDir, resolveHome());
485
+ const gitignore = options.noGitignore ? { result: 'skipped' } : ensureGitignore(root);
486
+ return { ok: true, created, skipped, refused, refusedDirs, episodic, gitignore };
487
+ }
433
488
  export function executeInitCommand(options = {}) {
434
489
  try {
435
490
  const { root, type } = resolveTargetRoot();
436
- const codehereDir = join(root, '.codehere');
437
491
  newline();
438
492
  console.log(colors.bold('codehere init'));
439
493
  console.log(colors.dim(` scaffolding .codehere/ at ${type} root: ${root}`));
440
494
  newline();
441
- // Refuse if .codehere itself is a symlink — would redirect every subsequent write.
442
- if (existsSync(codehereDir) && isSymlink(codehereDir)) {
443
- console.log(` ${error('refused')} .codehere is a symlink — refusing to follow. Remove it first.`);
495
+ const r = scaffoldProjectAt(root, options);
496
+ if (!r.ok) {
497
+ if (r.reason === 'codehere-dir-is-symlink') {
498
+ console.log(` ${error('refused')} .codehere is a symlink — refusing to follow. Remove it first.`);
499
+ }
500
+ else {
501
+ console.log(` ${error('refused')} .codehere exists but is not a directory.`);
502
+ }
444
503
  newline();
445
504
  process.exitCode = 1;
446
505
  return;
447
506
  }
448
- if (!existsSync(codehereDir)) {
449
- mkdirSync(codehereDir, { recursive: true });
450
- }
451
- else {
452
- const codehereStat = statSync(codehereDir);
453
- if (!codehereStat.isDirectory()) {
454
- console.log(` ${error('refused')} .codehere exists but is not a directory.`);
455
- newline();
456
- process.exitCode = 1;
457
- return;
458
- }
459
- }
460
- // F3 layout: ensure all directories exist before writing any seeds.
461
- // Per-segment symlink check on EACH directory's ancestors; if .codehere/memory
462
- // (or skills, or protocols, etc.) is a symlink, refuse to mkdir into it.
463
- const refusedDirs = [];
464
- for (const dir of F3_DIRECTORIES) {
465
- const offending = findSymlinkAncestor(codehereDir, dir);
466
- const fullPath = join(codehereDir, dir);
467
- if (offending !== null || isSymlink(fullPath)) {
468
- refusedDirs.push(dir);
469
- continue;
470
- }
471
- mkdirSync(fullPath, { recursive: true });
472
- }
473
- const created = [];
474
- const skipped = [];
475
- const refused = [];
476
- // v0.4.1 templates first (CONTEXT/HANDOVER/PROMPTS at the top level).
477
- for (const tmpl of TEMPLATES) {
478
- const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
479
- if (outcome === 'created')
480
- created.push(tmpl.filename);
481
- else if (outcome === 'skipped')
482
- skipped.push(tmpl.filename);
483
- else
484
- refused.push(tmpl.filename);
485
- }
486
- // F3 v0.5 seed files (memory layers + skills + protocols).
487
- for (const tmpl of F3_TEMPLATES) {
488
- const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
489
- if (outcome === 'created')
490
- created.push(tmpl.filename);
491
- else if (outcome === 'skipped')
492
- skipped.push(tmpl.filename);
493
- else
494
- refused.push(tmpl.filename);
495
- }
507
+ const { created, skipped, refused, refusedDirs } = r;
496
508
  for (const f of created) {
497
509
  console.log(` ${success(`created`)} .codehere/${f}`);
498
510
  }
@@ -506,8 +518,7 @@ export function executeInitCommand(options = {}) {
506
518
  console.log(` ${error(`refused`)} .codehere/${d}/ ${colors.dim('(intermediate symlink — remove it first)')}`);
507
519
  }
508
520
  // F3 Task 3 — episodic AGENT_LEARNINGS.jsonl symlink to user's session log.
509
- const home = resolveHome();
510
- const episodic = ensureEpisodicSymlink(codehereDir, home);
521
+ const episodic = r.episodic;
511
522
  switch (episodic.result) {
512
523
  case 'created':
513
524
  console.log(` ${success('linked ')} .codehere/memory/episodic/AGENT_LEARNINGS.jsonl → ${episodic.targetPath}`);
@@ -529,7 +540,7 @@ export function executeInitCommand(options = {}) {
529
540
  break;
530
541
  }
531
542
  if (!options.noGitignore) {
532
- const gitignoreResult = ensureGitignore(root);
543
+ const gitignoreResult = r.gitignore;
533
544
  newline();
534
545
  switch (gitignoreResult.result) {
535
546
  case 'updated':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",