codehere 0.5.0-alpha.2 → 0.5.0-alpha.4

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Final Report — PM-UX Layer 3 · MVP (v0.5.0-alpha.3+).
3
+ *
4
+ * At task exit, generates a 3-paragraph plain-English summary of what the
5
+ * agent did, what was touched, and what the user should look at. Prints to
6
+ * stdout so the dogfood-while-building loop sees the artifact immediately.
7
+ *
8
+ * Provider strategy (MVP — try free first, fall back to paid):
9
+ * - Tries `claude-free --bare -p "<prompt>"` first. The `claude-free`
10
+ * wrapper is what actually routes through the FCC proxy at :8082
11
+ * (verified 2026-05-02 dogfood: plain `claude --bare` does NOT route
12
+ * through FCC — it hits paid Claude. The May 1 memory rule was wrong.)
13
+ * - Falls back to `claude --bare -p "<prompt>"` if `claude-free` is not
14
+ * installed (paid path; will fail gracefully if no credit).
15
+ * - If neither binary is installed, returns null silently (no report).
16
+ *
17
+ * Env override: set CODEHERE_REPORT_BINARY=<binary-name> to force a
18
+ * specific provider (e.g. for testing or non-default setups).
19
+ *
20
+ * Iterations 5-9 (2026-05-02) refuted the "we own the supervisory layer"
21
+ * thesis. Final Report is the keystone delivery moment per the v3 chain
22
+ * (claim_market_position) — it's the artifact the dogfood loop produces
23
+ * that none of the surveyed competitors (ComposioHQ, Overstory, Praktor,
24
+ * Claude Squad, AI-Agents-Orchestrator) advertise.
25
+ */
26
+ import { spawn } from 'child_process';
27
+ const PROMPT_TEMPLATE = (i) => `You are a PM reporting back to a developer about what their AI coding agent
28
+ just did. Write 3 short paragraphs in plain English. No headers, no bullet
29
+ lists, no markdown — just three paragraphs separated by blank lines.
30
+
31
+ Paragraph 1: What the agent DID. Verb-led. Name files specifically. 1-3 sentences.
32
+ Paragraph 2: What was TOUCHED. Files modified, line counts, anything that looks risky. 1-3 sentences.
33
+ Paragraph 3: What the developer should LOOK AT. Open concerns, things to verify.
34
+ If everything looks clean, say so explicitly. 1-2 sentences.
35
+
36
+ Be specific. Don't pad. Don't editorialize. Don't praise the agent.
37
+
38
+ The task was: ${i.prompt}
39
+ The agent used: ${i.agent}
40
+ Duration: ${i.durationMs}ms
41
+ Status: ${i.status}
42
+
43
+ Steps the agent took:
44
+ ${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') : '(no structured steps captured)'}
45
+
46
+ Files changed:
47
+ ${i.artifacts?.files?.length
48
+ ? i.artifacts.files.map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`).join('\n')
49
+ : '(no file artifacts captured)'}
50
+
51
+ Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
52
+
53
+ Now write the 3 paragraphs.`;
54
+ const REPORT_TIMEOUT_MS = 30_000;
55
+ /** Provider chain: try free first, then paid. Override with CODEHERE_REPORT_BINARY. */
56
+ function selectProviderBinary() {
57
+ const override = process.env.CODEHERE_REPORT_BINARY;
58
+ if (override)
59
+ return [override];
60
+ return ['claude-free', 'claude'];
61
+ }
62
+ /**
63
+ * Generate a Final Report. Returns null if no provider is reachable.
64
+ * Never throws — failure modes return null + log a debug line.
65
+ */
66
+ export async function generateFinalReport(input) {
67
+ const prompt = PROMPT_TEMPLATE(input);
68
+ for (const binary of selectProviderBinary()) {
69
+ const result = await tryProvider(binary, prompt, input.sessionId);
70
+ if (result)
71
+ return result;
72
+ }
73
+ return null;
74
+ }
75
+ function tryProvider(binary, prompt, sessionId) {
76
+ const startedAt = Date.now();
77
+ return new Promise((resolve) => {
78
+ let stdout = '';
79
+ let stderr = '';
80
+ let settled = false;
81
+ const finish = (result) => {
82
+ if (settled)
83
+ return;
84
+ settled = true;
85
+ resolve(result);
86
+ };
87
+ const child = spawn(binary, ['--bare', '-p', prompt], {
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ });
90
+ const timeout = setTimeout(() => {
91
+ child.kill('SIGTERM');
92
+ if (process.env.CODEHERE_DEBUG === '1') {
93
+ console.error(`[final-report] ${binary} timeout after ${REPORT_TIMEOUT_MS}ms for ${sessionId}`);
94
+ }
95
+ finish(null);
96
+ }, REPORT_TIMEOUT_MS);
97
+ child.on('error', (err) => {
98
+ clearTimeout(timeout);
99
+ // ENOENT = binary not installed. Silent fallback to next provider in chain.
100
+ if (process.env.CODEHERE_DEBUG === '1') {
101
+ console.error(`[final-report] ${binary} unavailable: ${err.message}`);
102
+ }
103
+ finish(null);
104
+ });
105
+ child.stdout?.on('data', (chunk) => {
106
+ stdout += chunk.toString('utf8');
107
+ });
108
+ child.stderr?.on('data', (chunk) => {
109
+ stderr += chunk.toString('utf8');
110
+ });
111
+ child.on('exit', (code) => {
112
+ clearTimeout(timeout);
113
+ if (code !== 0) {
114
+ if (process.env.CODEHERE_DEBUG === '1') {
115
+ console.error(`[final-report] ${binary} exited ${code}: ${stderr.slice(0, 200)}`);
116
+ }
117
+ finish(null);
118
+ return;
119
+ }
120
+ const summary = stdout.trim();
121
+ if (!summary) {
122
+ finish(null);
123
+ return;
124
+ }
125
+ finish({ summary, generatedAt: Date.now(), durationMs: Date.now() - startedAt });
126
+ });
127
+ });
128
+ }
129
+ /**
130
+ * Pretty-print to terminal. Used by session-manager when a spawned task exits.
131
+ * Wrapped in try/catch upstream — never let a print failure surface.
132
+ */
133
+ export function printFinalReportToTerminal(input, output) {
134
+ const banner = '─'.repeat(60);
135
+ process.stdout.write('\n');
136
+ process.stdout.write(`${banner}\n`);
137
+ process.stdout.write(`Final Report · ${input.agent} · ${input.sessionId.slice(0, 8)}\n`);
138
+ process.stdout.write(`${banner}\n`);
139
+ process.stdout.write(`${output.summary}\n`);
140
+ process.stdout.write(`${banner}\n`);
141
+ process.stdout.write(`(generated in ${output.durationMs}ms)\n`);
142
+ process.stdout.write('\n');
143
+ }
144
+ //# sourceMappingURL=generate-final-report.js.map
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { Command } from 'commander';
14
14
  import { config } from 'dotenv';
15
15
  import { join, dirname } from 'path';
16
16
  import { fileURLToPath } from 'url';
17
+ import { homedir } from 'os';
17
18
  import { executeSetupCommand } from './presentation/cli/commands/setup-command.js';
18
19
  import { executeInitCommand } from './presentation/cli/commands/init-command.js';
19
20
  import { executeMemoryCommand } from './presentation/cli/commands/memory-command.js';
@@ -139,7 +140,7 @@ program
139
140
  // trust-scorer pattern bank to .codehere/protocols/hook_patterns.json so
140
141
  // auditors / cross-tool consumers can read codehere's safety surface.
141
142
  program
142
- .command('protocols')
143
+ .command('protocols', { hidden: true }) // hidden from --help (May 2 cut: power-user F3 export, see docs/MEMORY.md)
143
144
  .description('F3 protocols layer · export (write hook_patterns.json from trust-scorer bank)')
144
145
  .argument('[action]', 'Action: export, help')
145
146
  .option('--rebuild', 'Refresh built-in patterns (project patterns are preserved)')
@@ -345,7 +346,7 @@ program
345
346
  // ── serve (MCP server) ───────────────────────────────────────────────────────
346
347
  program
347
348
  .command('serve')
348
- .description('Start codehere as an MCP server — expose trust tools to Claude Desktop, Cursor, and any MCP client')
349
+ .description('Run codehere as a background MCP server (no web UI) — for Claude Desktop / Cursor integration')
349
350
  .option('-p, --port <number>', 'Port to listen on', '3773')
350
351
  .option('--host <host>', 'Host to bind to (default: 127.0.0.1)', '127.0.0.1')
351
352
  .option('--token <token>', 'Require Bearer token for authentication')
@@ -406,7 +407,7 @@ program
406
407
  // ── start (Mission Control: daemon + web UI) ─────────────────────────────────
407
408
  program
408
409
  .command('start')
409
- .description('Start the codehere daemon — task intake UI + MCP server at localhost:3773')
410
+ .description('Start codehere — interactive PM dashboard at localhost:3773 (delegate tasks, watch agents work)')
410
411
  .option('-p, --port <number>', 'Port to listen on', '3773')
411
412
  .option('--host <host>', 'Host to bind to', '127.0.0.1')
412
413
  .option('--no-open', "Don't auto-open the browser")
@@ -428,6 +429,25 @@ program
428
429
  }
429
430
  };
430
431
  try {
432
+ // v0.6.0 M1+M7 — migrate sessions.jsonl → ~/.codehere/projects/<slug>/thread.jsonl
433
+ // on daemon start. Idempotent: skips projects whose thread.jsonl already exists,
434
+ // so this is safe to run on every boot. Opt-out via CODEHERE_DISABLE_THREAD_MIGRATION=1.
435
+ // Failures are non-blocking — back-compat to v0.5 sessions.jsonl is preserved.
436
+ if (process.env.CODEHERE_DISABLE_THREAD_MIGRATION !== '1') {
437
+ try {
438
+ const { migrate } = await import('./infrastructure/storage/project-thread-migration.js');
439
+ const result = migrate({
440
+ sourceJsonl: join(homedir(), '.codehere/sessions.jsonl'),
441
+ targetDir: join(homedir(), '.codehere/projects'),
442
+ });
443
+ if (result.threadsWritten > 0) {
444
+ console.log(colors.dim(` v0.6 migration: created ${result.threadsWritten} project thread(s) from ${result.sessionsProcessed} sessions (${result.durationMs}ms)`));
445
+ }
446
+ }
447
+ catch (err) {
448
+ console.warn(colors.dim(` v0.6 migration skipped: ${err instanceof Error ? err.message : String(err)}`));
449
+ }
450
+ }
431
451
  const { startMCPServer } = await import('./infrastructure/protocols/mcp-server-impl.js');
432
452
  const server = startMCPServer({ port, host });
433
453
  // Wait for the server to either start listening or fail. The default
@@ -597,9 +617,44 @@ program
597
617
  r.end();
598
618
  });
599
619
  });
620
+ // ── migrate-threads — v0.6.0 schema migration sessions.jsonl → projects/<slug>/thread.jsonl
621
+ program
622
+ .command('migrate-threads', { hidden: true }) // hidden from --help (v0.6.0 internal — auto-runs on daemon start once wired)
623
+ .description('Migrate sessions.jsonl into per-project thread.jsonl files (v0.6.0 schema)')
624
+ .option('--source <path>', 'Source sessions.jsonl', `${homedir()}/.codehere/sessions.jsonl`)
625
+ .option('--target <dir>', 'Target projects dir', `${homedir()}/.codehere/projects`)
626
+ .option('--dry-run', 'Report what migrate WOULD do, write nothing', false)
627
+ .option('--rollback', 'Remove the projects dir; sessions.jsonl untouched', false)
628
+ .option('--keep-temp-dirs', 'Include /tmp + /var/folders cwds (default skips them)', false)
629
+ .action(async (options) => {
630
+ const { migrate, dryRun, rollback } = await import('./infrastructure/storage/project-thread-migration.js');
631
+ if (options.rollback) {
632
+ const r = rollback(options.target);
633
+ console.log(`Rollback: removed ${r.removedDirs} project dir(s) from ${options.target}`);
634
+ console.log(`Source ${options.source} untouched.`);
635
+ return;
636
+ }
637
+ const fn = options.dryRun ? dryRun : migrate;
638
+ const label = options.dryRun ? 'DRY RUN' : 'MIGRATION';
639
+ const result = fn({
640
+ sourceJsonl: options.source,
641
+ targetDir: options.target,
642
+ skipTempDirs: !options.keepTempDirs,
643
+ });
644
+ console.log(`=== ${label} ===`);
645
+ console.log(` source: ${options.source}`);
646
+ console.log(` target: ${options.target}`);
647
+ console.log(` sessions processed: ${result.sessionsProcessed}`);
648
+ console.log(` threads ${options.dryRun ? 'would write' : 'written'}: ${result.threadsWritten}`);
649
+ console.log(` threads skipped (exist): ${result.threadsSkippedExisting}`);
650
+ console.log(` unknown-cwd sessions: ${result.unknownCwdSessions}`);
651
+ console.log(` abandoned sessions: ${result.abandonedSessions}`);
652
+ console.log(` temp-dir sessions skipped: ${result.tempDirSessionsSkipped}`);
653
+ console.log(` duration: ${result.durationMs}ms`);
654
+ });
600
655
  // ── scan-tool (called by Claude Code hooks system, not users directly) ───────
601
656
  program
602
- .command('scan-tool')
657
+ .command('scan-tool', { hidden: true }) // hidden from --help (May 2 cut: internal hook handler, called by Claude Code via stdin)
603
658
  .description('Hook handler: reads Claude Code tool event from stdin, outputs block/allow (used by hook system)')
604
659
  .option('--log-only', 'Never block, log only')
605
660
  .option('--block-all', 'Block on any finding, not just critical')
@@ -613,7 +668,7 @@ program
613
668
  });
614
669
  // ── completion (shell completion script generator) ──────────────────────────
615
670
  program
616
- .command('completion')
671
+ .command('completion', { hidden: true }) // hidden from --help (May 2 cut: shell-completion is npm convention, not product surface)
617
672
  .description('Generate shell completion script')
618
673
  .argument('<shell>', 'Shell type: bash, zsh, or fish')
619
674
  .action(async (shell) => {
@@ -221,6 +221,12 @@ export function spawnClaudeCode(prompt, options = {}) {
221
221
  }
222
222
  // ── stdout: newline-delimited JSON from `--output-format stream-json` ────────
223
223
  let stdoutBuf = '';
224
+ // Iter 13 — capture the claude-internal session_id from the init event so
225
+ // appendMessage can use `--resume <session-id>` (reliable) instead of `-c`
226
+ // (which picks "most recent in cwd" and can collide with other concurrent
227
+ // claude sessions OR with conversations containing thinking blocks the
228
+ // headless API can't replay).
229
+ let claudeSessionId;
224
230
  child.stdout?.on('data', (chunk) => {
225
231
  stdoutBuf += chunk.toString('utf-8');
226
232
  let idx;
@@ -231,6 +237,12 @@ export function spawnClaudeCode(prompt, options = {}) {
231
237
  continue;
232
238
  try {
233
239
  const data = JSON.parse(line);
240
+ if (claudeSessionId === undefined &&
241
+ data.type === 'system' &&
242
+ data.subtype === 'init' &&
243
+ typeof data.session_id === 'string') {
244
+ claudeSessionId = data.session_id;
245
+ }
234
246
  emit({ type: data.type ?? 'message', timestamp: Date.now(), data });
235
247
  }
236
248
  catch {
@@ -284,6 +296,7 @@ export function spawnClaudeCode(prompt, options = {}) {
284
296
  startedAt,
285
297
  prompt,
286
298
  agent: 'claude-code',
299
+ canAppendMessage: true,
287
300
  memoryInjection: injected.record,
288
301
  get status() {
289
302
  return statusValue;
@@ -317,6 +330,77 @@ export function spawnClaudeCode(prompt, options = {}) {
317
330
  }
318
331
  }, 3000);
319
332
  },
333
+ /**
334
+ * Iter 12 (with iter-13 fix) — follow-up message to a completed session.
335
+ *
336
+ * Iter 12 used `claude -p "<text>" -c` which picks "most recent
337
+ * conversation in current directory". Iter 13 dogfood proved this
338
+ * fragile: in a cwd with multiple concurrent claude sessions OR with
339
+ * conversations containing thinking blocks, -c either picks the wrong
340
+ * session or hits an API-replay error ("Invalid signature in thinking
341
+ * block"). Fix: capture the claude session_id from this session's init
342
+ * event (above) and use `--resume <session-id>` for follow-ups —
343
+ * unambiguous and replay-safe.
344
+ *
345
+ * Scope: still one-shot Q&A. No streaming. Returns assistant's response
346
+ * text. If we never captured a session_id (init event never fired —
347
+ * shouldn't happen in healthy claude runs), reject explicitly.
348
+ */
349
+ appendMessage(text) {
350
+ const startedAt = Date.now();
351
+ return new Promise((resolve, reject) => {
352
+ if (!claudeSessionId) {
353
+ reject(new Error('Cannot reply: original claude session_id was never captured (init event missed)'));
354
+ return;
355
+ }
356
+ let stdout = '';
357
+ let stderr = '';
358
+ const followup = spawn('claude', ['-p', text, '--resume', claudeSessionId, ...HEADLESS_FLAGS], {
359
+ cwd,
360
+ env: spawnEnv,
361
+ stdio: ['ignore', 'pipe', 'pipe'],
362
+ });
363
+ followup.on('error', (err) => reject(err));
364
+ followup.stdout?.on('data', (chunk) => {
365
+ stdout += chunk.toString('utf-8');
366
+ });
367
+ followup.stderr?.on('data', (chunk) => {
368
+ stderr += chunk.toString('utf-8');
369
+ });
370
+ followup.on('exit', (code) => {
371
+ if (code !== 0) {
372
+ reject(new Error(`claude -c exited ${code}: ${stderr.slice(0, 200)}`));
373
+ return;
374
+ }
375
+ // Parse stream-json — extract assistant message text from the
376
+ // 'assistant' typed lines and the final 'result' line.
377
+ const lines = stdout.split('\n').filter((l) => l.trim());
378
+ const fragments = [];
379
+ for (const line of lines) {
380
+ try {
381
+ const obj = JSON.parse(line);
382
+ if (obj.type === 'assistant' && obj.message?.content) {
383
+ for (const block of obj.message.content) {
384
+ if (block.type === 'text' && block.text)
385
+ fragments.push(block.text);
386
+ }
387
+ }
388
+ else if (obj.type === 'result' && typeof obj.result === 'string') {
389
+ fragments.push(obj.result);
390
+ }
391
+ }
392
+ catch {
393
+ // Non-JSON line — skip
394
+ }
395
+ }
396
+ // Dedupe trivially: if 'result' contained the same text as the
397
+ // last assistant block, take just one. Conservative: prefer 'result'
398
+ // if present (it's the final summary), else join assistants.
399
+ const result = fragments.length ? fragments[fragments.length - 1] : '(no response captured)';
400
+ resolve({ result, durationMs: Date.now() - startedAt });
401
+ });
402
+ });
403
+ },
320
404
  };
321
405
  }
322
406
  // ── AgentAdapter implementation ─────────────────────────────────────────────
@@ -23,6 +23,7 @@ import { buildConductorAdapter } from './conductor-adapter.js';
23
23
  import { seedAgentsConfig } from './agents-config-seed.js';
24
24
  import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
25
25
  import { appendSpawned, appendCompleted, loadRecent } from './session-persistence.js';
26
+ import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
26
27
  // ── Agent registry ─────────────────────────────────────────────────────────────
27
28
  // All backends codehere can spawn. Order here drives the default UI dropdown
28
29
  // order. Built-in adapters first; user-config-driven ones appended from
@@ -96,6 +97,18 @@ export class SessionManager {
96
97
  * These subprocesses are long-dead; we surface them so the user doesn't lose
97
98
  * their Final reports across restarts. */
98
99
  persistedCache = loadRecent(20);
100
+ /** When a spawned session exits, we capture the completion timestamp here
101
+ * so the API layer can show duration on still-in-memory sessions (the
102
+ * TaskSession interface intentionally has no completedAt; only PersistedSession
103
+ * does, so anything reading session.completedAt got undefined pre-fix).
104
+ * Surfaced May 2 by the cold-start dogfood — `duration: NaNs` in /api/tasks
105
+ * for completed-but-not-yet-restarted sessions. Bug 2 sister-fix. */
106
+ completedAtBySession = new Map();
107
+ /** PM-UX Layer 3 · Final Report cache (iteration 11 MVP, UI-integration iter 12).
108
+ * Populated when generateFinalReport resolves in the exit handler. Web UI
109
+ * reads via getFinalReport() through the /api/tasks serializer so non-technical
110
+ * users see the prose summary without opening a terminal. */
111
+ finalReportBySession = new Map();
99
112
  spawn(prompt, agentId = 'claude-code', options) {
100
113
  const adapter = getAdapter(agentId);
101
114
  if (!adapter) {
@@ -121,14 +134,50 @@ export class SessionManager {
121
134
  if (event.type !== 'exit')
122
135
  return;
123
136
  const data = event.data;
137
+ const completedAt = Date.now();
138
+ // Surface completedAt to API consumers reading the still-in-memory
139
+ // session (Bug 2 sister-fix, May 2). Without this, /api/tasks shows
140
+ // status=completed but no completedAt → NaN duration in the UI.
141
+ this.completedAtBySession.set(session.sessionId, completedAt);
124
142
  appendCompleted({
125
143
  id: session.sessionId,
126
144
  status: session.status,
127
- durationMs: Number(data?.durationMs ?? Date.now() - session.startedAt),
128
- completedAt: Date.now(),
145
+ durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
146
+ completedAt,
129
147
  steps: session.steps ? [...session.steps] : undefined,
130
148
  artifacts: session.artifacts,
131
149
  });
150
+ // PM-UX Layer 3 · Final Report MVP. Fire-and-forget; the report appears
151
+ // in the terminal a few seconds after exit. Failure modes return null
152
+ // silently (claude CLI not installed, timeout, non-zero exit). No SSE,
153
+ // no UI integration in this MVP — terminal output only so the dogfood
154
+ // loop sees the artifact immediately. See `generate-final-report.ts`.
155
+ const reportInput = {
156
+ sessionId: session.sessionId,
157
+ prompt: session.prompt,
158
+ agent: session.agent,
159
+ status: session.status,
160
+ durationMs: completedAt - session.startedAt,
161
+ steps: session.steps ? [...session.steps] : [],
162
+ artifacts: session.artifacts,
163
+ };
164
+ generateFinalReport(reportInput)
165
+ .then((report) => {
166
+ if (report) {
167
+ // Cache for web UI consumption via /api/tasks (iter 12 — non-technical
168
+ // users live in the web UI, not the terminal).
169
+ this.finalReportBySession.set(session.sessionId, report);
170
+ try {
171
+ printFinalReportToTerminal(reportInput, report);
172
+ }
173
+ catch {
174
+ // Don't let a print failure crash the daemon.
175
+ }
176
+ }
177
+ })
178
+ .catch(() => {
179
+ // generateFinalReport never throws by contract; defensive only.
180
+ });
132
181
  unsubscribe();
133
182
  });
134
183
  return session;
@@ -137,6 +186,20 @@ export class SessionManager {
137
186
  listPersisted() {
138
187
  return this.persistedCache;
139
188
  }
189
+ /** Completion timestamp for an in-memory session, if it has exited.
190
+ * Returns undefined if the session is still running OR was never spawned
191
+ * through this manager. Bug 2 sister-fix (May 2): TaskSession itself has
192
+ * no completedAt field; this method lets the API layer compute durations. */
193
+ getCompletedAt(sessionId) {
194
+ return this.completedAtBySession.get(sessionId);
195
+ }
196
+ /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
197
+ * Returns undefined while generation is in flight OR if the provider chain
198
+ * failed (no claude-free, no claude, no credit, etc). The web UI reads this
199
+ * through the /api/tasks serializer to render the prose summary inline. */
200
+ getFinalReport(sessionId) {
201
+ return this.finalReportBySession.get(sessionId);
202
+ }
140
203
  get(sessionId) {
141
204
  return this.sessions.get(sessionId);
142
205
  }
@@ -538,6 +538,11 @@ export function startMCPServer(options = {}) {
538
538
  startedAt: session.startedAt,
539
539
  status: session.status,
540
540
  agent: session.agent,
541
+ // Iter 13 — include canAppendMessage so the freshly-attached
542
+ // session card can correctly enable/disable reply input on the
543
+ // first render. Without this, ALL just-spawned sessions render
544
+ // with reply disabled even when the adapter supports it.
545
+ canAppendMessage: session.canAppendMessage === true,
541
546
  }));
542
547
  }
543
548
  catch (e) {
@@ -548,16 +553,29 @@ export function startMCPServer(options = {}) {
548
553
  return;
549
554
  }
550
555
  if (req.method === 'GET' && req.url === '/api/tasks') {
551
- const spawned = manager.list().map((s) => ({
552
- sessionId: s.sessionId,
553
- prompt: s.prompt,
554
- startedAt: s.startedAt,
555
- status: s.status,
556
- agent: s.agent,
557
- steps: s.steps ?? [],
558
- artifacts: s.artifacts,
559
- provenance: 'spawned',
560
- }));
556
+ const spawned = manager.list().map((s) => {
557
+ // Bug 2 sister-fix (May 2): include completedAt for in-memory exited
558
+ // sessions so the UI can compute duration. TaskSession interface
559
+ // intentionally lacks completedAt; SessionManager tracks it separately
560
+ // in completedAtBySession map (populated when exit event fires).
561
+ const completedAt = manager.getCompletedAt(s.sessionId);
562
+ const finalReport = manager.getFinalReport(s.sessionId);
563
+ return {
564
+ sessionId: s.sessionId,
565
+ prompt: s.prompt,
566
+ startedAt: s.startedAt,
567
+ status: s.status,
568
+ agent: s.agent,
569
+ steps: s.steps ?? [],
570
+ artifacts: s.artifacts,
571
+ ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
572
+ ...(finalReport !== undefined ? { finalReport } : {}),
573
+ // Iter 12 — surface canAppendMessage so the UI knows whether to
574
+ // render the reply input or a disabled "follow-up unsupported" state.
575
+ canAppendMessage: s.canAppendMessage === true,
576
+ provenance: 'spawned',
577
+ };
578
+ });
561
579
  const activeAgents = manager.listActiveAgents();
562
580
  const persisted = manager.listPersisted();
563
581
  // Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
@@ -581,7 +599,7 @@ export function startMCPServer(options = {}) {
581
599
  return;
582
600
  }
583
601
  // /api/task/:id/events or /api/task/:id/kill
584
- const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill)$/);
602
+ const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
585
603
  if (m) {
586
604
  const [, sessionId, verb] = m;
587
605
  const session = manager.get(sessionId);
@@ -612,6 +630,38 @@ export function startMCPServer(options = {}) {
612
630
  res.end(JSON.stringify({ sessionId, status: session.status }));
613
631
  return;
614
632
  }
633
+ // Iter 12 — POST /api/task/:id/message — follow-up message to a
634
+ // completed session. The reply-to-thread surface in the web UI calls
635
+ // this. Body: { text }. Response: { result, durationMs }.
636
+ if (verb === 'message' && req.method === 'POST') {
637
+ if (!session.canAppendMessage || !session.appendMessage) {
638
+ res.writeHead(400, { 'Content-Type': 'application/json' });
639
+ res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
640
+ return;
641
+ }
642
+ let body = '';
643
+ req.on('data', (c) => { body += c.toString('utf-8'); });
644
+ req.on('end', async () => {
645
+ try {
646
+ const parsed = JSON.parse(body || '{}');
647
+ const text = (parsed.text ?? '').trim();
648
+ if (!text) {
649
+ res.writeHead(400, { 'Content-Type': 'application/json' });
650
+ res.end(JSON.stringify({ error: 'text is required' }));
651
+ return;
652
+ }
653
+ const out = await session.appendMessage(text);
654
+ res.writeHead(200, { 'Content-Type': 'application/json' });
655
+ res.end(JSON.stringify({ sessionId, ...out }));
656
+ }
657
+ catch (err) {
658
+ const msg = err instanceof Error ? err.message : String(err);
659
+ res.writeHead(500, { 'Content-Type': 'application/json' });
660
+ res.end(JSON.stringify({ error: msg }));
661
+ }
662
+ });
663
+ return;
664
+ }
615
665
  }
616
666
  res.writeHead(405, { 'Content-Type': 'application/json' });
617
667
  res.end(JSON.stringify({ error: 'Method not allowed for this path' }));
@@ -253,6 +253,31 @@ const SHARED_STYLES = `
253
253
  color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.12em; font-size: 10px;
