codehere 0.5.0-alpha.2 → 0.5.0-alpha.3

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
@@ -139,7 +139,7 @@ program
139
139
  // trust-scorer pattern bank to .codehere/protocols/hook_patterns.json so
140
140
  // auditors / cross-tool consumers can read codehere's safety surface.
141
141
  program
142
- .command('protocols')
142
+ .command('protocols', { hidden: true }) // hidden from --help (May 2 cut: power-user F3 export, see docs/MEMORY.md)
143
143
  .description('F3 protocols layer · export (write hook_patterns.json from trust-scorer bank)')
144
144
  .argument('[action]', 'Action: export, help')
145
145
  .option('--rebuild', 'Refresh built-in patterns (project patterns are preserved)')
@@ -345,7 +345,7 @@ program
345
345
  // ── serve (MCP server) ───────────────────────────────────────────────────────
346
346
  program
347
347
  .command('serve')
348
- .description('Start codehere as an MCP server — expose trust tools to Claude Desktop, Cursor, and any MCP client')
348
+ .description('Run codehere as a background MCP server (no web UI) — for Claude Desktop / Cursor integration')
349
349
  .option('-p, --port <number>', 'Port to listen on', '3773')
350
350
  .option('--host <host>', 'Host to bind to (default: 127.0.0.1)', '127.0.0.1')
351
351
  .option('--token <token>', 'Require Bearer token for authentication')
@@ -406,7 +406,7 @@ program
406
406
  // ── start (Mission Control: daemon + web UI) ─────────────────────────────────
407
407
  program
408
408
  .command('start')
409
- .description('Start the codehere daemon — task intake UI + MCP server at localhost:3773')
409
+ .description('Start codehere — interactive PM dashboard at localhost:3773 (delegate tasks, watch agents work)')
410
410
  .option('-p, --port <number>', 'Port to listen on', '3773')
411
411
  .option('--host <host>', 'Host to bind to', '127.0.0.1')
412
412
  .option('--no-open', "Don't auto-open the browser")
@@ -599,7 +599,7 @@ program
599
599
  });
600
600
  // ── scan-tool (called by Claude Code hooks system, not users directly) ───────
601
601
  program
602
- .command('scan-tool')
602
+ .command('scan-tool', { hidden: true }) // hidden from --help (May 2 cut: internal hook handler, called by Claude Code via stdin)
603
603
  .description('Hook handler: reads Claude Code tool event from stdin, outputs block/allow (used by hook system)')
604
604
  .option('--log-only', 'Never block, log only')
605
605
  .option('--block-all', 'Block on any finding, not just critical')
@@ -613,7 +613,7 @@ program
613
613
  });
614
614
  // ── completion (shell completion script generator) ──────────────────────────
615
615
  program
616
- .command('completion')
616
+ .command('completion', { hidden: true }) // hidden from --help (May 2 cut: shell-completion is npm convention, not product surface)
617
617
  .description('Generate shell completion script')
618
618
  .argument('<shell>', 'Shell type: bash, zsh, or fish')
