codehere 0.6.2 → 0.7.1

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.
Files changed (73) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +87 -168
  4. package/dist/application/use-cases/generate-narration.js +11 -0
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +211 -16
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
  23. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  24. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  25. package/dist/infrastructure/orchestration/aider-adapter.js +49 -18
  26. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  27. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  28. package/dist/infrastructure/orchestration/claude-code-adapter.js +38 -5
  29. package/dist/infrastructure/orchestration/codex-adapter.js +150 -22
  30. package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
  31. package/dist/infrastructure/orchestration/context-handoff.js +3 -2
  32. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  33. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  34. package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
  35. package/dist/infrastructure/orchestration/graphify-context.js +18 -13
  36. package/dist/infrastructure/orchestration/memory-injector.js +46 -1
  37. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +230 -0
  38. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  40. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  41. package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
  42. package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
  43. package/dist/infrastructure/orchestration/session-manager.js +112 -6
  44. package/dist/infrastructure/orchestration/session-persistence.js +140 -31
  45. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  46. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  47. package/dist/infrastructure/protocols/cloud-sync.js +1 -6
  48. package/dist/infrastructure/protocols/mcp-server-impl.js +1283 -13
  49. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  50. package/dist/infrastructure/protocols/ui-page-v2.js +3913 -88
  51. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  52. package/dist/infrastructure/protocols/verify-page.js +941 -0
  53. package/dist/infrastructure/security/trust-scorer.js +76 -1
  54. package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
  55. package/dist/infrastructure/storage/project-thread-migration.js +2 -2
  56. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  57. package/dist/monitoring.js +1 -1
  58. package/dist/presentation/cli/commands/audit-command.js +126 -0
  59. package/dist/presentation/cli/commands/config-command.js +2 -2
  60. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  61. package/dist/presentation/cli/commands/help-command.js +51 -1
  62. package/dist/presentation/cli/commands/report-command.js +6 -4
  63. package/dist/presentation/cli/commands/setup-command.js +71 -18
  64. package/dist/presentation/cli/commands/status-command.js +0 -4
  65. package/dist/presentation/cli/commands/thread-command.js +288 -0
  66. package/dist/presentation/cli/commands/verify-command.js +233 -0
  67. package/dist/presentation/cli/error-display.js +1 -1
  68. package/dist/presentation/cli/repl.js +32 -1
  69. package/dist/presentation/cli/task-deliverable.js +127 -0
  70. package/dist/testing/cassette.js +125 -0
  71. package/dist/testing/replay-adapter.js +166 -0
  72. package/dist/ui.js +18 -1
  73. package/package.json +5 -2
package/README.md CHANGED
@@ -5,12 +5,14 @@
5
5
 
