codehere 0.7.1 → 1.0.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 (139) hide show
  1. package/README.md +22 -13
  2. package/dist/application/services/verdict-service.js +154 -0
  3. package/dist/application/use-cases/assess-cross-vendor.js +126 -0
  4. package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
  5. package/dist/application/use-cases/export-aat.js +107 -0
  6. package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
  7. package/dist/application/use-cases/generate-final-report.js +140 -15
  8. package/dist/application/use-cases/generate-narration.js +5 -3
  9. package/dist/application/use-cases/generate-thread-title.js +5 -2
  10. package/dist/application/use-cases/narrator-prompt-template.js +1 -1
  11. package/dist/application/use-cases/plan-auto-review.js +57 -0
  12. package/dist/application/use-cases/reconcile-audit.js +100 -0
  13. package/dist/application/use-cases/rerun-tests.js +226 -0
  14. package/dist/application/use-cases/search-sessions.js +70 -0
  15. package/dist/application/use-cases/should-auto-review.js +71 -0
  16. package/dist/application/use-cases/thread-export.js +90 -0
  17. package/dist/application/use-cases/verify-claim.js +187 -5
  18. package/dist/domain/audit.js +11 -0
  19. package/dist/domain/services/codebase-detector.js +0 -6
  20. package/dist/domain/task.js +91 -0
  21. package/dist/error-handler.js +3 -115
  22. package/dist/index.js +279 -31
  23. package/dist/infrastructure/completion/bash-completion.js +7 -3
  24. package/dist/infrastructure/completion/fish-completion.js +7 -12
  25. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  26. package/dist/infrastructure/config/config-manager.js +4 -3
  27. package/dist/infrastructure/config/product-context.js +1 -19
  28. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  29. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  30. package/dist/infrastructure/context/github-api.js +110 -0
  31. package/dist/infrastructure/context/github-device-flow.js +65 -0
  32. package/dist/infrastructure/context/oauth-store.js +5 -2
  33. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  34. package/dist/infrastructure/cost/budget-config.js +4 -3
  35. package/dist/infrastructure/cost/cost-router.js +16 -23
  36. package/dist/infrastructure/cost/spend-aggregator.js +91 -16
  37. package/dist/infrastructure/cost/team-ledger.js +355 -0
  38. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  39. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  40. package/dist/infrastructure/hooks/claude-sdk-hook.js +139 -3
  41. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  42. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  43. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  44. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  45. package/dist/infrastructure/orchestration/agents-config-seed.js +23 -11
  46. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  47. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  48. package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
  49. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  50. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  51. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  52. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  53. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  54. package/dist/infrastructure/orchestration/failover-config.js +1 -11
  55. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  56. package/dist/infrastructure/orchestration/generic-cli-adapter.js +134 -3
  57. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  58. package/dist/infrastructure/orchestration/memory-injector.js +4 -1
  59. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +167 -9
  60. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  61. package/dist/infrastructure/orchestration/progress-aggregator.js +275 -23
  62. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  63. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  64. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  65. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  66. package/dist/infrastructure/protocols/cloud-sync.js +2 -2
  67. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  68. package/dist/infrastructure/protocols/mcp-server-impl.js +1498 -354
  69. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  70. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  71. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  72. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  73. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  74. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  75. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  76. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  77. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  78. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  79. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  80. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  81. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  82. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  83. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  84. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  85. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  86. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  87. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  88. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  89. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  90. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  91. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  92. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4126
  93. package/dist/infrastructure/protocols/verify-page.js +884 -148
  94. package/dist/infrastructure/security/fix-code.js +144 -0
  95. package/dist/infrastructure/security/fix-secret.js +89 -0
  96. package/dist/infrastructure/security/permissions-config.js +3 -2
  97. package/dist/infrastructure/security/scan-types.js +10 -0
  98. package/dist/infrastructure/security/ship-scan.js +195 -0
  99. package/dist/infrastructure/security/trust-scorer.js +70 -3
  100. package/dist/infrastructure/security/vibe-checks.js +99 -0
  101. package/dist/infrastructure/storage/atomic-write.js +86 -0
  102. package/dist/infrastructure/storage/project-thread-mirror.js +151 -4
  103. package/dist/infrastructure/storage/project-thread-reader.js +262 -25
  104. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  105. package/dist/monitoring.js +2 -2
  106. package/dist/presentation/cli/commands/audit-command.js +79 -4
  107. package/dist/presentation/cli/commands/budget-command.js +2 -2
  108. package/dist/presentation/cli/commands/check-command.js +96 -0
  109. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  110. package/dist/presentation/cli/commands/help-command.js +5 -5
  111. package/dist/presentation/cli/commands/hook-command.js +5 -5
  112. package/dist/presentation/cli/commands/init-command.js +89 -66
  113. package/dist/presentation/cli/commands/memory-command.js +28 -28
  114. package/dist/presentation/cli/commands/permission-command.js +1 -12
  115. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  116. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  117. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  118. package/dist/presentation/cli/commands/report-command.js +48 -32
  119. package/dist/presentation/cli/commands/scan-command.js +52 -0
  120. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  121. package/dist/presentation/cli/commands/search-command.js +51 -0
  122. package/dist/presentation/cli/commands/setup-command.js +31 -8
  123. package/dist/presentation/cli/commands/status-command.js +39 -86
  124. package/dist/presentation/cli/commands/team-command.js +256 -0
  125. package/dist/presentation/cli/commands/thread-command.js +87 -5
  126. package/dist/presentation/cli/commands/trust-command.js +53 -31
  127. package/dist/presentation/cli/commands/verify-command.js +74 -9
  128. package/dist/presentation/cli/error-display.js +0 -8
  129. package/dist/presentation/cli/repl.js +15 -7
  130. package/dist/presentation/cli/task-deliverable.js +36 -10
  131. package/dist/testing/replay-adapter.js +0 -19
  132. package/dist/ui.js +51 -239
  133. package/dist/utils/version.js +0 -6
  134. package/package.json +30 -10
  135. package/dist/domain/entities/task.js +0 -61
  136. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  137. package/dist/infrastructure/security/input-validator.js +0 -152
  138. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  139. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -95,7 +95,7 @@ export const figmaSource = {
95
95
  if (!node)
96
96
  throw new ContextSourceError('figma', 'not_found', `node ${nodeId} not in response`);
97
97
  text = renderNodeAsTree(node, fileKey, nodeId);
98
- metadata = { fileKey, nodeId, type: node.type, name: node.name };
98
+ metadata = { fileKey, nodeId, type: node.type, name: node.name, frames: collectFrames(node) };
99
99
  }