254
254
  margin-bottom: 8px;
255
255
  }
256
+ /* Iter 12 — prose summary block. Plain English for non-technical users.
257
+ Sans-serif breaks the monospace context so it reads as narrative, not data. */
258
+ .final-report .fr-summary {
259
+ font-family: var(--sans);
260
+ font-size: 13px;
261
+ line-height: 1.55;
262
+ color: var(--fg);
263
+ margin-bottom: 12px;
264
+ padding-bottom: 12px;
265
+ border-bottom: 1px solid var(--border-soft);
266
+ }
267
+ .final-report .fr-summary p {
268
+ margin: 0 0 10px 0;
269
+ }
270
+ .final-report .fr-summary p:last-child { margin-bottom: 0; }
271
+ .final-report .fr-summary-pending p {
272
+ color: var(--fg-50);
273
+ font-style: italic;
274
+ animation: shimmer 1.6s linear infinite;
275
+ background: linear-gradient(90deg, var(--fg-30) 0%, var(--fg-70) 50%, var(--fg-30) 100%);
276
+ background-size: 200% auto;
277
+ -webkit-background-clip: text;
278
+ background-clip: text;
279
+ color: transparent;
280
+ }
256
281
  .final-report .fr-files { display: flex; flex-direction: column; gap: 3px; }