6
6
  [![npm](https://img.shields.io/npm/v/codehere)](https://www.npmjs.com/package/codehere)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](../LICENSE)
8
- [![Tests](https://img.shields.io/badge/tests-1196%2F1196-brightgreen)]()
9
- [![Vulnerabilities](https://img.shields.io/badge/vulnerabilities-0-brightgreen)]()
8
+ [![Internal tests](https://img.shields.io/badge/internal_tests-1200%2F1200-brightgreen)]()
9
+ [![Honest status](https://img.shields.io/badge/v0.7--honest--reframe-2026--05--19-blue)](../docs/TRUTHFUL_STATE_2026-05-19.md)
10
10
 
11
11
  **Codehere is the vendor-neutral layer between you and the coding agents you already use** — Claude Code, Codex, Aider, Gemini, OpenCode. One `npm install`. Local-first, OSS, no account, no IAM, no compliance prereqs. Switch agent, keep your memory. End the day with one Final Report covering everything they did.
12
12
 
13
- All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) get session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call. The *memory*, *audit log*, *failover*, and *Final Report* surfaces ship across all 5 adapters today.
13
+ All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) ship adapter code for session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call via the Claude Agent SDK. The *memory*, *audit log*, *failover*, and *Final Report* surfaces are wired across all 5 adapters today.
14
+
15
+ > **Honest status (2026-05-19)** — The 1200 internal tests verify codehere's code against codehere's own assumptions about what each adapter emits. They do **not** systematically verify against real-adapter outputs in production. Real-adapter contract verification is the v0.7 release work — see [`docs/TRUTHFUL_STATE_2026-05-19.md`](../docs/TRUTHFUL_STATE_2026-05-19.md) for the verified / assumed / unverified classification of every surface.
14
16
 
15
17
  The enterprise tier is owned by [OpenAI Frontier](https://openai.com/index/introducing-openai-frontier/) (8-14 weeks of deploy prereqs, sales-team gated) and [Anthropic Cowork](https://www.anthropic.com/product/claude-cowork) (paid Claude subscription, single-vendor). Codehere fills the gap they structurally can't reach: **the individual / small-team / no-IT-department tier, vendor-neutral**.
16
18
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Per-domain trust grade for W-PASS write targets · v0.9 must-ship.
3
+ *
4
+ * Demo v12 + locked v1 tier scope: "linear.app A · *.atlassian.net B ·
5
+ * unknown F · first-write confirmation per domain."
6
+ *
7
+ * Two pieces of state:
8
+ * 1. WELL_KNOWN_GRADES · hardcoded · the platforms we've vetted ourselves
9
+ * ship with grade A. Subdomain wildcards get B. Everything else
10
+ * defaults to F (forces explicit user confirmation on first write).
11
+ * 2. USER_TRUSTED_DOMAINS · ~/.codehere/keys/trusted-domains.json ·
12
+ * domains the user has explicitly trusted after a first-write prompt.
13
+ * Domains here promote from F → B.
14
+ *
15
+ * Pure logic. CLI + HTTP wrap it.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
18
+ import { homedir } from 'os';
19
+ import { dirname, join } from 'path';
20
+ const WELL_KNOWN_GRADES = [
21
+ // Grade A · platforms codehere has shipped+tested adapters for
22
+ { pattern: /^api\.linear\.app$/i, grade: 'A', reason: 'official Linear API · codehere ships a vetted adapter' },
23
+ { pattern: /^api\.github\.com$/i, grade: 'A', reason: 'official GitHub API · codehere ships a vetted adapter' },
24
+ { pattern: /^api\.figma\.com$/i, grade: 'A', reason: 'official Figma API · codehere ships a vetted adapter' },
25
+ // Grade B · same vendor, surrounding subdomains
26
+ { pattern: /^[a-z0-9-]+\.linear\.app$/i, grade: 'B', reason: 'Linear subdomain · adjacent to the vetted API' },
27
+ { pattern: /^[a-z0-9-]+\.github\.com$/i, grade: 'B', reason: 'GitHub subdomain · adjacent to the vetted API' },
28
+ { pattern: /^[a-z0-9-]+\.figma\.com$/i, grade: 'B', reason: 'Figma subdomain · adjacent to the vetted API' },
29
+ {
30
+ pattern: /^[a-z0-9-]+\.atlassian\.net$/i,
31
+ grade: 'B',
32
+ reason: 'Atlassian-hosted instance · vendor known but instance not vetted',
33
+ },
34
+ // Grade C · public hosts known to be commonly used
35
+ { pattern: /^raw\.githubusercontent\.com$/i, grade: 'C', reason: 'GitHub-hosted raw content · static, read-mostly' },
36
+ ];
37
+ function trustedDomainsPath() {
38
+ const home = process.env.CODEHERE_HOME || homedir();
39
+ return join(home, '.codehere', 'keys', 'trusted-domains.json');
40
+ }
41
+ export function loadTrustedDomains() {
42
+ const path = trustedDomainsPath();
43
+ if (!existsSync(path))
44
+ return { schema: 1, domains: {} };
45
+ try {
46
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
47
+ if (raw && raw.schema === 1 && typeof raw.domains === 'object')
48
+ return raw;
49
+ }
50
+ catch {
51
+ /* fall through · empty trust store */
52
+ }
53
+ return { schema: 1, domains: {} };
54
+ }
55
+ export function trustDomain(host, note) {
56
+ const file = loadTrustedDomains();
57
+ file.domains[host.toLowerCase()] = { addedAt: Date.now(), note };
58
+ mkdirSync(dirname(trustedDomainsPath()), { recursive: true });
59
+ writeFileSync(trustedDomainsPath(), JSON.stringify(file, null, 2) + '\n', 'utf-8');
60
+ }
61
+ export function untrustDomain(host) {
62
+ const file = loadTrustedDomains();
63
+ const key = host.toLowerCase();
64
+ if (!file.domains[key])
65
+ return false;
66
+ delete file.domains[key];
67
+ writeFileSync(trustedDomainsPath(), JSON.stringify(file, null, 2) + '\n', 'utf-8');
68
+ return true;
69
+ }
70
+ export function gradeDomain(host) {
71
+ const userFile = loadTrustedDomains();
72
+ const userTrusted = !!userFile.domains[host.toLowerCase()];
73
+ for (const rule of WELL_KNOWN_GRADES) {
74
+ if (rule.pattern.test(host)) {
75
+ return {
76
+ host,
77
+ grade: rule.grade,
78
+ reason: rule.reason,
79
+ userTrusted,
80
+ requiresFirstWriteConfirmation: false,
81
+ };
82
+ }
83
+ }
84
+ if (userTrusted) {
85
+ return {
86
+ host,
87
+ grade: 'B',
88
+ reason: 'previously trusted by you · stored in trusted-domains.json',
89
+ userTrusted: true,
90
+ requiresFirstWriteConfirmation: false,
91
+ };
92
+ }
93
+ return {
94
+ host,
95
+ grade: 'F',
96
+ reason: 'unknown domain · not in well-known list · not previously trusted',
97
+ userTrusted: false,
98
+ requiresFirstWriteConfirmation: true,
99
+ };
100
+ }
101
+ /** Extract the host from a fully-resolved HTTPS URL. */
102
+ export function hostOf(url) {
103
+ try {
104
+ return new URL(url).host;
105
+ }
106
+ catch {
107
+ return '';
108
+ }
109
+ }
110
+ //# sourceMappingURL=domain-trust.js.map
@@ -1,99 +1,84 @@
1
1
  /**
2
- * Final Report — PM-UX Layer 3 · MVP (v0.5.0-alpha.3+).
2
+ * Final Report — the headline deliverable. At task exit, produce a plain-English
3
+ * summary of what the agent did.
3
4
  *
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.
5
+ * Engine (locked 2026-06-03, after the real dogfood found NO report on a real
6
+ * conductor run): REUSE THE AGENT SESSION. The agent already narrates what it
7
+ * did (kind:'say' steps — e.g. "Added subtract(a,b) to utils.js:5"); we use that
8
+ * directly. No fresh summarizer spawn.
7
9
  *
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).
10
+ * Why the old path was replaced: it spawned a SEPARATE provider binary chain
11
+ * (`claude-free` → `claude`) to summarize. `claude-free` (the FCC/NIM proxy
12
+ * binary) is on PATH but its proxy was down → 30s hang → null; the `claude`
13
+ * fallback also returned null in the conductor exit-handler. Net: a completed
14
+ * real task showed "⚠ Final Report unavailable" — the headline deliverable
15
+ * missing. Reusing the agent's own words is cheaper, has full context, and can't
16
+ * be taken down by an external proxy.
16
17
  *
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.
18
+ * Fallback (locked): when the agent left no usable summary (or it looks like a
19
+ * provider failure), build a DETERMINISTIC report from the recorded steps +
20
+ * artifacts. It states ONLY verified facts, says plainly that no written summary
21
+ * was available, and NEVER implies success — so a completed task always shows a
22
+ * deliverable, and it's honest about which kind it is (see `source`).
25
23
  */
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
- CRITICAL anti-hallucination rule: ground every claim in the structured data
32
- below. You CANNOT see the contents of files, the output of commands, or
33
- anything beyond what's listed under "Steps the agent took" and "Files
34
- changed". If the data doesn't show specific filenames, do NOT invent any.
35
- If a step ran a command but its output isn't shown to you, describe what
36
- the step ATTEMPTED, not what it found. Saying "I don't have the output to
37
- report on" is correct; making up plausible-sounding details is wrong and
38
- the developer will catch the lie.
39
-
40
- Paragraph 1: What the agent ATTEMPTED. Verb-led. Describe steps as listed.
41
- Mention specific filenames ONLY if they appear in the data
42
- below. 1-3 sentences.
43
- Paragraph 2: What was TOUCHED. Reference ONLY the "Files changed" list
44
- below. If the list is empty, say "No files were changed."
45
- 1-2 sentences.
46
- Paragraph 3: What the developer should LOOK AT. If steps + files data show
47
- nothing risky, say so explicitly ("nothing to verify; the
48
- session was read-only" or similar). 1-2 sentences.
49
-
50
- Be specific where the data lets you. Don't pad. Don't editorialize. Don't
51
- praise the agent. Don't fabricate file names, line counts, or content
52
- descriptions you cannot derive from the data below.
53
-
54
- The task was: ${i.prompt}
55
- The agent used: ${i.agent}
56
- Duration: ${i.durationMs}ms
57
- Status: ${i.status}
58
-
59
- Steps the agent took:
60
- ${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') : '(no structured steps captured)'}
61
-
62
- Files changed:
63
- ${i.artifacts?.files?.length
64
- ? i.artifacts.files
65
- .map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`)
66
- .join('\n')
67
- : '(no file artifacts captured)'}
68
-
69
- Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
70
-
71
- Now write the 3 paragraphs, grounded ONLY in the data above.`;
72
- const REPORT_TIMEOUT_MS = 30_000;
73
- /** Provider chain: try free first, then paid. Override with CODEHERE_REPORT_BINARY. */
74
- function selectProviderBinary() {
75
- const override = process.env.CODEHERE_REPORT_BINARY;
76
- if (override)
77
- return [override];
78
- return ['claude-free', 'claude'];
79
- }
24
+ const MAX_SUMMARY_CHARS = 1600;
80
25
  /**
81
- * UAT-003 — patterns that indicate the wrapped agent's session terminated
82
- * because of a network / auth / quota failure rather than a real task.
83
- * When we see these in the would-be summary text, we prepend a plain-English
84
- * recovery hint instead of letting the raw provider error echo through.
85
- *
86
- * Pre-fix: bare `exit` in REPL → Conductor spawned claude-code → real API
87
- * call failed with "All connection attempts failed Request ID: req_...";
88
- * Final Report quoted that text verbatim. Per Core Values #4 + #6, this is
89
- * unacceptable — the user sees a Request ID that means nothing.
26
+ * UAT-003 — patterns indicating the agent's own text is actually a network /
27
+ * auth / quota failure, not a real summary. Such "summaries" must NOT be shown
28
+ * as the report; they fall through to the deterministic fallback.
90
29
  */
91
30
  const NETWORK_FAILURE_PATTERNS = /(?:all connection attempts failed|ECONNREFUSED|ENOTFOUND|fetch failed|network error|request id:\s*req_|connection timed out|getaddrinfo)/i;
92
31
  function isLikelyNetworkFailureSummary(text) {
93
32
  return NETWORK_FAILURE_PATTERNS.test(text);
94
33
  }
34
+ /**
35
+ * ENGINE: the agent's own narration. kind:'say' steps are the words the agent
36
+ * already emitted (reasoning + wrap-up). Joined newest-context-last, they ARE
37
+ * the summary — no extra spawn. Returns null if the agent said nothing usable.
38
+ */
39
+ function extractAgentSummary(steps) {
40
+ const says = steps
41
+ .filter((s) => s.kind === 'say' && typeof s.label === 'string' && s.label.trim().length > 0)
42
+ .map((s) => s.label.trim());
43
+ if (!says.length)
44
+ return null;
45
+ const text = says.join('\n\n').slice(0, MAX_SUMMARY_CHARS);
46
+ // Too short to be a real summary (e.g. a one-word ack) → let the fallback give facts.
47
+ return text.length >= 12 ? text : null;
48
+ }
49
+ /**
50
+ * FALLBACK: deterministic, honest. Facts only, from steps + artifacts. Says
51
+ * plainly that no written summary was available. Never implies success.
52
+ */
53
+ function buildDeterministicReport(input) {
54
+ const files = input.artifacts?.files ?? [];
55
+ const toolSteps = input.steps.filter((s) => s.kind !== 'say');
56
+ const noGit = input.artifacts?.noGitTracking;
57
+ const agentName = input.agent === 'conductor' ? 'the agent codehere chose' : input.agent;
58
+ const parts = [
59
+ `No plain-English summary was available for this run — ${agentName} didn't leave a written summary, and codehere doesn't fabricate one. Here are the recorded facts.`,
60
+ `The session ran ${input.steps.length} step${input.steps.length === 1 ? '' : 's'}` +
61
+ (toolSteps.length
62
+ ? ` (${toolSteps.slice(0, 5).map((s) => s.label).join(', ')}${toolSteps.length > 5 ? ', …' : ''})`
63
+ : '') +
64
+ `.`,
65
+ ];
66
+ if (noGit) {
67
+ parts.push(`This folder isn't a git repository, so codehere couldn't track which files changed — check the working tree yourself.`);
68
+ }
69
+ else if (files.length) {
70
+ parts.push(`Files changed: ` +
71
+ files
72
+ .map((f) => `${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust ${f.trust}` : ''})`)
73
+ .join('; ') +
74
+ `.`);
75
+ }
76
+ else {
77
+ parts.push(`No files were changed.`);
78
+ }
79
+ return { summary: parts.join('\n\n'), generatedAt: Date.now(), durationMs: 0, source: 'deterministic' };
80
+ }
95
81
  function buildErrorRecoveryReport(input, durationMs) {
96
- // Tailor the hint to the specific failure shape we can detect from input.
97
82
  const agentName = input.agent === 'conductor' ? 'the agent codehere chose' : input.agent;
98
83
  return {
99
84
  summary: `The ${agentName} session ended without completing the task. ` +
@@ -101,101 +86,35 @@ function buildErrorRecoveryReport(input, durationMs) {
101
86
  `What to try: check your internet, run \`codehere health\` to confirm reachability + auth, ` +
102
87
  `or wait a minute and rerun the same prompt. If it keeps happening, try a different agent ` +
103
88
  `from the dropdown — codehere is vendor-neutral on purpose.\n\n` +
104
- `No files were changed in this attempt.`,
89
+ `codehere couldn't confirm any file changes from this attempt.`,
105
90
  generatedAt: Date.now(),
106
91
  durationMs,
92
+ source: 'error-recovery',
107
93
  };
108
94
  }
109
95
  /**
110
- * Generate a Final Report. Returns null if no provider is reachable.
111
- * Never throws — failure modes return null + log a debug line.
112
- *
113
- * UAT-003 — when the input.status is 'error', we skip the LLM call entirely
114
- * and return a deterministic recovery hint. Saves tokens on a known-failed
115
- * task AND gives the user a plain-English next step instead of waiting for
116
- * a possibly-unhelpful LLM summary.
96
+ * Generate a Final Report. Reuses the agent's own summary; falls back to a
97
+ * deterministic, honest facts report. A completed task ALWAYS gets a report
98
+ * (never null for non-error terminal states) — no external dependency.
117
99
  */
118
100
  export async function generateFinalReport(input) {
119
- // Short-circuit on errored sessions — don't waste tokens summarizing nothing.
101
+ // No mock short-circuit: under CODEHERE_MOCK the SAME engine runs, so the
102
+ // narration gap is reproduced faithfully — claude/aider (emit `say`) get a
103
+ // rich agent-summary; codex/gemini/opencode (no `say`) get the honest
104
+ // deterministic report, exactly as on a real run. (A prior mock-only
105
+ // short-circuit synthesized a report from steps for every agent, hiding the
106
+ // cross-vendor gap — you couldn't dogfood "codex's report is thin" under mock.)
107
+ // Errored session — deterministic recovery hint, no summarization of nothing.
120
108
  if (input.status === 'error') {
121
109
  return buildErrorRecoveryReport(input, 0);
122
110
  }
123
- const prompt = PROMPT_TEMPLATE(input);
124
- for (const binary of selectProviderBinary()) {
125
- const result = await tryProvider(binary, prompt, input.sessionId);
126
- if (result) {
127
- // Defense-in-depth: even on 'completed' status, if the generated
128
- // summary leaks a provider network-failure string (e.g., the wrapped
129
- // agent exited 0 but its assistant text was an error message), wrap
130
- // the summary with a recovery preamble.
131
- if (isLikelyNetworkFailureSummary(result.summary)) {
132
- return {
133
- summary: `Couldn't reach the provider cleanly — the agent's session text looks like a network or auth failure.\n\n` +
134
- `What to try: check your internet, run \`codehere health\`, or rerun in a minute. ` +
135
- `If it persists, switch agents from the dropdown.\n\n` +
136
- `— Raw agent message —\n${result.summary}`,
137
- generatedAt: result.generatedAt,
138
- durationMs: result.durationMs,
139
- };
140
- }
141
- return result;
142
- }
111
+ // ENGINE: reuse the agent's own narration (no fresh spawn).
112
+ const agentSummary = extractAgentSummary(input.steps);
113
+ if (agentSummary && !isLikelyNetworkFailureSummary(agentSummary)) {
114
+ return { summary: agentSummary, generatedAt: Date.now(), durationMs: 0, source: 'agent-summary' };
143
115
  }
144
- return null;
145
- }
146
- function tryProvider(binary, prompt, sessionId) {
147
- const startedAt = Date.now();
148
- return new Promise((resolve) => {
149
- let stdout = '';
150
- let stderr = '';
151
- let settled = false;
152
- const finish = (result) => {
153
- if (settled)
154
- return;
155
- settled = true;
156
- resolve(result);
157
- };
158
- const child = spawn(binary, ['--bare', '-p', prompt], {
159
- stdio: ['ignore', 'pipe', 'pipe'],
160
- });
161
- const timeout = setTimeout(() => {
162
- child.kill('SIGTERM');
163
- if (process.env.CODEHERE_DEBUG === '1') {
164
- console.error(`[final-report] ${binary} timeout after ${REPORT_TIMEOUT_MS}ms for ${sessionId}`);
165
- }
166
- finish(null);
167
- }, REPORT_TIMEOUT_MS);
168
- child.on('error', (err) => {
169
- clearTimeout(timeout);
170
- // ENOENT = binary not installed. Silent fallback to next provider in chain.
171
- if (process.env.CODEHERE_DEBUG === '1') {
172
- console.error(`[final-report] ${binary} unavailable: ${err.message}`);
173
- }
174
- finish(null);
175
- });
176
- child.stdout?.on('data', (chunk) => {
177
- stdout += chunk.toString('utf8');
178
- });
179
- child.stderr?.on('data', (chunk) => {
180
- stderr += chunk.toString('utf8');
181
- });
182
- child.on('exit', (code) => {
183
- clearTimeout(timeout);
184
- if (code !== 0) {
185
- if (process.env.CODEHERE_DEBUG === '1') {
186
- console.error(`[final-report] ${binary} exited ${code}: ${stderr.slice(0, 200)}`);
187
- }
188
- finish(null);
189
- return;
190
- }
191
- const summary = stdout.trim();
192
- if (!summary) {
193
- finish(null);
194
- return;
195
- }
196
- finish({ summary, generatedAt: Date.now(), durationMs: Date.now() - startedAt });
197
- });
198
- });
116
+ // FALLBACK: deterministic, honest — always a deliverable, never a fake ✓.
117
+ return buildDeterministicReport(input);
199
118
  }
200
119
  /**
201
120
  * Pretty-print to terminal. Used by session-manager when a spawned task exits.
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { spawn } from 'child_process';
23
23
  import { loadTemplate, renderTemplate } from './narrator-prompt-template.js';
24
+ import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
25
+ import { mockNarration } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
24
26
  /** v0.6 S6 — prompt template lifted into ~/.codehere/narrator-prompt.md so
25
27
  * the user can edit the agent's voice. loadTemplate() falls back to the
26
28
  * baked-in default on missing file or missing required placeholders, so
@@ -37,6 +39,15 @@ function selectProviderBinary() {
37
39
  return ['claude-free', 'claude'];
38
40
  }
39
41
  export async function generateNarration(input) {
42
+ if (isMockMode()) {
43
+ const m = mockNarration({ steps: input.steps });
44
+ return {
45
+ summary: m.summary,
46
+ generatedAt: Date.now(),
47
+ durationMs: m.durationMs,
48
+ stepCountAtSnapshot: input.steps.length,
49
+ };
50
+ }
40
51
  const prompt = buildPrompt(input);
41
52
  const candidates = selectProviderBinary();
42
53
  for (const binary of candidates) {
@@ -21,6 +21,8 @@
21
21
  * available; degrades silently to "(untitled)" when no provider responds.
22
22
  */
23
23
  import { spawn } from 'child_process';
24
+ import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
25
+ import { mockTitle } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
24
26
  const TITLE_TIMEOUT_MS = 15_000; // titles are short; abort fast if stuck
25
27
  const PROMPT_TEMPLATE = (firstPrompt, cwd) => `Generate a 4-6 word project title from this first user prompt.
26
28
 
@@ -163,6 +165,8 @@ export function sanitizeTitle(raw) {
163
165
  * Never throws — failure modes return null.
164
166
  */
165
167
  export async function generateThreadTitle(firstPrompt, cwd) {
168
+ if (isMockMode())
169
+ return sanitizeTitle(mockTitle(firstPrompt, cwd)) || null;
166
170
  const prompt = PROMPT_TEMPLATE(firstPrompt, cwd);
167
171
  for (const binary of selectProviderBinary()) {
168
172
  const result = await tryProvider(binary, prompt);
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Thread export · v0.8 S4 · signed-payload "GitHub for agent sessions"
3
+ *
4
+ * Produces a self-contained, Ed25519-signed bundle of one PersistedSession
5
+ * that a colleague can verify and replay locally. Local-first by design:
6
+ * never relayed through codehere's infrastructure · the user shares the
7
+ * file (or signed URL) by their preferred channel (Slack, email, PR
8
+ * description) and the recipient runs `codehere thread import <file>`.
9
+ *
10
+ * The signature covers a CANONICAL JSON form of the bundle body so two
11
+ * machines that re-serialize the same data get the same hash. Verification
12
+ * recomputes the canonical form, re-derives the hash, checks the signature
13
+ * against the included public key, and returns one of:
14
+ * - 'valid' · signature matches · safe to replay
15
+ * - 'tampered' · signature does NOT match · refuse
16
+ * - 'malformed' · bundle is structurally wrong · refuse
17
+ * - 'unknown_key' · signature valid but public key not in user's trust
18
+ * store · prompt user to trust+continue or refuse
19
+ *
20
+ * Pure logic. The CLI + HTTP routes wrap this and do I/O.
21
+ */
22
+ import { sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync, createPrivateKey, createPublicKey, } from 'crypto';
23
+ /* ──────────────────────────────────────────────────────────────────────── */
24
+ /* Canonicalization · deterministic JSON form so signatures match */
25
+ /* ──────────────────────────────────────────────────────────────────────── */
26
+ /**
27
+ * Stable JSON: object keys sorted, no whitespace, primitives preserved.
28
+ * Two machines that build the same body MUST produce the same bytes for
29
+ * the signature to verify. We deliberately don't use JSON.stringify with
30
+ * a replacer because Node's serializer doesn't guarantee key order across
31
+ * versions/realms.
32
+ */
33
+ export function canonicalize(value) {
34
+ if (value === null)
35
+ return 'null';
36
+ if (typeof value === 'number') {
37
+ if (!Number.isFinite(value))
38
+ throw new Error(`cannot canonicalize non-finite number: ${value}`);
39
+ return JSON.stringify(value);
40
+ }
41
+ if (typeof value === 'string' || typeof value === 'boolean')
42
+ return JSON.stringify(value);
43
+ if (Array.isArray(value)) {
44
+ return '[' + value.map(canonicalize).join(',') + ']';
45
+ }
46
+ if (typeof value === 'object') {
47
+ const obj = value;
48
+ const keys = Object.keys(obj).sort();
49
+ const parts = keys
50
+ .filter((k) => obj[k] !== undefined) // undefined → drop the key (JSON convention)
51
+ .map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k]));
52
+ return '{' + parts.join(',') + '}';
53
+ }
54
+ if (value === undefined)
55
+ return 'null';
56
+ throw new Error(`cannot canonicalize type: ${typeof value}`);
57
+ }
58
+ /** Generate a fresh Ed25519 keypair · used for first-time setup. */
59
+ export function generateKeypair() {
60
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
61
+ return { publicKey, privateKey };
62
+ }
63
+ /** Sign a bundle body and produce the full ThreadBundle.
64
+ * Ed25519 is NOT a hash-then-sign scheme · pass the message directly to
65
+ * crypto.sign(null, msg, key) · the algorithm is implied by the key. */
66
+ export function signBundle(body, keys) {
67
+ const canonical = canonicalize(body);
68
+ const sig = cryptoSign(null, Buffer.from(canonical, 'utf-8'), keys.privateKey);
69
+ const pubRaw = keys.publicKey.export({ format: 'der', type: 'spki' });
70
+ return {
71
+ body,
72
+ signature: sig.toString('base64'),
73
+ publicKey: pubRaw.toString('base64'),
74
+ alg: 'ed25519',
75
+ };
76
+ }
77
+ /**
78
+ * Verify a bundle. Returns one of:
79
+ * - valid · signature matches AND the publicKey is in trustedKeys
80
+ * - valid-untrusted · signature matches but the key isn't in trustedKeys
81
+ * (returned with status='valid' but trusted=false)
82
+ * - tampered · signature doesn't match the recomputed body bytes
83
+ * - malformed · bundle structure broken
84
+ */
85
+ export function verifyBundle(bundle, trustedKeys = new Set()) {
86
+ if (!isBundle(bundle))
87
+ return { status: 'malformed', reason: 'bundle shape is invalid' };
88
+ if (bundle.alg !== 'ed25519')
89
+ return { status: 'malformed', reason: `unsupported alg: ${bundle.alg}` };
90
+ try {
91
+ const canonical = canonicalize(bundle.body);
92
+ const pubBuf = Buffer.from(bundle.publicKey, 'base64');
93
+ const sigBuf = Buffer.from(bundle.signature, 'base64');
94
+ const pubKey = createPublicKey({ key: pubBuf, format: 'der', type: 'spki' });
95
+ const ok = cryptoVerify(null, Buffer.from(canonical, 'utf-8'), pubKey, sigBuf);
96
+ if (!ok)
97
+ return { status: 'tampered', reason: 'signature does not match body' };
98
+ const trusted = trustedKeys.has(bundle.publicKey);
99
+ if (!trusted) {
100
+ return { status: 'unknown_key', bundle, publicKey: bundle.publicKey };
101
+ }
102
+ return { status: 'valid', bundle, trusted: true };
103
+ }
104
+ catch (err) {
105
+ return { status: 'malformed', reason: err instanceof Error ? err.message : String(err) };
106
+ }
107
+ }
108
+ function isBundle(x) {
109
+ if (typeof x !== 'object' || x === null)
110
+ return false;
111
+ const obj = x;
112
+ if (typeof obj.signature !== 'string')
113
+ return false;
114
+ if (typeof obj.publicKey !== 'string')
115
+ return false;
116
+ if (typeof obj.alg !== 'string')
117
+ return false;
118
+ const body = obj.body;
119
+ if (typeof body !== 'object' || body === null)
120
+ return false;
121
+ const b = body;
122
+ if (b.schema !== 1)
123
+ return false;
124
+ if (b.kind !== 'thread')
125
+ return false;
126
+ if (typeof b.sessionId !== 'string')
127
+ return false;
128
+ if (typeof b.session !== 'object' || b.session === null)
129
+ return false;
130
+ return true;
131
+ }
132
+ /* ──────────────────────────────────────────────────────────────────────── */
133
+ /* Key derivation helpers · PEM encoding for storage */
134
+ /* ──────────────────────────────────────────────────────────────────────── */
135
+ export function exportPrivatePem(key) {
136
+ return key.export({ format: 'pem', type: 'pkcs8' }).toString();
137
+ }
138
+ export function exportPublicPem(key) {
139
+ return key.export({ format: 'pem', type: 'spki' }).toString();
140
+ }
141
+ export function importPrivatePem(pem) {
142
+ return createPrivateKey({ key: pem, format: 'pem', type: 'pkcs8' });
143
+ }
144
+ export function importPublicPem(pem) {
145
+ return createPublicKey({ key: pem, format: 'pem', type: 'spki' });
146
+ }
147
+ /** Compute the base64 publicKey string for a KeyObject · same encoding as in bundles. */
148
+ export function publicKeyToBase64(key) {
149
+ return key.export({ format: 'der', type: 'spki' }).toString('base64');
150
+ }
151
+ /** Short fingerprint used in human-readable output. */
152
+ export function shortFingerprint(publicKeyB64) {
153
+ return publicKeyB64.slice(0, 8) + '...' + publicKeyB64.slice(-4);
154
+ }
155
+ //# sourceMappingURL=thread-export.js.map