100
100
  else {
101
101
  const parsed = JSON.parse(res.body);
@@ -104,7 +104,12 @@ export const figmaSource = {
104
104
  if (!parsed.document)
105
105
  throw new ContextSourceError('figma', 'parse', 'Figma response missing document');
106
106
  text = renderFile(parsed, fileKey);
107
- metadata = { fileKey, name: parsed.name ?? null, lastModified: parsed.lastModified ?? null };
107
+ metadata = {
108
+ fileKey,
109
+ name: parsed.name ?? null,
110
+ lastModified: parsed.lastModified ?? null,
111
+ frames: collectFrames(parsed.document),
112
+ };
108
113
  }
109
114
  return {
110
115
  url: url.toString(),
@@ -116,6 +121,36 @@ export const figmaSource = {
116
121
  };
117
122
  },
118
123
  };
124
+ function collectFrames(root, limit = 12) {
125
+ const frames = [];
126
+ const visit = (node) => {
127
+ if (frames.length >= limit)
128
+ return;
129
+ if (node.type === 'FRAME') {
130
+ const bb = node.absoluteBoundingBox;
131
+ frames.push({
132
+ name: node.name,
133
+ width: bb ? Math.round(bb.width) : null,
134
+ height: bb ? Math.round(bb.height) : null,
135
+ text: firstText(node),
136
+ });
137
+ }
138
+ for (const child of node.children ?? [])
139
+ visit(child);
140
+ };
141
+ visit(root);
142
+ return frames;
143
+ }
144
+ function firstText(node) {
145
+ if (node.characters)
146
+ return node.characters.replace(/\s+/g, ' ').slice(0, MAX_TEXT_PER_NODE);
147
+ for (const child of node.children ?? []) {
148
+ const t = firstText(child);
149
+ if (t)
150
+ return t;
151
+ }
152
+ return null;
153
+ }
119
154
  function renderFile(file, fileKey) {
120
155
  const lines = [];
121
156
  lines.push(`Figma file · ${file.name ?? '(unnamed)'}`);
@@ -61,8 +61,9 @@ export const githubIssueSource = {
61
61
  'X-GitHub-Api-Version': '2022-11-28',
62
62
  'User-Agent': 'codehere/0.8',
63
63
  };
64
- if (cfg.token)
65
- headers['Authorization'] = `Bearer ${cfg.token}`;
64
+ const bearer = cfg.token ?? cfg.oauth?.accessToken;
65
+ if (bearer)
66
+ headers['Authorization'] = `Bearer ${bearer}`;
66
67
  const issueRes = await ctx.httpGet(`${endpoint}/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, headers);
67
68
  if (issueRes.status === 404) {
68
69
  throw new ContextSourceError('github', 'not_found', `${ref.owner}/${ref.repo}#${ref.number} not found`);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Minimal GitHub REST layer for the integration — reads an issue and turns it
3
+ * into a task prompt. Uses the OAuth token persisted by github-device-flow via
4
+ * oauth-store ('github'). HTTP is injectable so tests run without network.
5
+ *
6
+ * This is the "issues -> tasks" piece of Track 2: fetch the issue, format a
7
+ * prompt; the existing POST /api/task does the actual spawn (one proven path).
8
+ */
9
+ const defaultGet = async (url, headers) => {
10
+ const res = await fetch(url, { headers });
11
+ return { status: res.status, json: () => res.json() };
12
+ };
13
+ /** Accepts an issue URL, "owner/repo#123", or "owner/repo" (number then optional). */
14
+ export function parseRepoRef(input) {
15
+ const s = (input || '').trim();
16
+ const url = s.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/i);
17
+ if (url)
18
+ return { owner: url[1], repo: url[2], number: Number(url[3]) };
19
+ const hash = s.match(/^([^/\s]+)\/([^/\s#]+)#(\d+)$/);
20
+ if (hash)
21
+ return { owner: hash[1], repo: hash[2], number: Number(hash[3]) };
22
+ const plain = s.match(/^([^/\s]+)\/([^/\s#]+)$/);
23
+ if (plain)
24
+ return { owner: plain[1], repo: plain[2] };
25
+ return undefined;
26
+ }
27
+ export async function fetchIssue(token, owner, repo, number, http = defaultGet) {
28
+ const res = await http(`https://api.github.com/repos/${owner}/${repo}/issues/${number}`, {
29
+ Authorization: `Bearer ${token}`,
30
+ Accept: 'application/vnd.github+json',
31
+ 'User-Agent': 'codehere',
32
+ });
33
+ if (res.status === 404)
34
+ throw new Error(`github issue not found: ${owner}/${repo}#${number}`);
35
+ if (res.status === 401 || res.status === 403)
36
+ throw new Error('github auth failed or token lacks repo scope');
37
+ if (res.status >= 400)
38
+ throw new Error(`github api error ${res.status}`);
39
+ const d = (await res.json());
40
+ return {
41
+ number: Number(d.number ?? number),
42
+ title: String(d.title ?? ''),
43
+ body: typeof d.body === 'string' ? d.body : '',
44
+ htmlUrl: String(d.html_url ?? ''),
45
+ state: String(d.state ?? 'open'),
46
+ };
47
+ }
48
+ /** List open issues for a repo so the user can PICK one instead of typing
49
+ * "owner/repo#123" from memory. Filters out pull requests (the issues endpoint
50
+ * returns PRs too — they carry a `pull_request` field). */
51
+ export async function listIssues(token, owner, repo, http = defaultGet) {
52
+ const res = await http(`https://api.github.com/repos/${owner}/${repo}/issues?state=open&per_page=50`, {
53
+ Authorization: `Bearer ${token}`,
54
+ Accept: 'application/vnd.github+json',
55
+ 'User-Agent': 'codehere',
56
+ });
57
+ if (res.status === 404)
58
+ throw new Error(`github repo not found: ${owner}/${repo}`);
59
+ if (res.status === 401 || res.status === 403)
60
+ throw new Error('github auth failed or token lacks repo scope');
61
+ if (res.status >= 400)
62
+ throw new Error(`github api error ${res.status}`);
63
+ const arr = (await res.json());
64
+ return (Array.isArray(arr) ? arr : [])
65
+ .filter((d) => !d.pull_request)
66
+ .map((d) => ({
67
+ number: Number(d.number ?? 0),
68
+ title: String(d.title ?? ''),
69
+ body: typeof d.body === 'string' ? d.body : '',
70
+ htmlUrl: String(d.html_url ?? ''),
71
+ state: String(d.state ?? 'open'),
72
+ }));
73
+ }
74
+ /** Format an issue as a task prompt an agent can act on. */
75
+ export function issueToTaskPrompt(issue) {
76
+ const body = issue.body && issue.body.trim() ? `\n\n${issue.body.trim()}` : '';
77
+ return `Work on GitHub issue #${issue.number}: ${issue.title}${body}\n\nIssue: ${issue.htmlUrl}`;
78
+ }
79
+ /** Pull the issue ref out of a task prompt built by issueToTaskPrompt (the
80
+ * "Issue: <url>" line), so a finished task knows which issue to report back to. */
81
+ export function issueRefFromText(text) {
82
+ const m = (text || '').match(/github\.com\/[^\s)]+\/issues\/\d+/i);
83
+ if (!m)
84
+ return undefined;
85
+ const ref = parseRepoRef(m[0]);
86
+ return ref && ref.number !== undefined ? { owner: ref.owner, repo: ref.repo, number: ref.number } : undefined;
87
+ }
88
+ const defaultSend = async (url, method, headers, body) => {
89
+ const res = await fetch(url, { method, headers, body });
90
+ return { status: res.status, json: () => res.json() };
91
+ };
92
+ /** Post the agent's result back onto the issue as a comment. Non-destructive —
93
+ * closes the issue -> task -> result loop without touching any branches. */
94
+ export async function commentOnIssue(token, owner, repo, number, body, http = defaultSend) {
95
+ const res = await http(`https://api.github.com/repos/${owner}/${repo}/issues/${number}/comments`, 'POST', {
96
+ Authorization: `Bearer ${token}`,
97
+ Accept: 'application/vnd.github+json',
98
+ 'Content-Type': 'application/json',
99
+ 'User-Agent': 'codehere',
100
+ }, JSON.stringify({ body }));
101
+ if (res.status === 401 || res.status === 403)
102
+ throw new Error('github auth failed or token lacks repo scope');
103
+ if (res.status === 404)
104
+ throw new Error('github issue not found (or no access)');
105
+ if (res.status >= 400)
106
+ throw new Error(`github comment failed: ${res.status}`);
107
+ const d = (await res.json());
108
+ return { htmlUrl: String(d.html_url ?? '') };
109
+ }
110
+ //# sourceMappingURL=github-api.js.map
@@ -0,0 +1,65 @@
1
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
2
+ const TOKEN_URL = 'https://github.com/login/oauth/access_token';
3
+ const DEFAULT_SCOPE = 'repo read:org';
4
+ const GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
5
+ /** The public client_id of codehere's registered GitHub OAuth App. This is a
6
+ * PUBLIC value (device flow needs no secret), so it ships as the default — the
7
+ * same way the `gh` CLI bundles its own. Set this once the OAuth App with Device
8
+ * Flow enabled is registered; until then GitHub connect stays inert and the UI
9
+ * shows the "configure your own" fallback. CODEHERE_GITHUB_CLIENT_ID overrides
10
+ * it (for users who prefer their own app). */
11
+ const BUNDLED_GITHUB_CLIENT_ID = '';
12
+ export function githubClientId() {
13
+ const env = (process.env.CODEHERE_GITHUB_CLIENT_ID || '').trim();
14
+ if (env)
15
+ return env;
16
+ const bundled = BUNDLED_GITHUB_CLIENT_ID.trim();
17
+ return bundled || undefined;
18
+ }
19
+ const defaultHttp = async (url, body) => {
20
+ const res = await fetch(url, {
21
+ method: 'POST',
22
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
23
+ body: JSON.stringify(body),
24
+ });
25
+ return (await res.json());
26
+ };
27
+ /** Step 1: ask GitHub for a device + user code. The user enters userCode at
28
+ * verificationUri to approve; we then poll for the token. */
29
+ export async function requestDeviceCode(clientId, scope = DEFAULT_SCOPE, http = defaultHttp) {
30
+ const d = await http(DEVICE_CODE_URL, { client_id: clientId, scope });
31
+ if (!d.device_code) {
32
+ throw new Error('github device-code request failed' + (d.error ? `: ${String(d.error)}` : ''));
33
+ }
34
+ return {
35
+ deviceCode: String(d.device_code),
36
+ userCode: String(d.user_code ?? ''),
37
+ verificationUri: String(d.verification_uri ?? 'https://github.com/login/device'),
38
+ intervalSec: Number(d.interval ?? 5),
39
+ expiresInSec: Number(d.expires_in ?? 900),
40
+ };
41
+ }
42
+ /** Step 2: poll once for the token. Caller repeats at `intervalSec` until it
43
+ * resolves to connected or error (RFC 8628: authorization_pending = keep going,
44
+ * slow_down = back off). */
45
+ export async function pollForToken(clientId, deviceCode, http = defaultHttp) {
46
+ const d = await http(TOKEN_URL, { client_id: clientId, device_code: deviceCode, grant_type: GRANT });
47
+ if (d.access_token) {
48
+ return {
49
+ status: 'connected',
50
+ token: {
51
+ provider: 'github',
52
+ accessToken: String(d.access_token),
53
+ expiresAt: null, // GitHub OAuth App tokens don't expire unless revoked
54
+ scope: d.scope ? String(d.scope) : null,
55
+ },
56
+ };
57
+ }
58
+ const err = String(d.error ?? 'unknown');
59
+ if (err === 'authorization_pending')
60
+ return { status: 'pending' };
61
+ if (err === 'slow_down')
62
+ return { status: 'slow_down', intervalSec: Number(d.interval ?? 10) };
63
+ return { status: 'error', error: err };
64
+ }
65
+ //# sourceMappingURL=github-device-flow.js.map
@@ -27,7 +27,8 @@
27
27
  * File permissions · 0600 on write so a multi-user machine can't read each
28
28
  * other's tokens.
29
29
  */
30
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
30
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from 'fs';
31
+ import { writeFileAtomic } from '../storage/atomic-write.js';
31
32
  import { homedir } from 'os';
32
33
  import { dirname, join } from 'path';
33
34
  function configPath() {
@@ -52,7 +53,9 @@ export function readAllConfig(path) {
52
53
  function writeAllConfig(cfg, path) {
53
54
  const target = path ?? configPath();
54
55
  mkdirSync(dirname(target), { recursive: true });
55
- writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
56
+ // mode on the TEMP file, so the credentials are never briefly world-readable
57
+ // the way write-then-chmod leaves them. The chmod below stays as belt-and-braces.
58
+ writeFileAtomic(target, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
56
59
  try {
57
60
  chmodSync(target, 0o600);
58
61
  }
@@ -32,10 +32,6 @@ export function getDefaultRegistry() {
32
32
  cached = reg;
33
33
  return reg;
34
34
  }
35
- /** Forget the cached registry · used by tests + future `codehere context-source reload`. */
36
- export function resetDefaultRegistry() {
37
- cached = null;
38
- }
39
35
  /**
40
36
  * Resolve config path · respects CODEHERE_HOME env so tests can isolate.
41
37
  * File format: a JSON object keyed by adapter id, e.g.
@@ -1,3 +1,4 @@
1
+ import { writeFileAtomic } from '../storage/atomic-write.js';
1
2
  /**
2
3
  * v0.6 S3 slice 2 — daily spend budget config.
3
4
  *
@@ -21,7 +22,7 @@
21
22
  * the budget is a USER ceiling, not a codehere guarantee — we can't
22
23
  * see the actual provider invoice in real time.
23
24
  */
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
25
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
25
26
  import { dirname, join } from 'path';
26
27
  import { homedir } from 'os';
27
28
  const DEFAULT_CONFIG = {
@@ -29,7 +30,7 @@ const DEFAULT_CONFIG = {
29
30
  warnAtPct: 70,
30
31
  };
31
32
  function configFile() {
32
- return join(homedir(), '.codehere', 'budget.yaml');
33
+ return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'budget.yaml');
33
34
  }
34
35
  /** Parse a tiny subset of YAML — `key: value` pairs, # comments, blank lines.
35
36
  * No nesting, no lists, no multi-line. Same approach as permissions-config:
@@ -95,7 +96,7 @@ export function saveBudget(cfg) {
95
96
  }
96
97
  lines.push(`warn_at_pct: ${cfg.warnAtPct}`);
97
98
  lines.push('');
98
- writeFileSync(path, lines.join('\n'), 'utf-8');
99
+ writeFileAtomic(path, lines.join('\n'));
99
100
  }
100
101
  /** True when today's estimated spend has hit or exceeded the daily ceiling.
101
102
  * Returns false when no budget is set (no enforcement). */
@@ -6,9 +6,10 @@
6
6
  * routing decisions have real financial impact at scale.
7
7
  *
8
8
  * Model tiers (approximate $/M output tokens, 2026):
9
- * fast tier (e.g. claude-haiku-4-5) $1.25 — cheap, good for simple tasks
10
- * balanced tier (e.g. claude-sonnet-4-6) $15.00 — good for most tasks
11
- * max tier (e.g. claude-opus-4-6) $75.00 — strongest, use sparingly
9
+ * fast tier (claude-haiku-4-5) $1.25 — cheap, good for simple tasks
10
+ * balanced tier (claude-sonnet-4-6) $15.00 — good for most tasks
11
+ * max tier (claude-fable-5) $50.00 — strongest (Anthropic's current flagship,
12
+ * Mythos-class; 1M ctx); use sparingly
12
13
  *
13
14
  * Routing logic:
14
15
  * trust >= 90, simple task → fast tier (60x cheaper than max tier)
@@ -16,15 +17,20 @@
16
17
  * trust < 75, or critical → balanced tier with human review flag
17
18
  * critical findings present → block before reaching model
18
19
  */
19
- const MODEL_COSTS = {
20
+ export const MODEL_COSTS = {
20
21
  haiku: 1.25,
21
22
  sonnet: 15.0,
22
- opus: 75.0,
23
+ opus: 50.0,
23
24
  };
25
+ // NB: the `opus` key is the internal name for the MAX / strongest tier. Its model
26
+ // is now Claude Fable 5 (Anthropic's current flagship, released 2026-06-09 —
27
+ // stronger than Opus 4.8, which it falls back to on high-risk queries). User-facing
28
+ // surfaces show the tier label ("max tier") + this modelId, so the key name is
29
+ // internal-only.
24
30
  const MODEL_IDS = {
25
31
  haiku: 'claude-haiku-4-5-20251001',
26
32
  sonnet: 'claude-sonnet-4-6',
27
- opus: 'claude-opus-4-6',
33
+ opus: 'claude-fable-5',
28
34
  };
29
35
  const TIER_LABELS = {
30
36
  haiku: 'fast tier',
@@ -84,21 +90,8 @@ export function recommendModel(trustScore, complexity = 'standard', criticalFind
84
90
  humanReview: true,
85
91
  };
86
92
  }
87
- /**
88
- * Estimate monthly savings from routing decisions.
89
- * @param avgTokensPerTask Average output tokens per AI task
90
- * @param tasksPerMonth Monthly task volume
91
- * @param distribution Fraction of tasks at each tier
92
- */
93
- export function estimateMonthlySavings(avgTokensPerTask = 2000, tasksPerMonth = 500, distribution = { haiku: 0.4, sonnet: 0.55, opus: 0.05 }) {
94
- const tokensMillion = avgTokensPerTask / 1_000_000;
95
- let routedCost = 0;
96
- for (const [tier, fraction] of Object.entries(distribution)) {
97
- routedCost += fraction * tasksPerMonth * tokensMillion * MODEL_COSTS[tier];
98
- }
99
- const opusCost = tasksPerMonth * tokensMillion * MODEL_COSTS.opus;
100
- const saving = opusCost - routedCost;
101
- const savingPct = (saving / opusCost) * 100;
102
- return { routedCost, opusCost, saving, savingPct };
103
- }
93
+ // MED #29 (atomic audit) · estimateMonthlySavings deleted: a pure projection
94
+ // with zero production callers whose output ("~83% savings") had been quoted
95
+ // as a shipped outcome. The honest claim now lives in PRODUCT_STATE (🟡,
96
+ // projection); the budget gate carries the cost-bounded pillar.
104
97
  //# sourceMappingURL=cost-router.js.map
@@ -21,11 +21,11 @@
21
21
  */
22
22
  import { existsSync, readFileSync } from 'fs';
23
23
  import { homedir } from 'os';
24
- import { join } from 'path';
24
+ import { basename, join } from 'path';
25
25
  /** Resolved at call time, not module load, so test setups that swap $HOME
26
26
  * after import still get a fresh path on each invocation. */
27
27
  function sessionsFile() {
28
- return join(homedir(), '.codehere', 'sessions.jsonl');
28
+ return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
29
29
  }
30
30
  /** USD per million OUTPUT tokens at the agent's typical 2026 tier. Input is
31
31
  * estimated at 1/5 of output rate (Anthropic + OpenAI both publish ~5x
@@ -34,7 +34,7 @@ function sessionsFile() {
34
34
  *
35
35
  * Conservative-ceiling philosophy: better to over-estimate spend than
36
36
  * under-promise and miss a real bill. */
37
- const COST_PER_M_OUTPUT_TOKENS_USD = {
37
+ export const COST_PER_M_OUTPUT_TOKENS_USD = {
38
38
  'claude-code': 15.0, // Sonnet 4.6 baseline
39
39
  codex: 10.0, // GPT-5 / codex baseline
40
40
  aider: 15.0, // typically Sonnet
@@ -44,28 +44,69 @@ const COST_PER_M_OUTPUT_TOKENS_USD = {
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)
47
+ /** Per-MODEL published rates (USD per million tokens), 2026. Matched by substring
48
+ * so version suffixes resolve (claude-opus-4-x, gpt-5-codex, gemini-2.5-pro).
49
+ * This is what makes an Opus run cost more than a Haiku run instead of one flat
50
+ * per-agent tier, and prices a paid model even when the agent's default is free.
51
+ * Still an estimate, not an invoice — see the file header. */
52
+ const MODEL_RATES = [
53
+ // Anthropic — verified against platform.claude.com rate card (2026-07). Standard
54
+ // (post-introductory) rates; Sonnet 5's Aug-2026 intro price is lower, so the
55
+ // standard rate keeps the estimate conservative rather than under-billing.
56
+ { match: /fable|mythos/i, inputUsd: 10, outputUsd: 50 }, // Claude 5 flagship tier
57
+ { match: /opus/i, inputUsd: 5, outputUsd: 25 },
58
+ { match: /sonnet/i, inputUsd: 3, outputUsd: 15 },
59
+ { match: /haiku/i, inputUsd: 1, outputUsd: 5 },
60
+ { match: /gpt-5|codex/i, inputUsd: 1.25, outputUsd: 10 },
61
+ { match: /gemini.*(2\.5.*pro|1\.5.*pro|-pro)/i, inputUsd: 1.25, outputUsd: 10 }, // paid Gemini Pro tiers
62
+ ];
63
+ /** Self-hosted / local backends cost nothing per token — never bill them at a
64
+ * cloud tier just because the agent (e.g. aider) usually runs on a paid model. */
65
+ const LOCAL_MODEL = /(?:^|[/:])(?:ollama(?:_chat)?|lmstudio|lm-studio|llama[._-]?cpp|local)\b/i;
66
+ export function modelRate(model) {
67
+ if (!model)
68
+ return undefined;
69
+ for (const r of MODEL_RATES)
70
+ if (r.match.test(model))
71
+ return { inputUsd: r.inputUsd, outputUsd: r.outputUsd };
72
+ return undefined;
73
+ }
47
74
  /**
48
- * Estimate dollar cost for one session's token usage at one agent's typical tier.
49
- * Returns 0 for free-tier agents and unknown agents (no mapping → no estimate).
75
+ * Estimate dollar cost for one session's token usage. Per-model rates when the
76
+ * adapter surfaced the real model (accurate input AND output); otherwise the
77
+ * agent's typical output tier (input ≈ 1/5). Returns 0 when neither is priced.
50
78
  */
51
79
  export function estimateCostUsd(agent, usage) {
52
80
  if (!usage)
53
81
  return 0;
54
- const outputRate = COST_PER_M_OUTPUT_TOKENS_USD[agent];
55
- if (outputRate === undefined || outputRate === 0)
56
- return 0;
57
- const inputRate = outputRate * INPUT_RATE_RATIO;
58
- const cacheReadRate = inputRate * CACHE_READ_RATIO;
59
- // Cache CREATION is billed at input rate (Anthropic). Use same default.
60
82
  const tokens = {
61
83
  input: Math.max(0, usage.inputTokens || 0),
62
84
  output: Math.max(0, usage.outputTokens || 0),
63
85
  cacheRead: Math.max(0, usage.cacheReadTokens || 0),
64
86
  cacheCreate: Math.max(0, usage.cacheCreationTokens || 0),
65
87
  };
88
+ // Local/self-hosted models are free to call — don't bill them at cloud rates.
89
+ // EXCEPT ollama's `:cloud`-tagged models: no local weights, proxied to Ollama's
90
+ // billed cloud — "ollama/glm-5.2:cloud" must NOT be recorded as $0.
91
+ if (usage.model && LOCAL_MODEL.test(usage.model) && !/:cloud$/i.test(usage.model))
92
+ return 0;
93
+ // Per-model when the real model is known — also prices a paid model whose agent
94
+ // default tier is 0 (closes the gemini/opencode $0-on-a-paid-backend hole).
95
+ const mr = modelRate(usage.model);
96
+ if (mr) {
97
+ return ((tokens.input / 1_000_000) * mr.inputUsd +
98
+ (tokens.output / 1_000_000) * mr.outputUsd +
99
+ (tokens.cacheRead / 1_000_000) * (mr.inputUsd * CACHE_READ_RATIO) +
100
+ (tokens.cacheCreate / 1_000_000) * mr.inputUsd);
101
+ }
102
+ // Fallback: the agent's typical tier. No regression for sessions without a model.
103
+ const outputRate = COST_PER_M_OUTPUT_TOKENS_USD[agent];
104
+ if (outputRate === undefined || outputRate === 0)
105
+ return 0;
106
+ const inputRate = outputRate * INPUT_RATE_RATIO;
66
107
  return ((tokens.input / 1_000_000) * inputRate +
67
108
  (tokens.output / 1_000_000) * outputRate +
68
- (tokens.cacheRead / 1_000_000) * cacheReadRate +
109
+ (tokens.cacheRead / 1_000_000) * (inputRate * CACHE_READ_RATIO) +
69
110
  (tokens.cacheCreate / 1_000_000) * inputRate);
70
111
  }
71
112
  /** Return the millisecond timestamp for the start of "today" in local time. */
@@ -87,6 +128,8 @@ export function computeSpendToday(now = new Date()) {
87
128
  tokens: { input: 0, output: 0, cache: 0 },
88
129
  sessionCount: 0,
89
130
  byAgent: {},
131
+ byProject: {},
132
+ practice: { usd: 0, sessions: 0 },
90
133
  windowStartedAt: cutoff,
91
134
  };
92
135
  if (!existsSync(sessionsFile()))
@@ -98,9 +141,12 @@ export function computeSpendToday(now = new Date()) {
98
141
  catch {
99
142
  return empty;
100
143
  }
101
- // Pair spawned (carries agent) with completed (carries usage + completedAt).
144
+ // Pair spawned (carries agent + cwd + mockRun) with completed (carries usage + completedAt).
102
145
  const spawnedAgent = new Map();
103
- const report = { ...empty, byAgent: {} };
146
+ const spawnedProject = new Map();
147
+ const spawnedMock = new Set();
148
+ const completedById = new Map();
149
+ const report = { ...empty, byAgent: {}, byProject: {}, practice: { usd: 0, sessions: 0 } };
104
150
  for (const line of content.split('\n')) {
105
151
  const trimmed = line.trim();
106
152
  if (!trimmed)
@@ -114,17 +160,37 @@ export function computeSpendToday(now = new Date()) {
114
160
  }
115
161
  if (rec.t === 'spawned' && rec.id && rec.agent) {
116
162
  spawnedAgent.set(rec.id, rec.agent);
163
+ if (rec.cwd)
164
+ spawnedProject.set(rec.id, basename(rec.cwd));
165
+ if (rec.mockRun === true)
166
+ spawnedMock.add(rec.id);
117
167
  continue;
118
168
  }
119
169
  if (rec.t !== 'completed' || !rec.id)
120
170
  continue;
121
171
  if (typeof rec.completedAt !== 'number' || rec.completedAt < cutoff)
122
172
  continue;
123
- const agent = spawnedAgent.get(rec.id);
124
- if (!agent)
173
+ if (!spawnedAgent.has(rec.id))
125
174
  continue;
175
+ // ONE session = ONE count: a session legitimately carries MULTIPLE
176
+ // completed rows (the exit handler writes one, the async test-observation
177
+ // re-persists a corrected one — loadRecent dedups by id, later wins).
178
+ // Summing every row DOUBLED cost + session counts on the team record
179
+ // (found live 2026-07-07: 2 real runs read as 4 sessions / 2x spend).
180
+ // Same semantics as the reader: keep the LAST row per id.
181
+ completedById.set(rec.id, rec);
182
+ }
183
+ for (const [id, rec] of completedById) {
184
+ const agent = spawnedAgent.get(id);
126
185
  const usage = rec.artifacts?.usage;
127
186
  const usd = estimateCostUsd(agent, usage);
187
+ // Practice runs divert to their own bucket — a mock's fabricated "cost"
188
+ // never joins the real totals, the by-* breakdowns, or the budget's view.
189
+ if (spawnedMock.has(id)) {
190
+ report.practice.usd += usd;
191
+ report.practice.sessions += 1;
192
+ continue;
193
+ }
128
194
  report.sessionCount += 1;
129
195
  if (usage) {
130
196
  report.tokens.input += usage.inputTokens || 0;
@@ -136,12 +202,21 @@ export function computeSpendToday(now = new Date()) {
136
202
  cur.usd += usd;
137
203
  cur.sessions += 1;
138
204
  report.byAgent[agent] = cur;
205
+ const project = spawnedProject.get(id) || 'unknown';
206
+ const pcur = report.byProject[project] || { usd: 0, sessions: 0 };
207
+ pcur.usd += usd;
208
+ pcur.sessions += 1;
209
+ report.byProject[project] = pcur;
139
210
  }
140
211
  // Round totals to 4 decimals (1/100 of a cent) — final display rounds to 2.
141
212
  report.totalUsd = Math.round(report.totalUsd * 10_000) / 10_000;
142
213
  for (const k of Object.keys(report.byAgent)) {
143
214
  report.byAgent[k].usd = Math.round(report.byAgent[k].usd * 10_000) / 10_000;
144
215
  }
216
+ for (const k of Object.keys(report.byProject)) {
217
+ report.byProject[k].usd = Math.round(report.byProject[k].usd * 10_000) / 10_000;
218
+ }
219
+ report.practice.usd = Math.round(report.practice.usd * 10_000) / 10_000;
145
220
  return report;
146
221
  }
147
222
  //# sourceMappingURL=spend-aggregator.js.map