257
282
  .final-report .fr-file {
258
283
  display: grid;
@@ -1091,8 +1116,30 @@ ${SHARED_STYLES}
1091
1116
  color: var(--fg-50); font-size: 11px; font-family: var(--mono);
1092
1117
  display: flex; align-items: center; gap: 8px;
1093
1118
  }
1094
- .thread-input::before { content: '↳'; color: var(--fg-30); }
1095
- .thread-input .scaffold-note { color: var(--fg-30); font-size: 10px; margin-left: auto; }
1119
+ /* Iter 12 — reply surface now functional. Wraps textarea + send button. */
1120
+ .thread-input { padding: 8px; flex-direction: column; align-items: stretch; gap: 6px; }
1121
+ .thread-input::before { display: none; }
1122
+ .thread-input .reply-text {
1123
+ width: 100%; min-height: 38px; resize: vertical;
1124
+ background: transparent; border: 0; outline: none;
1125
+ color: var(--fg); font-family: var(--sans); font-size: 12px; line-height: 1.5;
1126
+ padding: 4px 0;
1127
+ }
1128
+ .thread-input .reply-text::placeholder { color: var(--fg-30); }
1129
+ .thread-input .reply-send {
1130
+ align-self: flex-end;
1131
+ padding: 5px 14px; border-radius: 6px;
1132
+ background: var(--accent-wash); color: var(--accent);
1133
+ border: 1px solid rgba(255,154,118,0.25);
1134
+ font-family: var(--sans); font-size: 11px; font-weight: 600;
1135
+ cursor: pointer;
1136
+ }
1137
+ .thread-input .reply-send:hover { background: rgba(255,154,118,0.18); }
1138
+ .thread-input .reply-send:disabled { opacity: 0.4; cursor: default; }
1139
+ .thread-input .reply-status { color: var(--fg-50); font-size: 10px; font-family: var(--mono); }
1140
+ .thread-input .reply-status.error { color: var(--block); }
1141
+ .thread-input.disabled .reply-text,
1142
+ .thread-input.disabled .reply-send { opacity: 0.5; cursor: not-allowed; }
1096
1143
 