619
619
  .action(async (shell) => {
@@ -284,6 +284,7 @@ export function spawnClaudeCode(prompt, options = {}) {
284
284
  startedAt,
285
285
  prompt,
286
286
  agent: 'claude-code',
287
+ canAppendMessage: true,
287
288
  memoryInjection: injected.record,
288
289
  get status() {
289
290
  return statusValue;
@@ -317,6 +318,66 @@ export function spawnClaudeCode(prompt, options = {}) {
317
318
  }
318
319
  }, 3000);
319
320
  },
321
+ /**
322
+ * Iter 12 — follow-up message to a completed session. Spawns a fresh
323
+ * `claude -p "<text>" -c` in the same cwd, which uses Claude Code's
324
+ * native -c flag to continue the most recent conversation in that dir.
325
+ *
326
+ * Scope: one-shot Q&A. No streaming, no event piping into the parent
327
+ * emitter. Returns the assistant's response text. Iter 13 may upgrade
328
+ * to a true thread-streaming model if the dogfood signal warrants.
329
+ */
330
+ appendMessage(text) {
331
+ const startedAt = Date.now();
332
+ return new Promise((resolve, reject) => {
333
+ let stdout = '';
334
+ let stderr = '';
335
+ const followup = spawn('claude', ['-p', text, '-c', ...HEADLESS_FLAGS], {
336
+ cwd,
337
+ env: spawnEnv,
338
+ stdio: ['ignore', 'pipe', 'pipe'],
339
+ });
340
+ followup.on('error', (err) => reject(err));
341
+ followup.stdout?.on('data', (chunk) => {
342
+ stdout += chunk.toString('utf-8');
343
+ });
344
+ followup.stderr?.on('data', (chunk) => {
345
+ stderr += chunk.toString('utf-8');
346
+ });
347
+ followup.on('exit', (code) => {
348
+ if (code !== 0) {
349
+ reject(new Error(`claude -c exited ${code}: ${stderr.slice(0, 200)}`));
350
+ return;
351
+ }
352
+ // Parse stream-json — extract assistant message text from the
353
+ // 'assistant' typed lines and the final 'result' line.
354
+ const lines = stdout.split('\n').filter((l) => l.trim());
355
+ const fragments = [];
356
+ for (const line of lines) {
357
+ try {
358
+ const obj = JSON.parse(line);
359
+ if (obj.type === 'assistant' && obj.message?.content) {
360
+ for (const block of obj.message.content) {
361
+ if (block.type === 'text' && block.text)
362
+ fragments.push(block.text);
363
+ }
364
+ }
365
+ else if (obj.type === 'result' && typeof obj.result === 'string') {
366
+ fragments.push(obj.result);
367
+ }
368
+ }
369
+ catch {
370
+ // Non-JSON line — skip
371
+ }
372
+ }
373
+ // Dedupe trivially: if 'result' contained the same text as the
374
+ // last assistant block, take just one. Conservative: prefer 'result'
375
+ // if present (it's the final summary), else join assistants.
376
+ const result = fragments.length ? fragments[fragments.length - 1] : '(no response captured)';
377
+ resolve({ result, durationMs: Date.now() - startedAt });
378
+ });
379
+ });
380
+ },
320
381
  };
321
382
  }
322
383
  // ── 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
  }
@@ -548,16 +548,29 @@ export function startMCPServer(options = {}) {
548
548
  return;
549
549
  }
550
550
  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
- }));
551
+ const spawned = manager.list().map((s) => {
552
+ // Bug 2 sister-fix (May 2): include completedAt for in-memory exited
553
+ // sessions so the UI can compute duration. TaskSession interface
554
+ // intentionally lacks completedAt; SessionManager tracks it separately
555
+ // in completedAtBySession map (populated when exit event fires).
556
+ const completedAt = manager.getCompletedAt(s.sessionId);
557
+ const finalReport = manager.getFinalReport(s.sessionId);
558
+ return {
559
+ sessionId: s.sessionId,
560
+ prompt: s.prompt,
561
+ startedAt: s.startedAt,
562
+ status: s.status,
563
+ agent: s.agent,
564
+ steps: s.steps ?? [],
565
+ artifacts: s.artifacts,
566
+ ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
567
+ ...(finalReport !== undefined ? { finalReport } : {}),
568
+ // Iter 12 — surface canAppendMessage so the UI knows whether to
569
+ // render the reply input or a disabled "follow-up unsupported" state.
570
+ canAppendMessage: s.canAppendMessage === true,
571
+ provenance: 'spawned',
572
+ };
573
+ });
561
574
  const activeAgents = manager.listActiveAgents();
562
575
  const persisted = manager.listPersisted();
563
576
  // Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
@@ -581,7 +594,7 @@ export function startMCPServer(options = {}) {
581
594
  return;
582
595
  }
583
596
  // /api/task/:id/events or /api/task/:id/kill
