codehere 0.6.1 → 0.7.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.
Files changed (81) 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 +84 -5
  4. package/dist/application/use-cases/generate-narration.js +20 -3
  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 +218 -19
  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/cost/budget-config.js +1 -1
  23. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  24. package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
  25. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  26. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  27. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  28. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  29. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  30. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  31. package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
  32. package/dist/infrastructure/orchestration/context-handoff.js +4 -3
  33. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  34. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  35. package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
  36. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  37. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  38. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  40. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  41. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  42. package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
  43. package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
  44. package/dist/infrastructure/orchestration/session-manager.js +142 -29
  45. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  46. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  47. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  48. package/dist/infrastructure/protocols/cloud-sync.js +10 -11
  49. package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
  50. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  51. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  52. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  53. package/dist/infrastructure/protocols/verify-page.js +941 -0
  54. package/dist/infrastructure/security/trust-scorer.js +76 -1
  55. package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
  56. package/dist/infrastructure/storage/project-thread-migration.js +14 -9
  57. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  58. package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
  59. package/dist/monitoring.js +1 -1
  60. package/dist/presentation/cli/commands/audit-command.js +126 -0
  61. package/dist/presentation/cli/commands/budget-command.js +2 -2
  62. package/dist/presentation/cli/commands/config-command.js +2 -2
  63. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  64. package/dist/presentation/cli/commands/help-command.js +51 -1
  65. package/dist/presentation/cli/commands/init-command.js +5 -4
  66. package/dist/presentation/cli/commands/memory-command.js +29 -15
  67. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  68. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  69. package/dist/presentation/cli/commands/report-command.js +6 -4
  70. package/dist/presentation/cli/commands/setup-command.js +71 -18
  71. package/dist/presentation/cli/commands/status-command.js +0 -4
  72. package/dist/presentation/cli/commands/thread-command.js +288 -0
  73. package/dist/presentation/cli/commands/verify-command.js +233 -0
  74. package/dist/presentation/cli/error-display.js +1 -1
  75. package/dist/presentation/cli/repl.js +52 -2
  76. package/dist/presentation/cli/task-deliverable.js +127 -0
  77. package/dist/testing/cassette.js +125 -0
  78. package/dist/testing/replay-adapter.js +166 -0
  79. package/dist/ui.js +54 -29
  80. package/dist/utils/typo-guard.js +1 -1
  81. package/package.json +4 -2
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Context-source primitive (v0.8 S1).
3
+ *
4
+ * The "external tool" abstraction codehere ships instead of building per-tool
5
+ * integrations. Each adapter takes a URL like `linear://INFRA-247` or
6
+ * `figma://team/api-design/frame/429-response` and returns a `ContextPayload`
7
+ * the spawned coding agent can read as plain text. Routing is by URL scheme;
8
+ * registry is pure data (a Map), so tests can inject mocks without monkey-
9
+ * patching globals.
10
+ *
11
+ * Two principles · captured in chat 2026-05-22:
12
+ * 1. Composable primitive · ship 2-3 reference adapters · community fills
13
+ * the long tail (no enterprise integration matrix).
14
+ * 2. Local-first · NEVER cloud-realtime. Fetch is a read · writes go
15
+ * through the W-PASS gate in v0.9.
16
+ *
17
+ * Pure function shape:
18
+ * - createRegistry() builds an empty registry.
19
+ * - register(reg, source) appends a source · returns the new registry (or
20
+ * mutates in place; both are accepted, see api).
21
+ * - resolve(reg, url) picks the source that handles the URL's scheme · or
22
+ * undefined if none. No throw.
23
+ * - fetch(source, url, ctx) actually invokes the adapter and returns a
24
+ * ContextPayload. The only path that does I/O. Caller passes ctx with
25
+ * network/auth callbacks · the adapter never touches process.env or
26
+ * fetch() directly · easier to mock + safer per local-first.
27
+ */
28
+ export class ContextSourceError extends Error {
29
+ source;
30
+ kind;
31
+ constructor(source, kind, message) {
32
+ super(`[${source}] ${kind}: ${message}`);
33
+ this.source = source;
34
+ this.kind = kind;
35
+ }
36
+ }
37
+ export function createRegistry() {
38
+ return { bySchemeOnly: new Map() };
39
+ }
40
+ /**
41
+ * Register a source. Returns the (mutated) registry so callers can chain.
42
+ * Throws if the scheme is already claimed · two adapters on one scheme
43
+ * would silently shadow each other, which is exactly the class of bug
44
+ * the verifier-agent is meant to catch elsewhere; don't introduce it here.
45
+ */
46
+ export function register(reg, source) {
47
+ if (reg.bySchemeOnly.has(source.scheme)) {
48
+ const existing = reg.bySchemeOnly.get(source.scheme);
49
+ throw new Error(`context-source: scheme '${source.scheme}' already registered by '${existing.id}'; cannot also register '${source.id}'`);
50
+ }
51
+ reg.bySchemeOnly.set(source.scheme, source);
52
+ return reg;
53
+ }
54
+ /**
55
+ * Resolve a URL to a source by scheme. Returns undefined when nothing matches
56
+ * (don't throw · the caller's job to decide if "no adapter" is a user-visible
57
+ * error).
58
+ */
59
+ export function resolve(reg, url) {
60
+ let parsed;
61
+ try {
62
+ parsed = typeof url === 'string' ? new URL(url) : url;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ // URL.protocol includes the trailing ':' · strip for registry lookup.
68
+ const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
69
+ const source = reg.bySchemeOnly.get(scheme);
70
+ if (!source)
71
+ return undefined;
72
+ return { source, parsed };
73
+ }
74
+ /**
75
+ * Fetch via the registry. Returns null when the URL has no registered adapter
76
+ * (so callers can distinguish "no adapter" from "fetch failed").
77
+ */
78
+ export async function fetchViaRegistry(reg, url, ctx) {
79
+ const r = resolve(reg, url);
80
+ if (!r)
81
+ return null;
82
+ const ts = ctx.now ? ctx.now() : Date.now();
83
+ try {
84
+ if (r.source.isConfigured && !r.source.isConfigured(ctx)) {
85
+ ctx.emitAuditLine?.({ source: r.source.id, url, status: 'unconfigured', ts });
86
+ throw new ContextSourceError(r.source.id, 'auth', `adapter '${r.source.id}' is not configured · set ctx.config['${r.source.id}'] (e.g., token, base URL)`);
87
+ }
88
+ const payload = await r.source.fetch(r.parsed, ctx);
89
+ ctx.emitAuditLine?.({ source: r.source.id, url, status: 'ok', ts, bytes: payload.text.length });
90
+ return payload;
91
+ }
92
+ catch (err) {
93
+ const detail = err instanceof Error ? err.message : String(err);
94
+ ctx.emitAuditLine?.({ source: r.source.id, url, status: 'error', ts, detail });
95
+ throw err;
96
+ }
97
+ }
98
+ /** List configured sources for UI rendering. */
99
+ export function listAvailable(reg, ctx) {
100
+ return Array.from(reg.bySchemeOnly.values()).filter((s) => {
101
+ if (!s.isConfigured)
102
+ return true; // adapter doesn't gate · assume usable
103
+ return s.isConfigured(ctx);
104
+ });
105
+ }
106
+ /** List ALL sources regardless of config · for `codehere context-source list`. */
107
+ export function listAll(reg) {
108
+ return Array.from(reg.bySchemeOnly.values());
109
+ }
110
+ /** Default token estimate · `len/4` is the industry-standard rough-tokens. */
111
+ export function estimateTokens(text) {
112
+ return Math.ceil(text.length / 4);
113
+ }
114
+ //# sourceMappingURL=context-source.js.map
@@ -0,0 +1,207 @@
1
+ /**
2
+ * OAuth 2.0 + PKCE flow for context-source providers (v0.8 S2.5).
3
+ *
4
+ * Provider-agnostic state machine that handles the four steps of the
5
+ * code-grant flow with PKCE (RFC 7636) so we don't need a client secret on
6
+ * the user's machine:
7
+ *
8
+ * 1. begin(provider) · build authorize URL · stash state + verifier
9
+ * 2. (browser) user approves · provider redirects to our callback
10
+ * 3. complete(code, state) · exchange code for {access_token, refresh_token, user}
11
+ * 4. refresh(refresh_token) · swap for a fresh access_token
12
+ *
13
+ * Tokens persist in ~/.codehere/context-sources.json (the same file manual
14
+ * configs use). User can revoke from the provider's web UI any time; our
15
+ * disconnect command just deletes the local config.
16
+ *
17
+ * Provider client IDs come from env vars (CODEHERE_LINEAR_CLIENT_ID,
18
+ * CODEHERE_FIGMA_CLIENT_ID) so binaries can ship without secrets baked in.
19
+ * The codehere app registrations are public client IDs · PKCE removes the
20
+ * need for a client secret on the user's machine. Anyone running codehere
21
+ * locally uses the same `codehere-local` app at each provider, no per-user
22
+ * registration needed.
23
+ *
24
+ * IMPORTANT · this module is pure logic. All I/O (httpPost, filesystem,
25
+ * clock) is injected. The daemon's HTTP route at /oauth/callback supplies
26
+ * the production wiring (registry-bootstrap.ts) and tests use mocks.
27
+ */
28
+ import { createHash, randomBytes } from 'crypto';
29
+ const DEFAULT_REDIRECT = 'http://127.0.0.1:3773/oauth/callback';
30
+ export const PROVIDERS = {
31
+ linear: {
32
+ id: 'linear',
33
+ label: 'Linear',
34
+ authorizeUrl: 'https://linear.app/oauth/authorize',
35
+ tokenUrl: 'https://api.linear.app/oauth/token',
36
+ scopes: ['read'],
37
+ clientIdEnv: 'CODEHERE_LINEAR_CLIENT_ID',
38
+ redirectUri: DEFAULT_REDIRECT,
39
+ tokenPostMode: 'form',
40
+ userInfoUrl: 'https://api.linear.app/graphql', // POST { query: '{ viewer { name email } }' }
41
+ },
42
+ figma: {
43
+ id: 'figma',
44
+ label: 'Figma',
45
+ authorizeUrl: 'https://www.figma.com/oauth',
46
+ tokenUrl: 'https://api.figma.com/v1/oauth/token',
47
+ scopes: ['file_read'],
48
+ clientIdEnv: 'CODEHERE_FIGMA_CLIENT_ID',
49
+ redirectUri: DEFAULT_REDIRECT,
50
+ tokenPostMode: 'form',
51
+ userInfoUrl: 'https://api.figma.com/v1/me',
52
+ },
53
+ };
54
+ export function listProviders() {
55
+ return Object.values(PROVIDERS);
56
+ }
57
+ export function getProvider(id) {
58
+ return PROVIDERS[id];
59
+ }
60
+ /* ──────────────────────────────────────────────────────────────────────── */
61
+ /* PKCE helpers */
62
+ /* ──────────────────────────────────────────────────────────────────────── */
63
+ function base64UrlEncode(buf) {
64
+ return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
65
+ }
66
+ /** Random 43-128 char string · we pick 64 bytes (86 chars after b64url). */
67
+ export function generateCodeVerifier() {
68
+ return base64UrlEncode(randomBytes(64));
69
+ }
70
+ export function deriveCodeChallenge(verifier) {
71
+ return base64UrlEncode(createHash('sha256').update(verifier).digest());
72
+ }
73
+ /** Opaque CSRF defense · returned by provider unchanged · we verify on callback. */
74
+ export function generateState() {
75
+ return base64UrlEncode(randomBytes(24));
76
+ }
77
+ export function begin(provider, ctx) {
78
+ if (!ctx.clientId) {
79
+ return { kind: 'not_registered', provider: provider.id, envVar: provider.clientIdEnv };
80
+ }
81
+ const codeVerifier = ctx.rng?.verifier() ?? generateCodeVerifier();
82
+ const codeChallenge = deriveCodeChallenge(codeVerifier);
83
+ const state = ctx.rng?.state() ?? generateState();
84
+ const startedAt = ctx.now ? ctx.now() : Date.now();
85
+ const params = new URLSearchParams({
86
+ response_type: 'code',
87
+ client_id: ctx.clientId,
88
+ redirect_uri: provider.redirectUri,
89
+ scope: provider.scopes.join(' '),
90
+ state,
91
+ code_challenge: codeChallenge,
92
+ code_challenge_method: 'S256',
93
+ });
94
+ return {
95
+ authorizeUrl: `${provider.authorizeUrl}?${params.toString()}`,
96
+ pending: { provider: provider.id, state, codeVerifier, startedAt },
97
+ };
98
+ }
99
+ export async function complete(code, state, ctx) {
100
+ const pending = ctx.pending.get(state);
101
+ if (!pending) {
102
+ return { kind: 'state_unknown', detail: `no pending flow for state '${state.slice(0, 8)}...'` };
103
+ }
104
+ ctx.pending.delete(state); // single-use
105
+ const provider = getProvider(pending.provider);
106
+ if (!provider) {
107
+ return { kind: 'state_unknown', detail: `unknown provider '${pending.provider}'` };
108
+ }
109
+ const maxAge = ctx.maxAgeMs ?? 10 * 60 * 1000;
110
+ const now = ctx.now ? ctx.now() : Date.now();
111
+ if (now - pending.startedAt > maxAge) {
112
+ return { kind: 'state_expired', detail: `pending flow older than ${Math.round(maxAge / 1000)}s` };
113
+ }
114
+ if (!ctx.clientId) {
115
+ return { kind: 'not_registered', detail: provider.clientIdEnv };
116
+ }
117
+ const params = new URLSearchParams({
118
+ grant_type: 'authorization_code',
119
+ code,
120
+ redirect_uri: provider.redirectUri,
121
+ client_id: ctx.clientId,
122
+ code_verifier: pending.codeVerifier,
123
+ });
124
+ const headers = {
125
+ 'Content-Type': 'application/x-www-form-urlencoded',
126
+ Accept: 'application/json',
127
+ };
128
+ const res = await ctx.httpPost(provider.tokenUrl, params.toString(), headers);
129
+ if (res.status !== 200) {
130
+ return {
131
+ kind: 'provider_error',
132
+ detail: `${provider.label} token endpoint returned ${res.status}: ${res.body.slice(0, 200)}`,
133
+ };
134
+ }
135
+ let payload;
136
+ try {
137
+ payload = JSON.parse(res.body);
138
+ }
139
+ catch (err) {
140
+ return {
141
+ kind: 'parse_error',
142
+ detail: `token response not JSON: ${err instanceof Error ? err.message : String(err)}`,
143
+ };
144
+ }
145
+ if (!payload.access_token) {
146
+ return { kind: 'parse_error', detail: 'token response missing access_token' };
147
+ }
148
+ const token = {
149
+ provider: provider.id,
150
+ accessToken: payload.access_token,
151
+ refreshToken: payload.refresh_token,
152
+ expiresAt: typeof payload.expires_in === 'number' ? now + payload.expires_in * 1000 : null,
153
+ scope: payload.scope ?? provider.scopes.join(' '),
154
+ };
155
+ // Best-effort user-info fetch · failure here doesn't undo the connect
156
+ if (ctx.fetchUserInfo && provider.userInfoUrl) {
157
+ try {
158
+ const u = await ctx.fetchUserInfo(token.accessToken, provider.userInfoUrl);
159
+ if (u) {
160
+ const label = u.email || u.name;
161
+ if (label)
162
+ token.userLabel = label;
163
+ }
164
+ }
165
+ catch {
166
+ // ignore · token is valid, user label is decoration
167
+ }
168
+ }
169
+ return token;
170
+ }
171
+ export async function refresh(provider, refreshToken, ctx) {
172
+ if (!ctx.clientId)
173
+ return { kind: 'not_registered', detail: provider.clientIdEnv };
174
+ const params = new URLSearchParams({
175
+ grant_type: 'refresh_token',
176
+ refresh_token: refreshToken,
177
+ client_id: ctx.clientId,
178
+ });
179
+ const res = await ctx.httpPost(provider.tokenUrl, params.toString(), {
180
+ 'Content-Type': 'application/x-www-form-urlencoded',
181
+ Accept: 'application/json',
182
+ });
183
+ if (res.status !== 200) {
184
+ return { kind: 'provider_error', detail: `${provider.label} refresh returned ${res.status}` };
185
+ }
186
+ let payload;
187
+ try {
188
+ payload = JSON.parse(res.body);
189
+ }
190
+ catch (err) {
191
+ return {
192
+ kind: 'parse_error',
193
+ detail: `refresh response not JSON: ${err instanceof Error ? err.message : String(err)}`,
194
+ };
195
+ }
196
+ if (!payload.access_token)
197
+ return { kind: 'parse_error', detail: 'refresh missing access_token' };
198
+ const now = ctx.now ? ctx.now() : Date.now();
199
+ return {
200
+ provider: provider.id,
201
+ accessToken: payload.access_token,
202
+ refreshToken: payload.refresh_token ?? refreshToken,
203
+ expiresAt: typeof payload.expires_in === 'number' ? now + payload.expires_in * 1000 : null,
204
+ scope: payload.scope ?? null,
205
+ };
206
+ }
207
+ //# sourceMappingURL=oauth-flow.js.map
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OAuth token storage · v0.8 S2.5.
3
+ *
4
+ * Persists OAuth tokens to ~/.codehere/context-sources.json next to manual
5
+ * configs. File shape evolved · backward-compatible:
6
+ *
7
+ * {
8
+ * "linear": {
9
+ * "apiKey": "...", // manual mode · still supported
10
+ * "oauth": { // OAuth mode · NEW
11
+ * "accessToken": "...",
12
+ * "refreshToken": "...",
13
+ * "expiresAt": 1700000000000,
14
+ * "scope": "read",
15
+ * "userLabel": "ega@example.com",
16
+ * "connectedAt": 1700000000000
17
+ * }
18
+ * },
19
+ * "figma": { ... }
20
+ * }
21
+ *
22
+ * The linear-source / figma-source adapters read EITHER apiKey OR
23
+ * oauth.accessToken (in S3 we'll merge them under one access path; for now
24
+ * S2.5 just lands the storage shape so the OAuth flow has somewhere to put
25
+ * the token).
26
+ *
27
+ * File permissions · 0600 on write so a multi-user machine can't read each
28
+ * other's tokens.
29
+ */
30
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
31
+ import { homedir } from 'os';
32
+ import { dirname, join } from 'path';
33
+ function configPath() {
34
+ const home = process.env.CODEHERE_HOME || homedir();
35
+ return join(home, '.codehere', 'context-sources.json');
36
+ }
37
+ export function readAllConfig(path) {
38
+ const target = path ?? configPath();
39
+ if (!existsSync(target))
40
+ return {};
41
+ try {
42
+ const raw = readFileSync(target, 'utf-8');
43
+ const parsed = JSON.parse(raw);
44
+ if (typeof parsed !== 'object' || parsed === null)
45
+ return {};
46
+ return parsed;
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ function writeAllConfig(cfg, path) {
53
+ const target = path ?? configPath();
54
+ mkdirSync(dirname(target), { recursive: true });
55
+ writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
56
+ try {
57
+ chmodSync(target, 0o600);
58
+ }
59
+ catch {
60
+ // chmod can fail on FATs · the local-first promise still holds; we just
61
+ // can't enforce 0600. Real-world users on macOS/Linux always succeed.
62
+ }
63
+ }
64
+ /** Persist a freshly-minted OAuth token for the given provider. */
65
+ export function saveToken(token, path) {
66
+ const cfg = readAllConfig(path);
67
+ const existing = cfg[token.provider] ?? {};
68
+ cfg[token.provider] = {
69
+ ...existing,
70
+ oauth: {
71
+ accessToken: token.accessToken,
72
+ refreshToken: token.refreshToken,
73
+ expiresAt: token.expiresAt,
74
+ scope: token.scope,
75
+ userLabel: token.userLabel,
76
+ connectedAt: Date.now(),
77
+ },
78
+ };
79
+ writeAllConfig(cfg, path);
80
+ }
81
+ /** Disconnect · drops only the oauth subtree, preserves any manual apiKey. */
82
+ export function clearToken(providerId, path) {
83
+ const cfg = readAllConfig(path);
84
+ const entry = cfg[providerId];
85
+ if (!entry || !entry.oauth)
86
+ return false;
87
+ delete entry.oauth;
88
+ // If the entry is now empty (no manual apiKey either), drop the whole row.
89
+ const isEmpty = Object.keys(entry).filter((k) => k !== 'oauth').length === 0;
90
+ if (isEmpty)
91
+ delete cfg[providerId];
92
+ writeAllConfig(cfg, path);
93
+ return true;
94
+ }
95
+ /** Read the persisted OAuth token for a provider (undefined if not connected). */
96
+ export function loadToken(providerId, path) {
97
+ const cfg = readAllConfig(path);
98
+ return cfg[providerId]?.oauth;
99
+ }
100
+ export function connectionStatus(providerId, path) {
101
+ const cfg = readAllConfig(path);
102
+ const entry = cfg[providerId];
103
+ if (!entry)
104
+ return { status: 'disconnected' };
105
+ if (entry.oauth?.accessToken) {
106
+ const exp = entry.oauth.expiresAt;
107
+ const expired = typeof exp === 'number' && exp < Date.now();
108
+ return {
109
+ status: expired ? 'expired' : 'connected',
110
+ userLabel: entry.oauth.userLabel,
111
+ connectedAt: entry.oauth.connectedAt,
112
+ expiresAt: exp,
113
+ };
114
+ }
115
+ if (entry.apiKey || entry.token)
116
+ return { status: 'manual' };
117
+ return { status: 'disconnected' };
118
+ }
119
+ //# sourceMappingURL=oauth-store.js.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Registry bootstrap · v0.8 S2.
3
+ *
4
+ * Builds the default ContextSourceRegistry at daemon startup with our 1st-
5
+ * party adapters (Linear, Figma), loads adapter config from
6
+ * `~/.codehere/context-sources.json`, and provides a default
7
+ * ContextSourceFetchContext that wraps node's fetch() for the network path
8
+ * and the audit chain for telemetry.
9
+ *
10
+ * This file is the ONLY place where context-sources touch process.env, the
11
+ * filesystem, and global fetch · everything else is pure code + injected
12
+ * primitives. Makes tests trivial and the security boundary explicit.
13
+ */
14
+ import { existsSync, readFileSync } from 'fs';
15
+ import { homedir } from 'os';
16
+ import { join } from 'path';
17
+ import { createRegistry, register, } from './context-source.js';
18
+ import { linearSource } from './adapters/linear-source.js';
19
+ import { figmaSource } from './adapters/figma-source.js';
20
+ import { drawioSource } from './adapters/drawio-source.js';
21
+ import { githubIssueSource } from './adapters/github-issue-source.js';
22
+ /** Built once per daemon · in-memory · re-read from disk on demand via `reload`. */
23
+ let cached = null;
24
+ export function getDefaultRegistry() {
25
+ if (cached)
26
+ return cached;
27
+ const reg = createRegistry();
28
+ register(reg, linearSource);
29
+ register(reg, figmaSource);
30
+ register(reg, drawioSource);
31
+ register(reg, githubIssueSource);
32
+ cached = reg;
33
+ return reg;
34
+ }
35
+ /** Forget the cached registry · used by tests + future `codehere context-source reload`. */
36
+ export function resetDefaultRegistry() {
37
+ cached = null;
38
+ }
39
+ /**
40
+ * Resolve config path · respects CODEHERE_HOME env so tests can isolate.
41
+ * File format: a JSON object keyed by adapter id, e.g.
42
+ * { "linear": { "apiKey": "..." }, "figma": { "token": "..." } }
43
+ */
44
+ function configPath() {
45
+ const home = process.env.CODEHERE_HOME || homedir();
46
+ return join(home, '.codehere', 'context-sources.json');
47
+ }
48
+ function loadConfig() {
49
+ const path = configPath();
50
+ if (!existsSync(path))
51
+ return {};
52
+ try {
53
+ const raw = readFileSync(path, 'utf-8');
54
+ const parsed = JSON.parse(raw);
55
+ if (typeof parsed !== 'object' || parsed === null)
56
+ return {};
57
+ return parsed;
58
+ }
59
+ catch {
60
+ return {};
61
+ }
62
+ }
63
+ /**
64
+ * Real-network FetchContext · used by the daemon at runtime. Tests inject
65
+ * a fake context so this code path never runs in CI.
66
+ */
67
+ export function createDefaultFetchContext(opts) {
68
+ const config = opts.configOverride ?? loadConfig();
69
+ return {
70
+ config,
71
+ async httpGet(target, headers) {
72
+ const r = await fetch(target, { method: 'GET', headers: headers ?? {} });
73
+ const body = await r.text();
74
+ const hOut = {};
75
+ r.headers.forEach((v, k) => {
76
+ hOut[k.toLowerCase()] = v;
77
+ });
78
+ return { status: r.status, body, headers: hOut };
79
+ },
80
+ emitAuditLine: opts.emitAuditLine,
81
+ now: () => Date.now(),
82
+ };
83
+ }
84
+ export function createDefaultFetchContextWithPost(opts) {
85
+ const base = createDefaultFetchContext(opts);
86
+ return {
87
+ ...base,
88
+ async httpPost(target, body, headers) {
89
+ const r = await fetch(target, { method: 'POST', headers: headers ?? {}, body });
90
+ const text = await r.text();
91
+ const hOut = {};
92
+ r.headers.forEach((v, k) => {
93
+ hOut[k.toLowerCase()] = v;
94
+ });
95
+ return { status: r.status, body: text, headers: hOut };
96
+ },
97
+ };
98
+ }
99
+ //# sourceMappingURL=registry-bootstrap.js.map
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Write-pass target resolver · v0.9 S6 part B.
3
+ *
4
+ * Turns a W-PASS target like `linear://INFRA-247` into a concrete
5
+ * HTTPS request the daemon can fire on the user's behalf, AFTER the
6
+ * user has approved the payload. The resolver layer mirrors the
7
+ * context-source registry · same scheme namespace, opposite direction
8
+ * (read vs write).
9
+ *
10
+ * Resolved request shape:
11
+ * - url · absolute HTTPS
12
+ * - method · same as the intent (POST/PUT/PATCH/DELETE)
13
+ * - headers · auth + content-type · loaded from ctx.config[scheme]
14
+ * - body · JSON-stringified payload
15
+ *
16
+ * Tests pass a fake httpRequest · production uses node's fetch. Same
17
+ * seam as context-source so adapters look symmetric.
18
+ *
19
+ * S6 ships ONE resolver (Linear) to prove the shape. github + figma +
20
+ * drawio + notion follow in subsequent slices · each is a self-contained
21
+ * function in this file, so adding one is a copy-paste-modify, not an
22
+ * architectural change.
23
+ */
24
+ import { gradeDomain, hostOf } from '../../application/use-cases/domain-trust.js';
25
+ export class WritePassResolveError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(`[w-pass resolve] ${code}: ${message}`);
29
+ this.code = code;
30
+ }
31
+ }
32
+ /**
33
+ * Resolve an intent + effective payload into a concrete HTTP request.
34
+ * Throws WritePassResolveError if the scheme isn't supported OR auth
35
+ * is missing · the daemon turns those into a 4xx response BEFORE
36
+ * touching the network.
37
+ */
38
+ export function resolveWriteRequest(intent, effectivePayload, config) {
39
+ const m = intent.target.match(/^([a-z][a-z0-9+.-]*):\/\/(.+)$/);
40
+ if (!m) {
41
+ throw new WritePassResolveError('invalid_target', `bad URL: ${intent.target}`);
42
+ }
43
+ const scheme = m[1];
44
+ const rest = m[2];
45
+ const built = scheme === 'linear'
46
+ ? resolveLinear(rest, intent.method, effectivePayload, config.linear)
47
+ : scheme === 'github'
48
+ ? resolveGitHub(rest, intent.method, effectivePayload, config.github)
49
+ : null;
50
+ if (built) {
51
+ return { ...built, trust: gradeDomain(hostOf(built.url)) };
52
+ }
53
+ throw new WritePassResolveError('unsupported_scheme', `no W-PASS resolver for "${scheme}" yet · supported in S6: linear, github`);
54
+ }
55
+ /* ──────────────────────────────────────────────────────────────────────── */
56
+ /* Linear */
57
+ /* ──────────────────────────────────────────────────────────────────────── */
58
+ function resolveLinear(rest, method, payload, cfg) {
59
+ const auth = cfg?.oauth?.accessToken ? `Bearer ${cfg.oauth.accessToken}` : cfg?.apiKey;
60
+ if (!auth) {
61
+ throw new WritePassResolveError('auth_missing', 'no Linear apiKey or OAuth token in config["linear"] · agent cannot write without explicit credentials');
62
+ }
63
+ // Linear's API is GraphQL · we send mutations via POST. The agent's
64
+ // intent.payload is expected to be { query, variables } or { mutation, variables }.
65
+ // We pass it through verbatim · the human saw + approved it.
66
+ const endpoint = cfg?.endpoint ?? 'https://api.linear.app/graphql';
67
+ return {
68
+ url: endpoint,
69
+ // Linear writes always POST (it's GraphQL) · intent.method is recorded
70
+ // for audit but doesn't change the transport. We log a one-line audit
71
+ // hint when method != POST so the user sees the mediation.
72
+ method: 'POST',
73
+ headers: {
74
+ Authorization: auth,
75
+ 'Content-Type': 'application/json',
76
+ 'X-Codehere-Mediated': 'w-pass',
77
+ 'X-Codehere-Original-Method': method,
78
+ 'X-Codehere-Identifier': rest,
79
+ },
80
+ body: JSON.stringify(payload),
81
+ };
82
+ }
83
+ /* ──────────────────────────────────────────────────────────────────────── */
84
+ /* GitHub */
85
+ /* ──────────────────────────────────────────────────────────────────────── */
86
+ function resolveGitHub(rest, method, payload, cfg) {
87
+ if (!cfg?.token) {
88
+ throw new WritePassResolveError('auth_missing', 'no GitHub token in config["github"] · public-by-URL is READ only · writes need an explicit PAT');
89
+ }
90
+ // rest is e.g. owner/repo/issues/42/comments or owner/repo/issues/42
91
+ const endpoint = cfg.endpoint ?? 'https://api.github.com';
92
+ return {
93
+ url: `${endpoint}/repos/${rest}`,
94
+ method,
95
+ headers: {
96
+ Authorization: `Bearer ${cfg.token}`,
97
+ Accept: 'application/vnd.github+json',
98
+ 'X-GitHub-Api-Version': '2022-11-28',
99
+ 'Content-Type': 'application/json',
100
+ 'User-Agent': 'codehere/0.9 w-pass',
101
+ 'X-Codehere-Mediated': 'w-pass',
102
+ },
103
+ body: JSON.stringify(payload),
104
+ };
105
+ }
106
+ //# sourceMappingURL=write-pass-resolver.js.map
@@ -84,7 +84,7 @@ export function saveBudget(cfg) {
84
84
  mkdirSync(dir, { recursive: true });
85
85
  const lines = [
86
86
  '# codehere daily-spend budget. Edit by hand or via `codehere budget set <amount>`.',
87
- '# daily_usd: hard ceiling for today\'s estimated spend. Set to a number to enable.',
87
+ "# daily_usd: hard ceiling for today's estimated spend. Set to a number to enable.",
88
88
  '# Leave commented out / omit to disable budget enforcement entirely.',
89
89
  ];
90
90
  if (cfg.dailyUsd !== null) {
@@ -36,11 +36,11 @@ function sessionsFile() {
36
36
  * under-promise and miss a real bill. */
37
37
  const COST_PER_M_OUTPUT_TOKENS_USD = {
38
38
  'claude-code': 15.0, // Sonnet 4.6 baseline
39
- 'codex': 10.0, // GPT-5 / codex baseline
40
- 'aider': 15.0, // typically Sonnet
41
- 'gemini': 0, // free tier (Google AI Studio)
42
- 'opencode': 0, // typically free local / NIM-routed
43
- 'conductor': 15.0, // routes to Claude Code by default
39
+ codex: 10.0, // GPT-5 / codex baseline
40
+ aider: 15.0, // typically Sonnet
41
+ gemini: 0, // free tier (Google AI Studio)
42
+ opencode: 0, // typically free local / NIM-routed
43
+ conductor: 15.0, // routes to Claude Code by default
44
44
  };
45
45
  const INPUT_RATE_RATIO = 0.2; // input ≈ 1/5 of output
46
46
  const CACHE_READ_RATIO = 0.1; // cache reads ≈ 1/10 of input (so 1/50 output)