1097
1144
  .view-toggle {
1098
1145
  display: inline-flex; gap: 0;
@@ -1165,7 +1212,7 @@ ${SHARED_STYLES}
1165
1212
  </div>
1166
1213
  </div>
1167
1214
  </div>
1168
- <button class="pane-add" type="button" title="Coming soon">+ New project</button>
1215
+ <!-- intentionally empty: multi-project sidebar arrives in v0.6.x -->
1169
1216
  </aside>
1170
1217
 
1171
1218
  <section class="thread-pane">
@@ -1459,11 +1506,37 @@ ${SHARED_STYLES}
1459
1506
  list.innerHTML = all.map((a) => {
1460
1507
  const cls = presenceCls(a.state);
1461
1508
  const glyph = presenceGlyph(a.state);
1462
- return '<div class="member" title="' + escape(a.state || 'unknown') + '">' +
1509
+ // Iter 12 — members are now clickable. Non-technical user expects
1510
+ // clicking an agent in the right pane to pick that agent for the
1511
+ // next task. Click handler below sets the agent <select> + scrolls
1512
+ // intake into view + focuses the prompt textarea.
1513
+ const stateTitle = a.state === 'ready' ? 'ready · click to select' :
1514
+ a.state === 'needs_install' ? 'needs install · click for setup' :
1515
+ a.state === 'needs_auth' ? 'needs auth · click for setup' :
1516
+ (a.state || 'unknown');
1517
+ return '<div class="member" data-agent-id="' + escape(a.id) + '" title="' + escape(stateTitle) + '">' +
1463
1518
  '<span class="member-presence ' + cls + '">' + glyph + '</span>' +
1464
1519
  '<span class="member-label">' + escape(a.label || a.id) + '</span>' +
1465
1520
  '</div>';
1466
1521
  }).join('');
1522
+ // Wire click → select-this-agent. One delegated handler is cheaper than
1523
+ // attaching one per item, and survives re-renders without leak.
1524
+ list.onclick = (ev) => {
1525
+ const item = ev.target && ev.target.closest && ev.target.closest('.member');
1526
+ if (!item) return;
1527
+ const id = item.getAttribute('data-agent-id');
1528
+ if (!id) return;
1529
+ // Set the agent dropdown to this agent. Trigger 'change' so the
1530
+ // updateSetupPanel code (existing) fires and shows install/auth help
1531
+ // for needs_install / needs_auth states.
1532
+ if (agentSelect) {
1533
+ agentSelect.value = id;
1534
+ try { agentSelect.dispatchEvent(new Event('change')); } catch { /* old browsers */ }
1535
+ }
1536
+ // Scroll intake into view + focus the prompt textarea.
1537
+ try { promptEl && promptEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* noop */ }
1538
+ if (promptEl) promptEl.focus();
1539
+ };
1467
1540
  }