584
- const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill)$/);
597
+ const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
585
598
  if (m) {
586
599
  const [, sessionId, verb] = m;
587
600
  const session = manager.get(sessionId);
@@ -612,6 +625,38 @@ export function startMCPServer(options = {}) {
612
625
  res.end(JSON.stringify({ sessionId, status: session.status }));
613
626
  return;
614
627
  }
628
+ // Iter 12 — POST /api/task/:id/message — follow-up message to a
629
+ // completed session. The reply-to-thread surface in the web UI calls
630
+ // this. Body: { text }. Response: { result, durationMs }.
631
+ if (verb === 'message' && req.method === 'POST') {
632
+ if (!session.canAppendMessage || !session.appendMessage) {
633
+ res.writeHead(400, { 'Content-Type': 'application/json' });
634
+ res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
635
+ return;
636
+ }
637
+ let body = '';
638
+ req.on('data', (c) => { body += c.toString('utf-8'); });
639
+ req.on('end', async () => {
640
+ try {
641
+ const parsed = JSON.parse(body || '{}');
642
+ const text = (parsed.text ?? '').trim();
643
+ if (!text) {
644
+ res.writeHead(400, { 'Content-Type': 'application/json' });
645
+ res.end(JSON.stringify({ error: 'text is required' }));
646
+ return;
647
+ }
648
+ const out = await session.appendMessage(text);
649
+ res.writeHead(200, { 'Content-Type': 'application/json' });
650
+ res.end(JSON.stringify({ sessionId, ...out }));
651
+ }
652
+ catch (err) {
653
+ const msg = err instanceof Error ? err.message : String(err);
654
+ res.writeHead(500, { 'Content-Type': 'application/json' });
655
+ res.end(JSON.stringify({ error: msg }));
656
+ }
657
+ });
658
+ return;
659
+ }
615
660
  }
616
661
  res.writeHead(405, { 'Content-Type': 'application/json' });
617
662
  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
+ <button class="pane-add" type="button" id="new-project-btn" title="Set a new working folder for the next task">+ New project</button>
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,23 @@ ${SHARED_STYLES}
1496
1569
  localStorage.setItem('codehere:cwd', cwdInput.value.trim());
1497
1570
  });
1498
1571
 
1572
+ // Iter 12 — "+ New project" button now wired. Previously dead chrome with
1573
+ // title="Coming soon". Multi-project sidebar is iter 13+; for now the button
1574
+ // does the simplest useful thing: clear the cwd input + scroll-and-focus
1575
+ // so the user can type a new folder path. The intake form's existing Assign
1576
+ // flow handles spawning the task in that path.
1577
+ const newProjectBtn = document.getElementById('new-project-btn');
1578
+ if (newProjectBtn) {
1579
+ newProjectBtn.addEventListener('click', () => {
1580
+ cwdInput.value = '';
1581
+ localStorage.removeItem('codehere:cwd');
1582
+ // Smooth-scroll the intake form into view (project sidebar is on the left,
1583
+ // intake is on the right; on narrow screens the user might be scrolled).
1584
+ try { cwdInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* old browsers */ }
1585
+ cwdInput.focus();
1586
+ });
1587
+ }
1588
+
1499
1589
  // Gate A1: starter-prompt chips prefill the textarea + focus it so the user
1500
1590
  // can edit or hit ⌘-Enter immediately. No scroll-jumping, no redirect.
1501
1591
  const starterPromptsEl = document.getElementById('starter-prompts');
@@ -1731,7 +1821,15 @@ ${SHARED_STYLES}
1731
1821
  // seed prompt, breaking chronological order in thread view.
1732
1822
  '<div class="thread-messages"></div>' +
1733
1823
  '<div class="final-report" style="display:none"></div>' +
1734
- '<div class="thread-input">Reply to thread <span class="scaffold-note">(coming soon)</span></div>' +
1824
+ // Iter 12 — reply surface now functional; replaced the prior dead
1825
+ // placeholder. Active for adapters with canAppendMessage=true
1826
+ // (claude-code today); disabled state for adapters that don't yet
1827
+ // implement appendMessage.
1828
+ '<div class="thread-input">' +
1829
+ '<textarea class="reply-text" placeholder="Reply to thread… (Cmd+Enter to send)" rows="2"></textarea>' +
1830
+ '<button class="reply-send" type="button">Send</button>' +
1831
+ '<div class="reply-status" style="display:none"></div>' +
1832
+ '</div>' +
1735
1833
  '<div class="event-log"></div>' +
1736
1834
  '</div>';
1737
1835
 
@@ -1746,6 +1844,57 @@ ${SHARED_STYLES}
1746
1844
  const finalReportEl = card.querySelector('.final-report');
1747
1845
  const threadEl = card.querySelector('.thread-messages');
1748
1846
  const bodyEl = card.querySelector('.session-body');
