codehere 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/use-cases/generate-final-report.js +86 -191
- package/dist/application/use-cases/narrator-prompt-template.js +1 -1
- package/dist/index.js +10 -6
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +1 -1
- package/dist/infrastructure/hooks/claude-sdk-hook.js +7 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +1 -1
- package/dist/infrastructure/orchestration/aider-adapter.js +4 -1
- package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
- package/dist/infrastructure/orchestration/claude-code-adapter.js +5 -1
- package/dist/infrastructure/orchestration/codex-adapter.js +4 -9
- package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
- package/dist/infrastructure/orchestration/failover-config.js +1 -1
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +2 -2
- package/dist/infrastructure/orchestration/graphify-context.js +18 -13
- package/dist/infrastructure/orchestration/memory-injector.js +50 -2
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +25 -25
- package/dist/infrastructure/orchestration/progress-aggregator.js +41 -6
- package/dist/infrastructure/orchestration/session-manager.js +18 -0
- package/dist/infrastructure/orchestration/session-persistence.js +2 -2
- package/dist/infrastructure/protocols/cloud-sync.js +2 -2
- package/dist/infrastructure/protocols/mcp-server-impl.js +66 -2
- package/dist/infrastructure/protocols/ui-page-v2.js +260 -40
- package/dist/infrastructure/protocols/verify-page.js +42 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +1 -1
- package/dist/infrastructure/storage/project-thread-reader.js +1 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/presentation/cli/commands/init-command.js +66 -55
- package/package.json +2 -1
|
@@ -15,33 +15,30 @@
|
|
|
15
15
|
* runs end-to-end. For replaying REAL captured runs, that's the cassette system.
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
18
|
-
import { join
|
|
18
|
+
import { join } from 'path';
|
|
19
19
|
function argVal(flag) {
|
|
20
20
|
const i = process.argv.indexOf(flag);
|
|
21
21
|
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
|
|
22
22
|
}
|
|
23
|
-
/** The file the mock
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
23
|
+
/** The single dedicated file the mock edits — claimed in the step AND written on
|
|
24
|
+
* disk, so they stay consistent. ALWAYS a mock-only file, NEVER an existing real
|
|
25
|
+
* source file. The prior version derived a path from the prompt and appended to
|
|
26
|
+
* it; when the daemon's cwd was a real repo and the prompt carried injected
|
|
27
|
+
* memory (which lists repo paths like `src/ui.ts`), it corrupted real tracked
|
|
28
|
+
* source. gitignored so it never rides along. */
|
|
29
|
+
const MOCK_EDIT_FILE = 'codehere-mock-change.md';
|
|
30
|
+
/** CODEHERE_MOCK fidelity: apply a small, safe on-disk edit so the run has a
|
|
31
|
+
* genuine change — WITHOUT touching real source. Writes only to the dedicated
|
|
32
|
+
* MOCK_EDIT_FILE. Note: that file is gitignored, so INSIDE a git repo the
|
|
33
|
+
* artifacts extractor (git ls-files --others --exclude-standard) won't count it
|
|
34
|
+
* — but verify-all runs the mock in a NON-git sandbox where file counts don't
|
|
35
|
+
* apply, and the polished demo uses seeded sessions, not live mock edits. */
|
|
36
|
+
function materializeEdit() {
|
|
33
37
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const marker = `\n// codehere(mock): change applied for "${prompt.slice(0, 60).replace(/"/g, "'")}"\n`;
|
|
39
|
-
if (existsSync(target)) {
|
|
40
|
-
writeFileSync(target, readFileSync(target, 'utf-8') + marker);
|
|
41
|
-
}
|
|
42
|
-
else if (!file.includes('/')) {
|
|
43
|
-
writeFileSync(target, marker.trimStart()); // top-level only — never mkdir
|
|
44
|
-
}
|
|
38
|
+
const target = join(process.cwd(), MOCK_EDIT_FILE);
|
|
39
|
+
const header = '# codehere mock scratch — synthetic edits under CODEHERE_MOCK (safe to delete)\n\n';
|
|
40
|
+
const marker = `codehere(mock): change applied for "${prompt.slice(0, 60).replace(/"/g, "'")}"\n`;
|
|
41
|
+
writeFileSync(target, (existsSync(target) ? readFileSync(target, 'utf-8') : header) + marker);
|
|
45
42
|
}
|
|
46
43
|
catch {
|
|
47
44
|
/* best-effort — mock fidelity must never crash the run */
|
|
@@ -74,7 +71,7 @@ const out = (o) => process.stdout.write(JSON.stringify(o) + '\n');
|
|
|
74
71
|
// for the execution path to produce real steps/artifacts without an API call.
|
|
75
72
|
function plannedSteps(p) {
|
|
76
73
|
const lc = p.toLowerCase();
|
|
77
|
-
const file =
|
|
74
|
+
const file = MOCK_EDIT_FILE;
|
|
78
75
|
const steps = [
|
|
79
76
|
{ tool: 'Read', input: { file_path: 'REPO_MAP.md' }, result: 'context loaded · 84 files indexed' },
|
|
80
77
|
{ tool: 'Edit', input: { file_path: file }, result: '+' + (20 + (p.length % 90)) + ' lines · change applied' },
|
|
@@ -197,14 +194,17 @@ async function runOpencode() {
|
|
|
197
194
|
out({ type: 'tool_use', part: { tool, state: { input: s.input, output: s.result, status: 'completed' } } });
|
|
198
195
|
}
|
|
199
196
|
await sleep(120);
|
|
200
|
-
|
|
197
|
+
// Real opencode carries narrative text in part.text (the adapter's summary
|
|
198
|
+
// extraction and the aggregator's say capture both read that shape) — the
|
|
199
|
+
// prior top-level `text` field was mock drift no real parser ever saw.
|
|
200
|
+
out({ type: 'text', part: { text: 'Completed (mock): ' + prompt } });
|
|
201
201
|
}
|
|
202
202
|
async function main() {
|
|
203
203
|
if (!prompt)
|
|
204
204
|
prompt = (await readStdin()) || 'task';
|
|
205
205
|
// Apply the real on-disk edit before emitting the run so the artifacts
|
|
206
206
|
// extractor (which diffs at exit) sees a genuine change.
|
|
207
|
-
materializeEdit(
|
|
207
|
+
materializeEdit();
|
|
208
208
|
switch (agent) {
|
|
209
209
|
case 'claude-code':
|
|
210
210
|
case 'claude':
|
|
@@ -169,6 +169,14 @@ export class ProgressAggregator {
|
|
|
169
169
|
// real gap surfaced by the mock harness). Build a step on the terminal
|
|
170
170
|
// (status==='completed') event so opencode is a first-class agent.
|
|
171
171
|
ingestOpencode(type, data) {
|
|
172
|
+
// OpenCode's narrative text rides `text` events with part.text (the same
|
|
173
|
+
// shape the adapter's summary extraction reads) — cross-vendor say capture
|
|
174
|
+
// per the v1 spec, so opencode's Final Report stops degrading to the
|
|
175
|
+
// deterministic fallback.
|
|
176
|
+
if (type === 'text') {
|
|
177
|
+
const part = (data.part ?? {});
|
|
178
|
+
return this.pushSay(String(part.text ?? ''));
|
|
179
|
+
}
|
|
172
180
|
if (type !== 'tool_use')
|
|
173
181
|
return false;
|
|
174
182
|
const part = (data.part ?? {});
|
|
@@ -213,6 +221,24 @@ export class ProgressAggregator {
|
|
|
213
221
|
const idx = this._steps.push({ label, state, startedAt: Date.now() }) - 1;
|
|
214
222
|
return idx;
|
|
215
223
|
}
|
|
224
|
+
/** The agent's own narration as a kind:'say' step — the "watch it work"
|
|
225
|
+
* signal AND what the Final Report engine reuses as the summary. Cross-
|
|
226
|
+
* vendor by decision of the v1 spec (the room thread is "tool steps woven
|
|
227
|
+
* with the agent's plain-English narration" — for every agent, not just
|
|
228
|
+
* claude/aider). Same 500-char cap as the original claude capture. */
|
|
229
|
+
pushSay(text) {
|
|
230
|
+
const said = String(text ?? '').trim();
|
|
231
|
+
if (!said)
|
|
232
|
+
return false;
|
|
233
|
+
this._steps.push({
|
|
234
|
+
kind: 'say',
|
|
235
|
+
label: said.length > 500 ? said.slice(0, 500) + '\\u2026' : said,
|
|
236
|
+
state: 'done',
|
|
237
|
+
startedAt: Date.now(),
|
|
238
|
+
completedAt: Date.now(),
|
|
239
|
+
});
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
216
242
|
ingestClaude(type, data) {
|
|
217
243
|
if (type === 'assistant') {
|
|
218
244
|
const msg = (data.message ?? {});
|
|
@@ -322,6 +348,12 @@ export class ProgressAggregator {
|
|
|
322
348
|
return true;
|
|
323
349
|
}
|
|
324
350
|
}
|
|
351
|
+
// Gemini's narrative text rides `content` events — same cross-vendor say
|
|
352
|
+
// capture as claude/aider/codex (v1 spec: narration woven into the thread,
|
|
353
|
+
// and the Final Report engine reuses it as the summary).
|
|
354
|
+
if (type === 'content' && typeof data.text === 'string') {
|
|
355
|
+
return this.pushSay(data.text);
|
|
356
|
+
}
|
|
325
357
|
return false;
|
|
326
358
|
}
|
|
327
359
|
/**
|
|
@@ -642,12 +674,15 @@ export class ProgressAggregator {
|
|
|
642
674
|
});
|
|
643
675
|
return true;
|
|
644
676
|
}
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
677
|
+
// agent_message = codex's own narration. Captured as kind:'say' since the
|
|
678
|
+
// v1 spec settled the taste call that used to be flagged here (pm-layers
|
|
679
|
+
// "just chatter" decision): the room thread is "tool steps woven with the
|
|
680
|
+
// agent's plain-English narration" for EVERY vendor, and the Final Report
|
|
681
|
+
// engine reuses 'say' steps as the summary — without this, codex always
|
|
682
|
+
// degraded to the deterministic fallback.
|
|
683
|
+
if (itemType === 'agent_message') {
|
|
684
|
+
return this.pushSay(String(item.text ?? ''));
|
|
685
|
+
}
|
|
651
686
|
return false;
|
|
652
687
|
}
|
|
653
688
|
}
|
|
@@ -20,6 +20,7 @@ import { OpenCodeAdapter } from './opencode-adapter.js';
|
|
|
20
20
|
import { GeminiAdapter } from './gemini-adapter.js';
|
|
21
21
|
import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js';
|
|
22
22
|
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
23
|
+
import { recordTurnToWorkingMemory } from './memory-injector.js';
|
|
23
24
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
24
25
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
25
26
|
import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
|
|
@@ -308,7 +309,22 @@ export class SessionManager {
|
|
|
308
309
|
summary: report.summary,
|
|
309
310
|
generatedAt: report.generatedAt,
|
|
310
311
|
durationMs: report.durationMs,
|
|
312
|
+
source: report.source,
|
|
311
313
|
});
|
|
314
|
+
// Shared-room hand-off: record this turn's outcome to the project's
|
|
315
|
+
// working memory so the NEXT agent spawned here reads it (closes the
|
|
316
|
+
// loop with applyMemoryPrefix). Best-effort; only initialized projects.
|
|
317
|
+
try {
|
|
318
|
+
recordTurnToWorkingMemory(cwd, {
|
|
319
|
+
agent: session.agent,
|
|
320
|
+
prompt: session.prompt,
|
|
321
|
+
summary: report.summary,
|
|
322
|
+
files: session.artifacts?.files?.length,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
/* memory write must never break the run */
|
|
327
|
+
}
|
|
312
328
|
try {
|
|
313
329
|
printFinalReportToTerminal(reportInput, report);
|
|
314
330
|
}
|
|
@@ -473,6 +489,7 @@ export class SessionManager {
|
|
|
473
489
|
summary: report.summary,
|
|
474
490
|
generatedAt: report.generatedAt,
|
|
475
491
|
durationMs: report.durationMs,
|
|
492
|
+
source: report.source,
|
|
476
493
|
});
|
|
477
494
|
try {
|
|
478
495
|
printFinalReportToTerminal(reportInput, report);
|
|
@@ -669,6 +686,7 @@ export class SessionManager {
|
|
|
669
686
|
summary: report.summary,
|
|
670
687
|
generatedAt: report.generatedAt,
|
|
671
688
|
durationMs: report.durationMs,
|
|
689
|
+
source: report.source,
|
|
672
690
|
});
|
|
673
691
|
try {
|
|
674
692
|
printFinalReportToTerminal(reportInput, report);
|
|
@@ -151,7 +151,7 @@ export function loadSessionById(idOrPrefix) {
|
|
|
151
151
|
steps: c.steps,
|
|
152
152
|
artifacts: c.artifacts,
|
|
153
153
|
...(c.turns ? { turns: c.turns } : {}),
|
|
154
|
-
...(fr ? { finalReport: { summary: fr.summary, generatedAt: fr.generatedAt, durationMs: fr.durationMs } } : {}),
|
|
154
|
+
...(fr ? { finalReport: { summary: fr.summary, generatedAt: fr.generatedAt, durationMs: fr.durationMs, source: fr.source } } : {}),
|
|
155
155
|
...(failoverFrom ? { failoverFrom } : {}),
|
|
156
156
|
...(failoverTo ? { failoverTo } : {}),
|
|
157
157
|
...(failoverReason ? { failoverReason } : {}),
|
|
@@ -222,7 +222,7 @@ export function loadRecent(limit = 20) {
|
|
|
222
222
|
steps: c.steps,
|
|
223
223
|
artifacts: c.artifacts,
|
|
224
224
|
...(c.turns ? { turns: c.turns } : {}),
|
|
225
|
-
...(fr ? { finalReport: { summary: fr.summary, generatedAt: fr.generatedAt, durationMs: fr.durationMs } } : {}),
|
|
225
|
+
...(fr ? { finalReport: { summary: fr.summary, generatedAt: fr.generatedAt, durationMs: fr.durationMs, source: fr.source } } : {}),
|
|
226
226
|
...((c.failoverFrom ?? s.failoverFrom) ? { failoverFrom: c.failoverFrom ?? s.failoverFrom } : {}),
|
|
227
227
|
...((c.failoverTo ?? s.failoverTo) ? { failoverTo: c.failoverTo ?? s.failoverTo } : {}),
|
|
228
228
|
...((c.failoverReason ?? s.failoverReason) ? { failoverReason: c.failoverReason ?? s.failoverReason } : {}),
|
|
@@ -16,8 +16,8 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
16
16
|
import { join, dirname } from 'path';
|
|
17
17
|
import { homedir, hostname } from 'os';
|
|
18
18
|
const CLOUD_URL = 'https://codehere.uk/api/sync';
|
|
19
|
-
const TOKEN_PATH = join(homedir(), '.codehere', 'cloud-token');
|
|
20
|
-
const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
|
|
19
|
+
const TOKEN_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'cloud-token');
|
|
20
|
+
const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
|
|
21
21
|
// Lazy-loaded session manager — imported once to avoid circular deps
|
|
22
22
|
let _sessionManager = null;
|
|
23
23
|
async function getSessionManager() {
|
|
@@ -46,6 +46,8 @@ import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuth
|
|
|
46
46
|
import { saveToken, clearToken, connectionStatus } from '../context/oauth-store.js';
|
|
47
47
|
import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
|
|
48
48
|
import { isAbsolute } from 'path';
|
|
49
|
+
import { findProjectRoot } from '../orchestration/memory-injector.js';
|
|
50
|
+
import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
|
|
49
51
|
import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
|
|
50
52
|
import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
|
|
51
53
|
import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
|
|
@@ -444,7 +446,9 @@ export function startMCPServer(options = {}) {
|
|
|
444
446
|
// Health check
|
|
445
447
|
if (req.method === 'GET' && req.url === '/health') {
|
|
446
448
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
447
|
-
|
|
449
|
+
// `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
|
|
450
|
+
// attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
|
|
451
|
+
res.end(JSON.stringify({ status: 'ok', server: 'codehere-mcp', version: getVersion(), tools: TOOLS.length, mock: process.env.CODEHERE_MOCK === '1' }));
|
|
448
452
|
return;
|
|
449
453
|
}
|
|
450
454
|
// v2 Task-Centric PM UI (primary — served at /)
|
|
@@ -543,7 +547,7 @@ export function startMCPServer(options = {}) {
|
|
|
543
547
|
// Tails sessions.jsonl and pushes "change" events so the /v2 UI can
|
|
544
548
|
// re-fetch task state instantly instead of waiting 8s for the next poll.
|
|
545
549
|
// Same pattern as /events (audit log tail) but for sessions.
|
|
546
|
-
const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
|
|
550
|
+
const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
|
|
547
551
|
if (req.method === 'GET' && req.url === '/api/events/sessions') {
|
|
548
552
|
res.writeHead(200, {
|
|
549
553
|
'Content-Type': 'text/event-stream',
|
|
@@ -1209,6 +1213,66 @@ export function startMCPServer(options = {}) {
|
|
|
1209
1213
|
}
|
|
1210
1214
|
return;
|
|
1211
1215
|
}
|
|
1216
|
+
// ── v1 project-memory routes (the room's init nudge) ─────────────────────
|
|
1217
|
+
// Found by the stranger walk: web-only users never run `codehere init`, so
|
|
1218
|
+
// the spec's "agent remembers the project" half of nothing-lost silently
|
|
1219
|
+
// doesn't apply. Status reports the SAME readiness the injector acts on
|
|
1220
|
+
// (shared findProjectRoot); init runs the SAME hardened scaffold the CLI
|
|
1221
|
+
// does (shared scaffoldProjectAt — symlink defenses included).
|
|
1222
|
+
if (req.method === 'GET' && req.url?.startsWith('/api/memory/status')) {
|
|
1223
|
+
try {
|
|
1224
|
+
const u = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
|
|
1225
|
+
const cwd = u.searchParams.get('cwd') || '';
|
|
1226
|
+
if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) {
|
|
1227
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1228
|
+
res.end(JSON.stringify({ error: 'cwd (absolute path) required' }));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const root = findProjectRoot(cwd);
|
|
1232
|
+
const ready = !!root && existsSync(join(root, '.codehere', 'memory', 'working'));
|
|
1233
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1234
|
+
res.end(JSON.stringify({ ready, projectRoot: root }));
|
|
1235
|
+
}
|
|
1236
|
+
catch (e) {
|
|
1237
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1238
|
+
res.end(JSON.stringify({ error: 'memory_status_failed', detail: String(e) }));
|
|
1239
|
+
}
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (req.method === 'POST' && req.url === '/api/memory/init') {
|
|
1243
|
+
(async () => {
|
|
1244
|
+
try {
|
|
1245
|
+
const body = await readBody(req, res);
|
|
1246
|
+
let cwd = '';
|
|
1247
|
+
try {
|
|
1248
|
+
cwd = String(JSON.parse(body || '{}').cwd || '');
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
/* falls through to the validation below */
|
|
1252
|
+
}
|
|
1253
|
+
if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
1254
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1255
|
+
res.end(JSON.stringify({ error: 'cwd (absolute path to an existing directory) required' }));
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const r = scaffoldProjectAt(cwd);
|
|
1259
|
+
if (!r.ok) {
|
|
1260
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1261
|
+
res.end(JSON.stringify({ ok: false, reason: r.reason }));
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1265
|
+
res.end(JSON.stringify({ ok: true, created: r.created.length, skipped: r.skipped.length, refused: r.refused.length + r.refusedDirs.length }));
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1269
|
+
res.end(JSON.stringify({ error: 'memory_init_failed', detail: String(e) }));
|
|
1270
|
+
}
|
|
1271
|
+
})().catch(() => {
|
|
1272
|
+
/* handled above — keep the route from rejecting unhandled */
|
|
1273
|
+
});
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1212
1276
|
// ── v0.6.0 M1 project thread routes ──────────────────────────────────────
|
|
1213
1277
|
// GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
|
|
1214
1278
|
// GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
|