1468
1541
 
1469
1542
  fetch('/api/readiness/agents').then(r => r.json()).then(d => {
@@ -1496,6 +1569,11 @@ ${SHARED_STYLES}
1496
1569
  localStorage.setItem('codehere:cwd', cwdInput.value.trim());
1497
1570
  });
1498
1571
 
1572
+ // Iter 13 — sidebar 'add project' button + handler removed (was iter 12 work).
1573
+ // The handler cleared cwd input + scrolled + focused, but the label promised
1574
+ // a new sidebar entry that we do not ship until v0.6.x multi-project work.
1575
+ // Mismatch caused real founder confusion in iter-13 tasting. Removed cleanly.
1576
+
1499
1577
  // Gate A1: starter-prompt chips prefill the textarea + focus it so the user
1500
1578
  // can edit or hit ⌘-Enter immediately. No scroll-jumping, no redirect.
1501
1579
  const starterPromptsEl = document.getElementById('starter-prompts');
@@ -1731,7 +1809,15 @@ ${SHARED_STYLES}
1731
1809
  // seed prompt, breaking chronological order in thread view.
1732
1810
  '<div class="thread-messages"></div>' +
1733
1811
  '<div class="final-report" style="display:none"></div>' +
1734
- '<div class="thread-input">Reply to thread <span class="scaffold-note">(coming soon)</span></div>' +
1812
+ // Iter 12 — reply surface now functional; replaced the prior dead
1813
+ // placeholder. Active for adapters with canAppendMessage=true
1814
+ // (claude-code today); disabled state for adapters that don't yet
1815
+ // implement appendMessage.
1816
+ '<div class="thread-input">' +
1817
+ '<textarea class="reply-text" placeholder="Reply to thread… (Cmd+Enter to send)" rows="2"></textarea>' +
1818
+ '<button class="reply-send" type="button">Send</button>' +
1819
+ '<div class="reply-status" style="display:none"></div>' +
1820
+ '</div>' +
1735
1821
  '<div class="event-log"></div>' +