1847
+ // Iter 12 — reply-to-thread refs + handler.
1848
+ const threadInputEl = card.querySelector('.thread-input');
1849
+ const replyTextEl = card.querySelector('.reply-text');
1850
+ const replySendBtn = card.querySelector('.reply-send');
1851
+ const replyStatusEl = card.querySelector('.reply-status');
1852
+ if (!session.canAppendMessage) {
1853
+ threadInputEl.classList.add('disabled');
1854
+ replyTextEl.placeholder = 'Reply unsupported for ' + agentLabel(session.agent) + ' yet';
1855
+ replyTextEl.disabled = true;
1856
+ replySendBtn.disabled = true;
1857
+ } else {
1858
+ const sendReply = async () => {
1859
+ const text = (replyTextEl.value || '').trim();
1860
+ if (!text) return;
1861
+ replySendBtn.disabled = true;
1862
+ replySendBtn.textContent = '...';
1863
+ replyStatusEl.style.display = 'none';
1864
+ replyStatusEl.classList.remove('error');
1865
+ // Optimistic: render the user's message immediately in the thread.
1866
+ appendThreadMessage(threadMessage({
1867
+ cls: 'user', author: 'You', time: new Date(), content: text,
1868
+ }));
1869
+ replyTextEl.value = '';
1870
+ try {
1871
+ const r = await fetch('/api/task/' + session.sessionId + '/message', {
1872
+ method: 'POST',
1873
+ headers: { 'Content-Type': 'application/json' },
1874
+ body: JSON.stringify({ text }),
1875
+ });
1876
+ const data = await r.json();
1877
+ if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
1878
+ // Render assistant response in the thread.
1879
+ appendThreadMessage(threadMessage({
1880
+ cls: 'agent', author: agentLabel(session.agent), time: new Date(),
1881
+ content: String(data.result || '(empty response)'),
1882
+ }));
1883
+ } catch (err) {
1884
+ replyStatusEl.textContent = 'Reply failed: ' + (err && err.message ? err.message : String(err));
1885
+ replyStatusEl.classList.add('error');
1886
+ replyStatusEl.style.display = 'block';
1887
+ } finally {
1888
+ replySendBtn.disabled = false;
1889
+ replySendBtn.textContent = 'Send';
1890
+ replyTextEl.focus();
1891
+ }
1892
+ };
1893
+ replySendBtn.addEventListener('click', sendReply);
1894
+ replyTextEl.addEventListener('keydown', (ev) => {
1895
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') { ev.preventDefault(); sendReply(); }
1896
+ });
1897
+ }
1749
1898
  const safetyPillEl = card.querySelector('.safety-pill');
1750
1899
  const safetySafeEl = safetyPillEl.querySelector('.safe');
1751
1900
  const safetyBlockedEl = safetyPillEl.querySelector('.blocked');
@@ -1939,12 +2088,35 @@ ${SHARED_STYLES}
1939
2088
  // Layer 3: render the final report block when the task completes.
1940
2089
  // Principles #2 + #7 — density (files/tests/trust in one view) and
1941
2090
  // agent-provenance (the report attributes to a specific agent).
