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.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +87 -168
- package/dist/application/use-cases/generate-narration.js +11 -0
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +211 -16
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +49 -18
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +38 -5
- package/dist/infrastructure/orchestration/codex-adapter.js +150 -22
- package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
- package/dist/infrastructure/orchestration/graphify-context.js +18 -13
- package/dist/infrastructure/orchestration/memory-injector.js +46 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +230 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +112 -6
- package/dist/infrastructure/orchestration/session-persistence.js +140 -31
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1283 -13
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3913 -88
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +32 -1
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +18 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/codehere)
|
|
7
7
|
[](../LICENSE)
|
|
8
|
-
[]()
|
|
9
|
+
[](../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)
|
|
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 —
|
|
2
|
+
* Final Report — the headline deliverable. At task exit, produce a plain-English
|
|
3
|
+
* summary of what the agent did.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
`
|
|
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.
|
|
111
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|