1736
1822
  '</div>';
1737
1823
 
@@ -1746,6 +1832,57 @@ ${SHARED_STYLES}
1746
1832
  const finalReportEl = card.querySelector('.final-report');
1747
1833
  const threadEl = card.querySelector('.thread-messages');
1748
1834
  const bodyEl = card.querySelector('.session-body');
1835
+ // Iter 12 — reply-to-thread refs + handler.
1836
+ const threadInputEl = card.querySelector('.thread-input');
1837
+ const replyTextEl = card.querySelector('.reply-text');
1838
+ const replySendBtn = card.querySelector('.reply-send');
1839
+ const replyStatusEl = card.querySelector('.reply-status');
1840
+ if (!session.canAppendMessage) {
1841
+ threadInputEl.classList.add('disabled');
1842
+ replyTextEl.placeholder = 'Reply unsupported for ' + agentLabel(session.agent) + ' yet';
1843
+ replyTextEl.disabled = true;
1844
+ replySendBtn.disabled = true;
1845
+ } else {
1846
+ const sendReply = async () => {
1847
+ const text = (replyTextEl.value || '').trim();
1848
+ if (!text) return;
1849
+ replySendBtn.disabled = true;
1850
+ replySendBtn.textContent = '...';
1851
+ replyStatusEl.style.display = 'none';
1852
+ replyStatusEl.classList.remove('error');
1853
+ // Optimistic: render the user's message immediately in the thread.
1854
+ appendThreadMessage(threadMessage({
1855
+ cls: 'user', author: 'You', time: new Date(), content: text,
1856
+ }));
1857
+ replyTextEl.value = '';
1858
+ try {
1859
+ const r = await fetch('/api/task/' + session.sessionId + '/message', {
1860
+ method: 'POST',
1861
+ headers: { 'Content-Type': 'application/json' },
1862
+ body: JSON.stringify({ text }),
1863
+ });
1864
+ const data = await r.json();
1865
+ if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
1866
+ // Render assistant response in the thread.
1867
+ appendThreadMessage(threadMessage({
1868
+ cls: 'agent', author: agentLabel(session.agent), time: new Date(),
1869
+ content: String(data.result || '(empty response)'),
1870
+ }));
1871
+ } catch (err) {
1872
+ replyStatusEl.textContent = 'Reply failed: ' + (err && err.message ? err.message : String(err));
1873
+ replyStatusEl.classList.add('error');
1874
+ replyStatusEl.style.display = 'block';
1875
+ } finally {
1876
+ replySendBtn.disabled = false;
1877
+ replySendBtn.textContent = 'Send';
1878
+ replyTextEl.focus();
1879
+ }
1880
+ };
1881
+ replySendBtn.addEventListener('click', sendReply);
1882
+ replyTextEl.addEventListener('keydown', (ev) => {
1883
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') { ev.preventDefault(); sendReply(); }
1884
+ });
1885
+ }
1749
1886
  const safetyPillEl = card.querySelector('.safety-pill');
1750
1887
  const safetySafeEl = safetyPillEl.querySelector('.safe');
1751
1888
  const safetyBlockedEl = safetyPillEl.querySelector('.blocked');
@@ -1939,12 +2076,35 @@ ${SHARED_STYLES}
1939
2076
  // Layer 3: render the final report block when the task completes.
1940
2077
  // Principles #2 + #7 — density (files/tests/trust in one view) and
1941
2078
  // agent-provenance (the report attributes to a specific agent).
1942
- function renderArtifacts(artifacts) {
1943
- if (!artifacts) { finalReportEl.style.display = 'none'; return; }
2079
+ // Iter 12 — prose summary (3 paragraphs, plain English) prepended above
2080
+ // the artifact list. Non-technical users see prose first; raw artifact
2081
+ // detail (files/tests/tokens) below as supporting context.
2082
+ function renderArtifacts(artifacts, finalReport, status) {
2083
+ // Show the block if either prose summary OR artifacts exist OR if a
2084
+ // completed task is still generating its report (placeholder state).
2085
+ const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
2086
+ const hasContent = artifacts || finalReport;
2087
+ const isAwaitingReport = isCompleted && !finalReport;
2088
+ if (!hasContent && !isAwaitingReport) { finalReportEl.style.display = 'none'; return; }
1944
2089
  finalReportEl.style.display = 'block';
1945
- const files = Array.isArray(artifacts.files) ? artifacts.files : [];
1946
- const tests = artifacts.tests;
2090
+ const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
2091
+ const tests = artifacts?.tests;
1947
2092
  let html = '<div class="fr-label">Final report · ' + escape(agentLabel(session.agent)) + '</div>';
2093
+ if (finalReport && finalReport.summary) {
2094
+ // Plain-English prose. Split paragraphs on blank lines, render each as <p>.
2095
+ // NOTE: this code is inside a TS template literal — backslashes in
2096
+ // regex literals must be doubled so the served JS gets the right
2097
+ // sequence (test/cli/ui-served-html-syntax.test.ts guards this).
2098
+ const paragraphs = String(finalReport.summary).split(/\\n\\s*\\n/).filter(p => p.trim());
2099
+ html += '<div class="fr-summary">' +
2100
+ paragraphs.map(p => '<p>' + escape(p.trim()) + '</p>').join('') +
2101
+ '</div>';
2102
+ } else if (isAwaitingReport) {
2103
+ // Placeholder — task done, report still generating (or provider failed silently).
2104
+ // After ~30s with no report, the placeholder persists. Iter 13 will replace
2105
+ // with explicit "report unavailable — start FCC proxy or set ANTHROPIC_API_KEY".
2106
+ html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
2107
+ }
1948
2108
  if (files.length > 0) {
1949
2109
  html += '<div class="fr-files">' + files.map(f => {
1950
2110
  const lines = '<span class="add">+' + (f.added || 0) + '</span> <span class="rem">−' + (f.removed || 0) + '</span>';
@@ -1978,7 +2138,11 @@ ${SHARED_STYLES}
1978
2138
 
1979
2139
  // Hydrate from initial snapshot (late-joiner support + persisted sessions)
1980
2140
  if (Array.isArray(session.steps) && session.steps.length) renderSteps(session.steps);
1981
- if (session.artifacts) renderArtifacts(session.artifacts);
2141
+ // Render Final Report block if there's anything to show (prose summary,
2142
+ // artifacts, or "generating…" placeholder for a freshly-completed session).
2143
+ if (session.artifacts || session.finalReport || session.status === 'completed' || session.status === 'killed' || session.status === 'error') {
2144
+ renderArtifacts(session.artifacts, session.finalReport, session.status);
2145
+ }
1982
2146
 
1983
2147
  // Persisted sessions have no live event stream and no subprocess to kill.
1984
2148
  if (isPersisted) {
@@ -1994,7 +2158,7 @@ ${SHARED_STYLES}
1994
2158
 
1995
2159
  // Layer 2/3 synthetic events — handle before renderEvent (so they don't leak into the raw log)
1996
2160
  if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
1997
- if (event.type === 'codehere:artifacts') { renderArtifacts(event.data); return; }
2161
+ if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status); return; }
1998
2162
 
1999
2163
  // Any structured error flips the status pill immediately — don't wait for
2000
2164
  // the subprocess to exit. Many CLIs print a logical error to stdout and
@@ -2032,7 +2196,10 @@ ${SHARED_STYLES}
2032
2196
  killBtn.textContent = 'Kill';
2033
2197
  });
2034
2198
 
2035
- sessions.set(session.sessionId, { card, logEl, es });
2199
+ // Iter 12 — expose renderArtifacts so the polling loop can refresh the
2200
+ // Final Report block when the prose summary arrives async (~2-5s after
2201
+ // task exit). Without this, non-technical users would have to refresh.
2202
+ sessions.set(session.sessionId, { card, logEl, es, renderArtifacts });
2036
2203
  }
2037
2204
 
2038
2205
  async function assignTask() {
@@ -2051,7 +2218,7 @@ ${SHARED_STYLES}
2051
2218
  });