1942
- function renderArtifacts(artifacts) {
1943
- if (!artifacts) { finalReportEl.style.display = 'none'; return; }
2091
+ // Iter 12 — prose summary (3 paragraphs, plain English) prepended above
2092
+ // the artifact list. Non-technical users see prose first; raw artifact
2093
+ // detail (files/tests/tokens) below as supporting context.
2094
+ function renderArtifacts(artifacts, finalReport, status) {
2095
+ // Show the block if either prose summary OR artifacts exist OR if a
2096
+ // completed task is still generating its report (placeholder state).
2097
+ const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
2098
+ const hasContent = artifacts || finalReport;
2099
+ const isAwaitingReport = isCompleted && !finalReport;
2100
+ if (!hasContent && !isAwaitingReport) { finalReportEl.style.display = 'none'; return; }
1944
2101
  finalReportEl.style.display = 'block';
1945
- const files = Array.isArray(artifacts.files) ? artifacts.files : [];
1946
- const tests = artifacts.tests;
2102
+ const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
2103
+ const tests = artifacts?.tests;
1947
2104
  let html = '<div class="fr-label">Final report · ' + escape(agentLabel(session.agent)) + '</div>';
2105
+ if (finalReport && finalReport.summary) {
2106
+ // Plain-English prose. Split paragraphs on blank lines, render each as <p>.
2107
+ // NOTE: this code is inside a TS template literal — backslashes in
2108
+ // regex literals must be doubled so the served JS gets the right
2109
+ // sequence (test/cli/ui-served-html-syntax.test.ts guards this).
2110
+ const paragraphs = String(finalReport.summary).split(/\\n\\s*\\n/).filter(p => p.trim());
2111
+ html += '<div class="fr-summary">' +
2112
+ paragraphs.map(p => '<p>' + escape(p.trim()) + '</p>').join('') +
2113
+ '</div>';
2114
+ } else if (isAwaitingReport) {
2115
+ // Placeholder — task done, report still generating (or provider failed silently).
2116
+ // After ~30s with no report, the placeholder persists. Iter 13 will replace
2117
+ // with explicit "report unavailable — start FCC proxy or set ANTHROPIC_API_KEY".
2118
+ html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
2119
+ }
1948
2120
  if (files.length > 0) {
1949
2121
  html += '<div class="fr-files">' + files.map(f => {
1950
2122
  const lines = '<span class="add">+' + (f.added || 0) + '</span> <span class="rem">−' + (f.removed || 0) + '</span>';
@@ -1978,7 +2150,11 @@ ${SHARED_STYLES}
1978
2150
 
1979
2151
  // Hydrate from initial snapshot (late-joiner support + persisted sessions)
1980
2152
  if (Array.isArray(session.steps) && session.steps.length) renderSteps(session.steps);
1981
- if (session.artifacts) renderArtifacts(session.artifacts);
2153
+ // Render Final Report block if there's anything to show (prose summary,
2154
+ // artifacts, or "generating…" placeholder for a freshly-completed session).
2155
+ if (session.artifacts || session.finalReport || session.status === 'completed' || session.status === 'killed' || session.status === 'error') {
2156
+ renderArtifacts(session.artifacts, session.finalReport, session.status);
2157
+ }
1982
2158
 
1983
2159
  // Persisted sessions have no live event stream and no subprocess to kill.
1984
2160
  if (isPersisted) {
@@ -1994,7 +2170,7 @@ ${SHARED_STYLES}
1994
2170
 
1995
2171
  // Layer 2/3 synthetic events — handle before renderEvent (so they don't leak into the raw log)
1996
2172
  if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
1997
- if (event.type === 'codehere:artifacts') { renderArtifacts(event.data); return; }
2173
+ if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status); return; }
1998
2174
 
1999
2175
  // Any structured error flips the status pill immediately — don't wait for
2000
2176
  // the subprocess to exit. Many CLIs print a logical error to stdout and
@@ -2032,7 +2208,10 @@ ${SHARED_STYLES}
2032
2208
  killBtn.textContent = 'Kill';
2033
2209
  });
2034
2210
 
2035
- sessions.set(session.sessionId, { card, logEl, es });
2211
+ // Iter 12 — expose renderArtifacts so the polling loop can refresh the
2212
+ // Final Report block when the prose summary arrives async (~2-5s after
2213
+ // task exit). Without this, non-technical users would have to refresh.
2214
+ sessions.set(session.sessionId, { card, logEl, es, renderArtifacts });
2036
2215
  }
2037
2216
 
2038
2217
  async function assignTask() {
@@ -2181,7 +2360,10 @@ ${SHARED_STYLES}
2181
2360
  }).catch(() => { /* fine */ });
2182
2361
  }
2183
2362
  loadSessions();
2184
- // Refresh detected-agents list + banner every 5s
2363
+ // Refresh detected-agents list + banner every 5s. Iter 12 — also patches
2364
+ // existing session cards with new finalReport prose when it arrives async.
2365
+ // Cheap re-render (innerHTML on a small div); non-technical user sees the
2366
+ // report appear on its own, no manual refresh.
2185
2367
  setInterval(() => {
2186
2368
  fetch('/api/tasks').then(r => r.json()).then(d => {
2187
2369
  const seen = new Set();
@@ -2191,6 +2373,13 @@ ${SHARED_STYLES}
2191
2373
  }
2192
2374
  pruneStaleAgents(seen);
2193
2375
  updateBanner(d);
2376
+ // Patch existing session cards with finalReport when it arrives.
2377
+ for (const s of (d.spawned || d.sessions || [])) {
2378
+ const entry = sessions.get(s.sessionId);
2379
+ if (entry && entry.renderArtifacts) {
2380
+ entry.renderArtifacts(s.artifacts, s.finalReport, s.status);
2381
+ }
2382
+ }
2194
2383
  }).catch(() => { /* fine */ });
2195
2384
  }, 5000);
2196
2385
 
@@ -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.3",
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",