2052
2219
  const data = await res.json();
2053
2220
  if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
2054
- attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor' });
2221
+ attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
2055
2222
  promptEl.value = '';
2056
2223
  } catch (e) {
2057
2224
  alert('Network error: ' + (e && e.message ? e.message : e));
@@ -2181,7 +2348,10 @@ ${SHARED_STYLES}
2181
2348
  }).catch(() => { /* fine */ });
2182
2349
  }
2183
2350
  loadSessions();
2184
- // Refresh detected-agents list + banner every 5s
2351
+ // Refresh detected-agents list + banner every 5s. Iter 12 — also patches
2352
+ // existing session cards with new finalReport prose when it arrives async.
2353
+ // Cheap re-render (innerHTML on a small div); non-technical user sees the
2354
+ // report appear on its own, no manual refresh.
2185
2355
  setInterval(() => {
2186
2356
  fetch('/api/tasks').then(r => r.json()).then(d => {
2187
2357
  const seen = new Set();
@@ -2191,6 +2361,13 @@ ${SHARED_STYLES}
2191
2361
  }
2192
2362
  pruneStaleAgents(seen);
2193
2363
  updateBanner(d);
2364
+ // Patch existing session cards with finalReport when it arrives.
2365
+ for (const s of (d.spawned || d.sessions || [])) {
2366
+ const entry = sessions.get(s.sessionId);
2367
+ if (entry && entry.renderArtifacts) {
2368
+ entry.renderArtifacts(s.artifacts, s.finalReport, s.status);
2369
+ }
2370
+ }
2194
2371
  }).catch(() => { /* fine */ });
2195
2372
  }, 5000);
2196
2373
 
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Project thread migration — sessions.jsonl → projects/<slug>/thread.jsonl.
3
+ *
4
+ * v0.6.0 M1+M7 foundation. The new schema groups sessions by their cwd
5
+ * (project) so the persistent project thread shape (one continuous chat per
6
+ * project) has a place to live. The original sessions.jsonl is preserved
7
+ * for one release as back-compat.
8
+ *
9
+ * Built from Spike-1 prototype (agent/spike/v0.6.0-migrate-sessions-to-threads.mjs).
10
+ * Spike validated lossless + reversible on 612 real sessions in 5ms.
11
+ *
12
+ * Production additions over the spike:
13
+ * - Atomic per-project writes (.tmp + rename) so partial failures don't
14
+ * leave half-written thread.jsonl files
15
+ * - Idempotent: skips writing if target thread.jsonl already exists
16
+ * (re-run on daemon start is safe)
17
+ * - Temp-dir filter: skips /tmp/, /var/folders/, /private/var/folders/ by
18
+ * default (configurable) — these are test artifacts, not real projects
19
+ * - Abandoned-session marker: sessions with no completed event get
20
+ * status='abandoned' instead of silent null
21
+ * - Configurable input/output paths so tests can use temp fixtures
22
+ *
23
+ * NOT in scope for this module (handled by callers):
24
+ * - CLI interface (codehere migrate-threads — TBD subcommand)
25
+ * - Auto-run on daemon start
26
+ * - Forward-mirroring (writing to thread.jsonl alongside sessions.jsonl
27
+ * during normal daemon operation)
28
+ *
29
+ * See docs/research/v0.6.0-spike-1-migration.md for the validation history.
30
+ */
31
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync, renameSync } from 'fs';
32
+ import { join } from 'path';
33
+ import { createHash } from 'crypto';
34
+ /** Path patterns considered ephemeral and skipped from project list. */
35
+ const DEFAULT_TEMP_DIR_PATTERNS = [
36
+ /^\/tmp\//,
37
+ /^\/var\/folders\//,
38
+ /^\/private\/var\/folders\//,
39
+ /^\/private\/tmp\//,
40
+ ];
41
+ /** sha1(cwd)[:12] — stable, short, filesystem-safe. */
42
+ function slugForCwd(cwd, unknownLabel) {
43
+ if (!cwd)
44
+ return unknownLabel;
45
+ return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
46
+ }
47
+ function isTempDir(cwd) {
48
+ if (!cwd)
49
+ return false;
50
+ return DEFAULT_TEMP_DIR_PATTERNS.some((re) => re.test(cwd));
51
+ }
52
+ /** Read JSONL events; tolerate malformed lines (count + skip). */
53
+ function readEvents(path) {
54
+ if (!existsSync(path))
55
+ return { events: [], malformed: 0 };
56
+ const text = readFileSync(path, 'utf-8');
57
+ const lines = text.split('\n').filter((l) => l.trim());
58
+ const events = [];
59
+ let malformed = 0;
60
+ for (const line of lines) {
61
+ try {
62
+ events.push(JSON.parse(line));
63
+ }
64
+ catch {
65
+ malformed++;
66
+ }
67
+ }
68
+ return { events, malformed };
69
+ }
70
+ /** Roll up event-log entries into one record per session. */
71
+ function rollupSessions(events) {
72
+ const byId = new Map();
73
+ const completedIds = new Set();
74
+ for (const e of events) {
75
+ const id = (e.id ?? e.sessionId);
76
+ if (!id)
77
+ continue;
78
+ if (e.t === 'completed' || e.completedAt !== undefined)
79
+ completedIds.add(id);
80
+ const existing = byId.get(id) ?? { id, abandoned: true };
81
+ const existingAny = existing;
82
+ for (const k of ['agent', 'prompt', 'cwd', 'startedAt', 'status', 'durationMs', 'completedAt', 'steps', 'artifacts']) {
83
+ if (e[k] !== undefined && existingAny[k] === undefined) {
84
+ existingAny[k] = e[k];
85
+ }
86
+ }
87
+ byId.set(id, existing);
88
+ }
89
+ // Mark abandoned (sessions with no completed event)
90
+ for (const s of byId.values()) {
91
+ s.abandoned = !completedIds.has(s.id);
92
+ if (s.abandoned && !s.status)
93
+ s.status = 'abandoned';
94
+ }
95
+ return [...byId.values()];
96
+ }
97
+ function groupByCwd(sessions, unknownLabel) {
98
+ const groups = new Map();
99
+ for (const s of sessions) {
100
+ const cwd = s.cwd ?? null;
101
+ const slug = slugForCwd(cwd, unknownLabel);
102
+ const group = groups.get(slug) ?? { slug, cwd, sessions: [] };
103
+ group.sessions.push(s);
104
+ groups.set(slug, group);
105
+ }
106
+ return [...groups.values()];
107
+ }
108
+ /** Atomic write: write to .tmp then rename. Caller ensures dir exists. */
109
+ function atomicWriteFile(path, content) {
110
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
111
+ writeFileSync(tmp, content);
112
+ renameSync(tmp, path);
113
+ }
114
+ function buildThreadJsonl(group) {
115
+ const lines = [];
116
+ lines.push(JSON.stringify({
117
+ _meta: 'project',
118
+ slug: group.slug,
119
+ cwd: group.cwd,
120
+ sessionCount: group.sessions.length,
121
+ migratedFrom: 'sessions.jsonl',
122
+ migratedAt: Date.now(),
123
+ }));
124
+ const sorted = [...group.sessions].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
125
+ for (const s of sorted) {
126
+ lines.push(JSON.stringify({
127
+ type: 'task',
128
+ sessionId: s.id,
129
+ agent: s.agent,
130
+ prompt: s.prompt,
131
+ cwd: s.cwd,
132
+ startedAt: s.startedAt,
133
+ status: s.status,
134
+ durationMs: s.durationMs,
135
+ completedAt: s.completedAt,
136
+ steps: s.steps,
137
+ artifacts: s.artifacts,
138
+ }));
139
+ }
140
+ return lines.join('\n') + '\n';
141
+ }
142
+ /**
143
+ * Run the migration. Idempotent: skips projects whose thread.jsonl already
144
+ * exists (so daemon-startup auto-runs are safe).
145
+ */
146
+ export function migrate(options) {
147
+ const startedAt = Date.now();
148
+ const skipTempDirs = options.skipTempDirs ?? true;
149
+ const unknownLabel = options.unknownCwdLabel ?? 'unknown';
150
+ const { events } = readEvents(options.sourceJsonl);
151
+ const sessions = rollupSessions(events);
152
+ const groups = groupByCwd(sessions, unknownLabel);
153
+ let threadsWritten = 0;
154
+ let threadsSkippedExisting = 0;
155
+ let unknownCwdSessions = 0;
156
+ let abandonedSessions = sessions.filter((s) => s.abandoned).length;
157
+ let tempDirSessionsSkipped = 0;
158
+ for (const group of groups) {
159
+ if (skipTempDirs && isTempDir(group.cwd)) {
160
+ tempDirSessionsSkipped += group.sessions.length;
161
+ continue;
162
+ }
163
+ if (group.cwd === null)
164
+ unknownCwdSessions += group.sessions.length;
165
+ const dir = join(options.targetDir, group.slug);
166
+ const threadFile = join(dir, 'thread.jsonl');
167
+ if (existsSync(threadFile)) {
168
+ threadsSkippedExisting++;
169
+ continue;
170
+ }
171
+ mkdirSync(dir, { recursive: true });
172
+ atomicWriteFile(threadFile, buildThreadJsonl(group));
173
+ threadsWritten++;
174
+ }
175
+ return {
176
+ sessionsProcessed: sessions.length,
177
+ threadsWritten,
178
+ threadsSkippedExisting,
179
+ unknownCwdSessions,
180
+ abandonedSessions,
181
+ tempDirSessionsSkipped,
182
+ durationMs: Date.now() - startedAt,
183
+ };
184
+ }
185
+ /** Dry-run: same as migrate but writes nothing. Returns what WOULD happen. */
186
+ export function dryRun(options) {
187
+ const startedAt = Date.now();
188
+ const skipTempDirs = options.skipTempDirs ?? true;
189
+ const unknownLabel = options.unknownCwdLabel ?? 'unknown';
190
+ const { events } = readEvents(options.sourceJsonl);
191
+ const sessions = rollupSessions(events);
192
+ const groups = groupByCwd(sessions, unknownLabel);
193
+ let threadsWritten = 0;
194
+ let threadsSkippedExisting = 0;
195
+ let unknownCwdSessions = 0;
196
+ let abandonedSessions = sessions.filter((s) => s.abandoned).length;
197
+ let tempDirSessionsSkipped = 0;
198
+ for (const group of groups) {
199
+ if (skipTempDirs && isTempDir(group.cwd)) {
200
+ tempDirSessionsSkipped += group.sessions.length;
201
+ continue;
202
+ }
203
+ if (group.cwd === null)
204
+ unknownCwdSessions += group.sessions.length;
205
+ const threadFile = join(options.targetDir, group.slug, 'thread.jsonl');
206
+ if (existsSync(threadFile))
207
+ threadsSkippedExisting++;
208
+ else
209
+ threadsWritten++;
210
+ }
211
+ return {
212
+ sessionsProcessed: sessions.length,
213
+ threadsWritten,
214
+ threadsSkippedExisting,
215
+ unknownCwdSessions,
216
+ abandonedSessions,
217
+ tempDirSessionsSkipped,
218
+ durationMs: Date.now() - startedAt,
219
+ };
220
+ }
221
+ /**
222
+ * Rollback: removes the projects directory. Source sessions.jsonl is never
223
+ * touched. Returns the count of removed project subdirectories.
224
+ */
225
+ export function rollback(targetDir) {
226
+ if (!existsSync(targetDir))
227
+ return { removedDirs: 0 };
228
+ const subdirs = readdirSync(targetDir);
229
+ rmSync(targetDir, { recursive: true });
230
+ return { removedDirs: subdirs.length };
231
+ }
232
+ //# sourceMappingURL=project-thread-migration.js.map
@@ -550,8 +550,11 @@ export function executeInitCommand(options = {}) {
550
550
  console.log(` 1. Open ${colors.cyan('.codehere/CONTEXT.md')} and fill in what this repo is`);
551
551
  console.log(` 2. Run ${colors.cyan('codehere start')} and delegate your first task`);
552
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')}`);
553
+ // Power-user / advanced layout entry points moved to docs/advanced/ per the
554
+ // "one canonical entry" rule (May 2 CUT pass). Used to print two extra
555
+ // dim lines pointing at `codehere memory show injection` and the F3
556
+ // canonical layout migration — both confused new users in real-task
557
+ // dogfood. Power users find them in docs/MEMORY.md.
555
558
  newline();
556
559
  }
557
560
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.5.0-alpha.2",
3
+ "version": "0.5.0-alpha.4",
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",