co-maintainer 0.5.0 → 0.5.1-beta.2

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 (177) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/opencode.d.ts +10 -0
  14. package/dist/src/ai/opencode.js +76 -0
  15. package/dist/src/ai/openrouter.d.ts +23 -7
  16. package/dist/src/ai/openrouter.js +39 -23
  17. package/dist/src/ai/pricing.js +1 -1
  18. package/dist/src/ai/provider.d.ts +9 -0
  19. package/dist/src/ai/provider.js +42 -3
  20. package/dist/src/ai/verify.d.ts +9 -1
  21. package/dist/src/ai/verify.js +81 -3
  22. package/dist/src/cli/args.d.ts +1 -2
  23. package/dist/src/cli/args.js +38 -26
  24. package/dist/src/cli/commands/config.d.ts +1 -1
  25. package/dist/src/cli/commands/config.js +10 -4
  26. package/dist/src/cli/commands/probe.js +3 -11
  27. package/dist/src/cli/commands/registry.d.ts +1 -9
  28. package/dist/src/cli/commands/registry.js +75 -10
  29. package/dist/src/cli/commands/review.js +18 -13
  30. package/dist/src/cli/commands/rollback.d.ts +1 -0
  31. package/dist/src/cli/commands/rollback.js +42 -0
  32. package/dist/src/cli/commands/serve.js +2 -3
  33. package/dist/src/cli/commands/set.js +71 -7
  34. package/dist/src/cli/commands/view.js +10 -9
  35. package/dist/src/cli/error.d.ts +4 -4
  36. package/dist/src/cli/error.js +4 -4
  37. package/dist/src/cli/main.d.ts +4 -4
  38. package/dist/src/cli/main.js +13 -8
  39. package/dist/src/cli/prompt.js +1 -1
  40. package/dist/src/cli/review_args.d.ts +9 -3
  41. package/dist/src/cli/review_args.js +28 -5
  42. package/dist/src/cli/review_github.d.ts +31 -0
  43. package/dist/src/cli/review_github.js +128 -0
  44. package/dist/src/cli/review_result.d.ts +27 -16
  45. package/dist/src/cli/review_result.js +7 -7
  46. package/dist/src/config.d.ts +28 -6
  47. package/dist/src/config.js +15 -1
  48. package/dist/src/git/default_branch.d.ts +1 -1
  49. package/dist/src/git/default_branch.js +1 -1
  50. package/dist/src/github/app.js +2 -2
  51. package/dist/src/github/app_manifest.d.ts +1 -7
  52. package/dist/src/github/app_manifest.js +2 -7
  53. package/dist/src/github/client.d.ts +4 -1
  54. package/dist/src/github/client.js +14 -2
  55. package/dist/src/github/gh.js +10 -7
  56. package/dist/src/github/pat.js +4 -4
  57. package/dist/src/github/permissions.d.ts +36 -0
  58. package/dist/src/github/permissions.js +334 -0
  59. package/dist/src/knowledge/facts.js +1 -1
  60. package/dist/src/knowledge/guide.d.ts +3 -3
  61. package/dist/src/knowledge/guide.js +5 -5
  62. package/dist/src/knowledge/probe.js +1 -2
  63. package/dist/src/knowledge/sections.d.ts +3 -3
  64. package/dist/src/knowledge/sections.js +3 -3
  65. package/dist/src/knowledge/skill.d.ts +1 -1
  66. package/dist/src/knowledge/skill.js +4 -4
  67. package/dist/src/knowledge/synthesis.d.ts +1 -1
  68. package/dist/src/knowledge/synthesis.js +5 -5
  69. package/dist/src/knowledge/types.d.ts +1 -1
  70. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  71. package/dist/src/local/codegraph_exclude.js +1 -1
  72. package/dist/src/local/codegraph_prepare.js +1 -1
  73. package/dist/src/local/git_ops.d.ts +1 -1
  74. package/dist/src/local/git_ops.js +1 -1
  75. package/dist/src/local/git_parse.d.ts +2 -2
  76. package/dist/src/local/git_parse.js +2 -2
  77. package/dist/src/local/git_revision.d.ts +1 -1
  78. package/dist/src/local/git_revision.js +1 -1
  79. package/dist/src/local/git_untracked.d.ts +1 -1
  80. package/dist/src/local/git_untracked.js +1 -1
  81. package/dist/src/local/review_local.js +17 -14
  82. package/dist/src/local/review_lock.d.ts +1 -1
  83. package/dist/src/local/review_lock.js +1 -1
  84. package/dist/src/pr/checkout.js +2 -2
  85. package/dist/src/pr/findings.js +1 -1
  86. package/dist/src/pr/findings_json.d.ts +2 -2
  87. package/dist/src/pr/findings_json.js +1 -1
  88. package/dist/src/pr/github_revision.js +1 -1
  89. package/dist/src/pr/review_copy.d.ts +2 -2
  90. package/dist/src/pr/review_copy.js +2 -2
  91. package/dist/src/pr/reviewer.d.ts +6 -0
  92. package/dist/src/pr/reviewer.js +49 -39
  93. package/dist/src/remote/client.d.ts +4 -0
  94. package/dist/src/remote/client.js +84 -23
  95. package/dist/src/remote/fixtures.js +1 -1
  96. package/dist/src/remote/http.d.ts +10 -1
  97. package/dist/src/remote/http.js +15 -3
  98. package/dist/src/remote/schema.d.ts +3 -3
  99. package/dist/src/remote/schema.js +3 -3
  100. package/dist/src/remote/server/body.d.ts +1 -1
  101. package/dist/src/remote/server/body.js +1 -1
  102. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  103. package/dist/src/remote/server/byok_keys.js +40 -0
  104. package/dist/src/remote/server/guides.js +2 -2
  105. package/dist/src/remote/server/routes.js +8 -1
  106. package/dist/src/remote/validate.js +15 -1
  107. package/dist/src/review/blocking.d.ts +2 -2
  108. package/dist/src/review/blocking.js +2 -2
  109. package/dist/src/review/carry_over.d.ts +2 -2
  110. package/dist/src/review/carry_over.js +2 -2
  111. package/dist/src/review/guides.js +1 -1
  112. package/dist/src/server/api/remote_tokens.js +2 -0
  113. package/dist/src/server/api/repos.js +51 -0
  114. package/dist/src/server/api/settings.js +10 -0
  115. package/dist/src/server/errors.d.ts +1 -1
  116. package/dist/src/server/http.js +1 -1
  117. package/dist/src/server/pages/activity.d.ts +2 -0
  118. package/dist/src/server/pages/activity.js +18 -4
  119. package/dist/src/server/pages/add_repo.js +61 -1
  120. package/dist/src/server/pages/analytics.js +18 -8
  121. package/dist/src/server/pages/cost.d.ts +21 -0
  122. package/dist/src/server/pages/cost.js +72 -0
  123. package/dist/src/server/pages/pr.js +5 -4
  124. package/dist/src/server/pages/repo.js +3 -2
  125. package/dist/src/server/pages/repo_prs.js +3 -2
  126. package/dist/src/server/pages/repo_remote.js +3 -2
  127. package/dist/src/server/pages/repo_settings.js +326 -12
  128. package/dist/src/server/pages/router.js +3 -2
  129. package/dist/src/server/pages/settings.js +50 -6
  130. package/dist/src/server/pages/styles.d.ts +1 -1
  131. package/dist/src/server/pages/styles.js +1 -1
  132. package/dist/src/services/credentials.js +8 -11
  133. package/dist/src/services/dashboard.d.ts +22 -9
  134. package/dist/src/services/dashboard.js +16 -2
  135. package/dist/src/services/probe.js +1 -1
  136. package/dist/src/services/remote_review.js +142 -35
  137. package/dist/src/services/remote_review_abort.d.ts +5 -1
  138. package/dist/src/services/remote_review_abort.js +9 -1
  139. package/dist/src/services/review.d.ts +1 -1
  140. package/dist/src/services/review.js +19 -5
  141. package/dist/src/services/review_policy.d.ts +116 -0
  142. package/dist/src/services/review_policy.js +489 -0
  143. package/dist/src/services/setup.d.ts +4 -3
  144. package/dist/src/services/setup.js +18 -16
  145. package/dist/src/services/setup_checklist.js +2 -2
  146. package/dist/src/services/webhook.d.ts +23 -0
  147. package/dist/src/services/webhook.js +141 -34
  148. package/dist/src/store/app_db.d.ts +5 -0
  149. package/dist/src/store/app_db.js +69 -13
  150. package/dist/src/store/backup.d.ts +31 -0
  151. package/dist/src/store/backup.js +158 -0
  152. package/dist/src/store/migrations.d.ts +1 -1
  153. package/dist/src/store/migrations.js +21 -1
  154. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  155. package/dist/src/store/remote_review_inputs.js +3 -3
  156. package/dist/src/store/remote_tokens.d.ts +1 -1
  157. package/dist/src/store/remote_tokens.js +1 -1
  158. package/dist/src/store/repos.d.ts +2 -2
  159. package/dist/src/store/repos.js +1 -1
  160. package/dist/src/store/reviews.d.ts +21 -13
  161. package/dist/src/store/reviews.js +60 -15
  162. package/dist/src/store/rows.d.ts +10 -0
  163. package/dist/src/tools/codegraph.d.ts +4 -4
  164. package/dist/src/tools/codegraph.js +5 -5
  165. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  166. package/dist/src/tools/codegraph_exec.js +1 -1
  167. package/dist/src/types.d.ts +12 -6
  168. package/dist/src/util/cost.d.ts +52 -0
  169. package/dist/src/util/cost.js +70 -0
  170. package/dist/src/util/log.d.ts +1 -1
  171. package/dist/src/util/log.js +2 -2
  172. package/dist/src/util/redact.js +2 -0
  173. package/dist/src/util/run_summary.d.ts +7 -2
  174. package/dist/src/util/run_summary.js +28 -2
  175. package/dist/src/util/time.d.ts +1 -1
  176. package/dist/src/util/time.js +1 -1
  177. package/package.json +1 -1
@@ -2,10 +2,10 @@ import type { RepoRow } from "./rows.ts";
2
2
  export declare function activateRepo(fullName: string, installationId: number | undefined): void;
3
3
  export declare function deactivateRepo(fullName: string): void;
4
4
  export declare function deactivateReposForInstallation(installationId: number): void;
5
- export declare function updateRepoSettings(fullName: string, patch: Partial<Pick<RepoRow, "auto_review" | "review_scope" | "skip_drafts" | "skip_bots" | "use_codegraph">>): void;
5
+ export declare function updateRepoSettings(fullName: string, patch: Partial<Pick<RepoRow, "auto_review" | "review_scope" | "skip_drafts" | "skip_bots" | "use_codegraph" | "review_policy_json">>): void;
6
6
  export declare function markKnowledgeBuilt(fullName: string, baseSha: string): void;
7
7
  export declare function setInstallationId(fullName: string, installationId: number): void;
8
8
  export declare function getRepo(fullName: string): RepoRow | undefined;
9
- /** Case-insensitive match on `repos.full_name` (plan §14.4). */
9
+ /** Case-insensitive match on `repos.full_name`. */
10
10
  export declare function findRepoByFullName(fullName: string): RepoRow | undefined;
11
11
  export declare function listActiveRepos(): RepoRow[];
@@ -46,7 +46,7 @@ export function getRepo(fullName) {
46
46
  .prepare(`SELECT * FROM repos WHERE full_name = ?`)
47
47
  .get(fullName);
48
48
  }
49
- /** Case-insensitive match on `repos.full_name` (plan §14.4). */
49
+ /** Case-insensitive match on `repos.full_name`. */
50
50
  export function findRepoByFullName(fullName) {
51
51
  return getAppDb()
52
52
  .prepare(`SELECT * FROM repos WHERE lower(full_name) = lower(?)`)
@@ -26,18 +26,29 @@ export declare function insertReview(row: {
26
26
  }): void;
27
27
  /** Written immediately before and after the `POST .../reviews` call, with
28
28
  * nothing else run between either write and the call. */
29
- export declare function setReviewStatus(id: string, status: ReviewRow["status"], patch?: Partial<Pick<ReviewRow, "posted_review_id" | "findings_count" | "tokens_in" | "tokens_out" | "cost" | "duration_ms" | "check_run_id" | "posted_fallback" | "open_count" | "closed_count" | "subject_id" | "guide_built_at">>): void;
29
+ export declare function setReviewStatus(id: string, status: ReviewRow["status"], patch?: Partial<Pick<ReviewRow, "posted_review_id" | "findings_count" | "tokens_in" | "tokens_out" | "cost" | "cost_status" | "cost_note" | "billed_to" | "duration_ms" | "check_run_id" | "posted_fallback" | "open_count" | "closed_count" | "subject_id" | "guide_built_at">>): void;
30
30
  export declare function setReviewScope(id: string, scope: string, baseSha: string): void;
31
31
  export declare function getReview(id: string): ReviewRow | undefined;
32
32
  export declare function getReviewByJobId(jobId: string): ReviewRow | undefined;
33
33
  /** Newest round first. The PR detail page reads this directly. */
34
34
  export declare function listReviewsForPr(repo: string, prNumber: number): ReviewRow[];
35
35
  export declare function latestPostedReview(repo: string, prNumber: number): ReviewRow | undefined;
36
- export type ReviewStats = {
36
+ /** `cost` adds up only the costs that are known and billed to the server.
37
+ * A review with an unknown cost is counted in `unknownCount` instead of
38
+ * being added as zero. `knownCount` tells "no cost yet" apart from "every
39
+ * review cost exactly $0". BYOK is kept apart, never inside `cost`. */
40
+ export type CostTotals = {
41
+ cost: number;
42
+ knownCount: number;
43
+ unknownCount: number;
44
+ byokUsd: number;
45
+ byokKnownCount: number;
46
+ byokUnknownCount: number;
47
+ };
48
+ export type ReviewStats = CostTotals & {
37
49
  reviews: number;
38
50
  pullRequests: number;
39
51
  findings: number;
40
- cost: number;
41
52
  tokensIn: number;
42
53
  tokensOut: number;
43
54
  failed: number;
@@ -49,22 +60,21 @@ export declare function reviewStats(sinceIso: string, repo?: string, untilIso?:
49
60
  export declare function reviewStatsByRepo(sinceIso: string): (ReviewStats & {
50
61
  repo: string;
51
62
  })[];
52
- export declare function reviewStatsByDay(sinceIso: string): {
63
+ export type DayStats = CostTotals & {
53
64
  day: string;
54
65
  reviews: number;
55
66
  findings: number;
56
- cost: number;
57
- }[];
67
+ };
68
+ export declare function reviewStatsByDay(sinceIso: string): DayStats[];
58
69
  export declare function reviewStatsByModel(sinceIso: string): (ReviewStats & {
59
70
  model: string;
60
71
  })[];
61
72
  export declare function listRecentReviews(limit: number, offset?: number): ReviewRow[];
62
73
  export declare function countReviews(): number;
63
- export type PullSummary = {
74
+ export type PullSummary = CostTotals & {
64
75
  pr_number: number;
65
76
  last_reviewed: string;
66
77
  findings: number;
67
- cost: number;
68
78
  review_count: number;
69
79
  };
70
80
  export declare function listPullSummaries(repo: string, limit: number, offset: number): {
@@ -72,15 +82,13 @@ export declare function listPullSummaries(repo: string, limit: number, offset: n
72
82
  total: number;
73
83
  };
74
84
  export declare function listRemoteReviewsForRepo(repo: string, limit: number, offset?: number, sinceIso?: string): ReviewRow[];
75
- export declare function remoteTokenUsageSince(tokenId: string, sinceIso: string): {
85
+ export declare function remoteTokenUsageSince(tokenId: string, sinceIso: string): CostTotals & {
76
86
  reviews: number;
77
- cost: number;
78
87
  };
79
- export declare function reviewStatsByRemoteToken(sinceIso: string): {
88
+ export declare function reviewStatsByRemoteToken(sinceIso: string): (CostTotals & {
80
89
  tokenId: string;
81
90
  tokenName: string;
82
91
  reviews: number;
83
- cost: number;
84
92
  findings: number;
85
- }[];
93
+ })[];
86
94
  export declare function listLatestReviewsForRepo(repo: string, limit: number): ReviewRow[];
@@ -4,16 +4,20 @@ export function insertRemoteReview(row) {
4
4
  getAppDb()
5
5
  .prepare(`INSERT INTO reviews
6
6
  (id, kind, subject_id, repo, pr_number, branch, token_id, token_name,
7
- job_id, head_sha, base_sha, scope, model, status, created_at, round)
8
- VALUES (?, 'remote', ?, ?, NULL, ?, ?, ?, ?, NULL, NULL, ?, ?, 'queued', ?, 1)`)
7
+ job_id, head_sha, base_sha, scope, model, status, created_at, round,
8
+ cost_status, cost_note, billed_to)
9
+ VALUES (?, 'remote', ?, ?, NULL, ?, ?, ?, ?, NULL, NULL, ?, ?, 'queued', ?, 1,
10
+ 'unknown', 'not_recorded', 'server')`)
9
11
  .run(row.id, row.subjectId, row.repo, row.branch, row.tokenId, row.tokenName, row.jobId, row.scope, row.model, nowIso());
10
12
  }
11
13
  export function insertReview(row) {
12
14
  getAppDb()
13
15
  .prepare(`INSERT INTO reviews
14
16
  (id, kind, subject_id, repo, pr_number, job_id, head_sha, base_sha, scope, model,
15
- status, created_at, round, "trigger", guide_built_at)
16
- VALUES (?, 'pr', ?, ?, ?, ?, ?, ?, ?, ?, 'drafting', ?, ?, ?, ?)`)
17
+ status, created_at, round, "trigger", guide_built_at,
18
+ cost_status, cost_note, billed_to)
19
+ VALUES (?, 'pr', ?, ?, ?, ?, ?, ?, ?, ?, 'drafting', ?, ?, ?, ?,
20
+ 'unknown', 'not_recorded', 'server')`)
17
21
  .run(row.id, row.subjectId ?? null, row.repo, row.prNumber, row.jobId, row.headSha, row.baseSha, row.scope, row.model, nowIso(), row.round ?? 1, row.trigger ?? null, row.guideBuiltAt ?? null);
18
22
  }
19
23
  /** Written immediately before and after the `POST .../reviews` call, with
@@ -28,13 +32,22 @@ export function setReviewStatus(id, status, patch = {}) {
28
32
  tokens_in = COALESCE(?, tokens_in),
29
33
  tokens_out = COALESCE(?, tokens_out),
30
34
  cost = COALESCE(?, cost),
35
+ cost_status = COALESCE(?, cost_status),
36
+ cost_note = CASE WHEN ? THEN ? ELSE cost_note END,
37
+ billed_to = COALESCE(?, billed_to),
31
38
  duration_ms = COALESCE(?, duration_ms),
32
39
  check_run_id = COALESCE(?, check_run_id),
33
40
  posted_fallback = COALESCE(?, posted_fallback),
34
41
  subject_id = COALESCE(?, subject_id),
35
42
  guide_built_at = COALESCE(?, guide_built_at)
36
43
  WHERE id = ?`)
37
- .run(status, patch.posted_review_id ?? null, patch.findings_count ?? null, patch.open_count ?? null, patch.closed_count ?? null, patch.tokens_in ?? null, patch.tokens_out ?? null, patch.cost ?? null, patch.duration_ms ?? null, patch.check_run_id ?? null, patch.posted_fallback ?? null, patch.subject_id ?? null, patch.guide_built_at ?? null, id);
44
+ .run(status, patch.posted_review_id ?? null, patch.findings_count ?? null, patch.open_count ?? null, patch.closed_count ?? null, patch.tokens_in ?? null, patch.tokens_out ?? null, patch.cost ?? null, patch.cost_status ?? null,
45
+ // `cost_note` travels with `cost_status`: when the status changes to
46
+ // known, the note (e.g. a stale "not recorded yet") must clear to
47
+ // null, which `COALESCE(?, cost_note)` cannot do since a provided
48
+ // `null` and an omitted field bind the same way. The flag makes the
49
+ // two cases distinguishable.
50
+ patch.cost_status !== undefined, patch.cost_note ?? null, patch.billed_to ?? null, patch.duration_ms ?? null, patch.check_run_id ?? null, patch.posted_fallback ?? null, patch.subject_id ?? null, patch.guide_built_at ?? null, id);
38
51
  }
39
52
  export function setReviewScope(id, scope, baseSha) {
40
53
  getAppDb()
@@ -64,13 +77,33 @@ export function latestPostedReview(repo, prNumber) {
64
77
  ORDER BY round DESC, created_at DESC LIMIT 1`)
65
78
  .get(repo, prNumber);
66
79
  }
80
+ const COST_STATUS = `COALESCE(cost_status, CASE WHEN cost IS NULL THEN 'unknown' ELSE 'known' END)`;
81
+ const BILLED = `COALESCE(billed_to, 'server')`;
82
+ const bucket = (status, billed, value) => `COALESCE(SUM(CASE WHEN ${COST_STATUS} = '${status}' AND ${BILLED} = '${billed}' THEN ${value} END), 0)`;
83
+ /** The six figures of `CostTotals`, ready to sit in any SELECT list. */
84
+ const COST_COLUMNS = `${bucket("known", "server", "cost")} AS cost,
85
+ ${bucket("known", "server", "1")} AS knownCount,
86
+ ${bucket("unknown", "server", "1")} AS unknownCount,
87
+ ${bucket("known", "byok", "cost")} AS byokUsd,
88
+ ${bucket("known", "byok", "1")} AS byokKnownCount,
89
+ ${bucket("unknown", "byok", "1")} AS byokUnknownCount`;
90
+ function asCostTotals(row) {
91
+ return {
92
+ cost: Number(row?.cost ?? 0),
93
+ knownCount: Number(row?.knownCount ?? 0),
94
+ unknownCount: Number(row?.unknownCount ?? 0),
95
+ byokUsd: Number(row?.byokUsd ?? 0),
96
+ byokKnownCount: Number(row?.byokKnownCount ?? 0),
97
+ byokUnknownCount: Number(row?.byokUnknownCount ?? 0),
98
+ };
99
+ }
67
100
  /** Every aggregate the dashboard reads comes from this one column list, so a
68
101
  * new figure lands on the totals, the per repo table and the trend window at
69
102
  * the same time. */
70
103
  const STATS_COLUMNS = `COUNT(*) AS reviews,
71
104
  COUNT(DISTINCT pr_number) AS pullRequests,
72
105
  COALESCE(SUM(findings_count), 0) AS findings,
73
- COALESCE(SUM(cost), 0) AS cost,
106
+ ${COST_COLUMNS},
74
107
  COALESCE(SUM(tokens_in), 0) AS tokensIn,
75
108
  COALESCE(SUM(tokens_out), 0) AS tokensOut,
76
109
  COALESCE(SUM(status = 'failed'), 0) AS failed,
@@ -80,7 +113,7 @@ function asStats(row) {
80
113
  reviews: Number(row?.reviews ?? 0),
81
114
  pullRequests: Number(row?.pullRequests ?? 0),
82
115
  findings: Number(row?.findings ?? 0),
83
- cost: Number(row?.cost ?? 0),
116
+ ...asCostTotals(row),
84
117
  tokensIn: Number(row?.tokensIn ?? 0),
85
118
  tokensOut: Number(row?.tokensOut ?? 0),
86
119
  failed: Number(row?.failed ?? 0),
@@ -118,11 +151,20 @@ export function reviewStatsByDay(sinceIso) {
118
151
  .prepare(`SELECT substr(created_at, 1, 10) AS day,
119
152
  COUNT(*) AS reviews,
120
153
  COALESCE(SUM(findings_count), 0) AS findings,
121
- COALESCE(SUM(cost), 0) AS cost
154
+ ${COST_COLUMNS}
122
155
  FROM reviews WHERE created_at >= ?
123
156
  GROUP BY day
124
157
  ORDER BY day`)
125
- .all(sinceIso);
158
+ .all(sinceIso)
159
+ .map((row) => {
160
+ const value = row;
161
+ return {
162
+ day: value.day,
163
+ reviews: Number(value.reviews),
164
+ findings: Number(value.findings),
165
+ ...asCostTotals(value),
166
+ };
167
+ });
126
168
  }
127
169
  export function reviewStatsByModel(sinceIso) {
128
170
  return getAppDb()
@@ -150,14 +192,17 @@ export function listPullSummaries(repo, limit, offset) {
150
192
  .prepare(`SELECT pr_number,
151
193
  MAX(created_at) AS last_reviewed,
152
194
  COALESCE(SUM(findings_count), 0) AS findings,
153
- COALESCE(SUM(cost), 0) AS cost,
195
+ ${COST_COLUMNS},
154
196
  COUNT(*) AS review_count
155
197
  FROM reviews WHERE repo = ?
156
198
  GROUP BY pr_number
157
199
  ORDER BY last_reviewed DESC
158
200
  LIMIT ? OFFSET ?`)
159
201
  .all(repo, limit, offset);
160
- return { items, total };
202
+ return {
203
+ items: items.map((item) => ({ ...item, ...asCostTotals(item) })),
204
+ total,
205
+ };
161
206
  }
162
207
  export function listRemoteReviewsForRepo(repo, limit, offset = 0, sinceIso) {
163
208
  if (sinceIso) {
@@ -177,20 +222,20 @@ export function listRemoteReviewsForRepo(repo, limit, offset = 0, sinceIso) {
177
222
  }
178
223
  export function remoteTokenUsageSince(tokenId, sinceIso) {
179
224
  const row = getAppDb()
180
- .prepare(`SELECT COUNT(*) AS reviews, COALESCE(SUM(cost), 0) AS cost
225
+ .prepare(`SELECT COUNT(*) AS reviews, ${COST_COLUMNS}
181
226
  FROM reviews
182
227
  WHERE token_id = ? AND kind = 'remote' AND created_at >= ?`)
183
228
  .get(tokenId, sinceIso);
184
229
  return {
185
230
  reviews: Number(row?.reviews ?? 0),
186
- cost: Number(row?.cost ?? 0),
231
+ ...asCostTotals(row),
187
232
  };
188
233
  }
189
234
  export function reviewStatsByRemoteToken(sinceIso) {
190
235
  return getAppDb()
191
236
  .prepare(`SELECT token_id, token_name,
192
237
  COUNT(*) AS reviews,
193
- COALESCE(SUM(cost), 0) AS cost,
238
+ ${COST_COLUMNS},
194
239
  COALESCE(SUM(findings_count), 0) AS findings
195
240
  FROM reviews
196
241
  WHERE kind = 'remote' AND created_at >= ? AND token_id IS NOT NULL
@@ -201,7 +246,7 @@ export function reviewStatsByRemoteToken(sinceIso) {
201
246
  tokenId: row.token_id,
202
247
  tokenName: row.token_name,
203
248
  reviews: Number(row.reviews),
204
- cost: Number(row.cost),
249
+ ...asCostTotals(row),
205
250
  findings: Number(row.findings),
206
251
  }));
207
252
  }
@@ -24,6 +24,9 @@ export type RepoRow = {
24
24
  settings: string;
25
25
  created_at: string;
26
26
  use_codegraph: number;
27
+ /** JSON: `"legacy"`, a template name, or a policy object. `null` takes the
28
+ * server default. */
29
+ review_policy_json: string | null;
27
30
  };
28
31
  export type JobRow = {
29
32
  id: string;
@@ -69,6 +72,10 @@ export type ReviewRow = {
69
72
  tokens_in: number;
70
73
  tokens_out: number;
71
74
  cost: number | null;
75
+ /** `null` on a row written by 0.5.0 after this column was added. */
76
+ cost_status: "known" | "unknown" | null;
77
+ cost_note: string | null;
78
+ billed_to: "server" | "byok" | null;
72
79
  duration_ms: number | null;
73
80
  posted_review_id: string | null;
74
81
  status: string;
@@ -176,4 +183,7 @@ export type RemoteReviewInputRow = {
176
183
  request_id: string;
177
184
  token_id: string;
178
185
  created_at: string;
186
+ /** `'byok'` when the client's own key paid for the review, `'server'`
187
+ * otherwise. A null (pre-0.5.1 row) is treated as `'server'`. */
188
+ billed_to?: string | null;
179
189
  };
@@ -33,7 +33,7 @@ export declare const CODEGRAPH_APPROX_SIZE = "~250 MB";
33
33
  /** A prompt may only be shown when there is a human on the other end of stdin
34
34
  * *and* stdout, and no CI marker. A closed stdin (`</dev/null`), a piped
35
35
  * output, or `CI=1` must all take the non-interactive path: asking then would
36
- * hang on a question nobody can answer (F01). `stdout` is checked because a
36
+ * hang on a question nobody can answer. `stdout` is checked because a
37
37
  * prompt printed into a pipe is invisible.
38
38
  *
39
39
  * `streams` is injectable so the tests can stand in for a TTY without a real
@@ -46,12 +46,12 @@ export declare function canPrompt(streams?: {
46
46
  isTTY?: boolean;
47
47
  };
48
48
  }, env?: Record<string, string | undefined>): boolean;
49
- /** The one line a non-interactive run prints instead of asking (F01). It names
49
+ /** The one line a non-interactive run prints instead of asking. It names
50
50
  * the reason and both ways forward, so a CI log explains itself. */
51
51
  export declare function skippedNotice(version?: string): string;
52
52
  /** What the install prompt says: the package and version, where it comes from,
53
53
  * what it is for, the approximate size, and the exact directory. A bare
54
- * "Install ...? [y/N]" left the user guessing (F01). */
54
+ * "Install ...? [y/N]" left the user guessing. */
55
55
  export declare function installPrompt(version: string, root: string): string;
56
56
  export declare function runCommand(command: string, args: string[]): Promise<CommandResult>;
57
57
  /** One version per directory, so several can sit side by side and an upgrade
@@ -83,7 +83,7 @@ export declare function installHint(version?: string, root?: string): string;
83
83
  * to the binary, or exits: co-maintainer indexes the repository with it, so
84
84
  * declining is a decision not to run this command, not a degraded mode. */
85
85
  export declare function ensureCodegraph(options?: EnsureOptions): Promise<string>;
86
- /** Review paths must not call `Deno.exit` (plan §8.7, S13). */
86
+ /** Review paths must not call `Deno.exit`. */
87
87
  export declare function ensureCodegraphForReview(options?: EnsureOptions): Promise<{
88
88
  path: string;
89
89
  } | {
@@ -17,7 +17,7 @@ export const CODEGRAPH_APPROX_SIZE = "~250 MB";
17
17
  /** A prompt may only be shown when there is a human on the other end of stdin
18
18
  * *and* stdout, and no CI marker. A closed stdin (`</dev/null`), a piped
19
19
  * output, or `CI=1` must all take the non-interactive path: asking then would
20
- * hang on a question nobody can answer (F01). `stdout` is checked because a
20
+ * hang on a question nobody can answer. `stdout` is checked because a
21
21
  * prompt printed into a pipe is invisible.
22
22
  *
23
23
  * `streams` is injectable so the tests can stand in for a TTY without a real
@@ -28,7 +28,7 @@ export function canPrompt(streams = {
28
28
  }, env = process.env) {
29
29
  return (streams.stdin?.isTTY === true && streams.stdout?.isTTY === true && !env.CI);
30
30
  }
31
- /** The one line a non-interactive run prints instead of asking (F01). It names
31
+ /** The one line a non-interactive run prints instead of asking. It names
32
32
  * the reason and both ways forward, so a CI log explains itself. */
33
33
  export function skippedNotice(version = CODEGRAPH_VERSION) {
34
34
  return (`codegraph ${version} is not installed, reviewing without it. ` +
@@ -36,7 +36,7 @@ export function skippedNotice(version = CODEGRAPH_VERSION) {
36
36
  }
37
37
  /** What the install prompt says: the package and version, where it comes from,
38
38
  * what it is for, the approximate size, and the exact directory. A bare
39
- * "Install ...? [y/N]" left the user guessing (F01). */
39
+ * "Install ...? [y/N]" left the user guessing. */
40
40
  export function installPrompt(version, root) {
41
41
  return (`co-maintainer uses codegraph ${version} (${CODEGRAPH_PACKAGE} from npm, ${CODEGRAPH_APPROX_SIZE}) ` +
42
42
  "to follow calls between files while reviewing.\n" +
@@ -165,7 +165,7 @@ export async function ensureCodegraph(options = {}) {
165
165
  log(`[codegraph] ready at ${after.path}`);
166
166
  return after.path;
167
167
  }
168
- /** Review paths must not call `Deno.exit` (plan §8.7, S13). */
168
+ /** Review paths must not call `Deno.exit`. */
169
169
  export async function ensureCodegraphForReview(options = {}) {
170
170
  const root = options.root ?? toolsDir();
171
171
  const run = options.run ?? runCommand;
@@ -179,7 +179,7 @@ export async function ensureCodegraphForReview(options = {}) {
179
179
  }
180
180
  if (!options.allowInstall) {
181
181
  // Non-interactive: do not ask, review without it. The caller logs the
182
- // reason, so the run continues instead of hanging or exiting (F01).
182
+ // reason, so the run continues instead of hanging or exiting.
183
183
  if (!interactive) {
184
184
  return { reason: skippedNotice() };
185
185
  }
@@ -7,7 +7,7 @@ export type ExecOptions = {
7
7
  env?: Record<string, string>;
8
8
  };
9
9
  export type CodegraphRunner = (binary: string, args: string[], worktree: string) => Promise<CommandResult>;
10
- /** Run the codegraph CLI in a repo root (plan §13.4).
10
+ /** Run the codegraph CLI in a repo root.
11
11
  *
12
12
  * On Windows the CLI is a `.cmd`/`.ps1` shim, and `node:child_process.spawn`
13
13
  * cannot execute those without a shell — it fails with `EINVAL`. `Deno.Command`
@@ -3,7 +3,7 @@ export const LOCAL_CODEGRAPH_DIR = ".co-maintainer-codegraph";
3
3
  export const SERVER_CODEGRAPH_DIR = ".codegraph";
4
4
  const TOOL_TIMEOUT_MS = 60_000;
5
5
  const INDEX_COMMANDS = new Set(["init", "sync", "index", "unlock"]);
6
- /** Run the codegraph CLI in a repo root (plan §13.4).
6
+ /** Run the codegraph CLI in a repo root.
7
7
  *
8
8
  * On Windows the CLI is a `.cmd`/`.ps1` shim, and `node:child_process.spawn`
9
9
  * cannot execute those without a shell — it fails with `EINVAL`. `Deno.Command`
@@ -1,6 +1,6 @@
1
1
  /** Cross-layer contracts only. A type owned by one layer (Fact, Source,
2
2
  * PullRequest, State) lives next to the code that owns it instead, in
3
- * `knowledge/types.ts` — see PLAN.md Section 3. */
3
+ * `knowledge/types.ts`. */
4
4
  export type Json = Record<string, unknown>;
5
5
  export type Options = {
6
6
  command: "probe" | "init" | "remake" | "review";
@@ -24,7 +24,7 @@ export type Options = {
24
24
  envPath?: string;
25
25
  auth: "gh" | "pat";
26
26
  githubPat?: string;
27
- ai: "none" | "openrouter" | "hetzner";
27
+ ai: "none" | "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
28
28
  aiToken?: string;
29
29
  lowModel?: string;
30
30
  highModel?: string;
@@ -44,9 +44,9 @@ export type Options = {
44
44
  maxPullRequestChangeLines?: number;
45
45
  maxComments?: number;
46
46
  /** `probe --run`: after printing the plan, run the recommended init in the
47
- * same process (CORE-24). */
47
+ * same process. */
48
48
  run?: boolean;
49
- /** How a review decides a blocking finding (CORE-41). `model` keeps the
49
+ /** How a review decides a blocking finding. `model` keeps the
50
50
  * 0.4.13 behavior, `severity` uses a fixed P0/P1 threshold. */
51
51
  reviewBlocking?: "model" | "severity";
52
52
  };
@@ -85,7 +85,7 @@ export type AiRequest = {
85
85
  job: string;
86
86
  /** A JSON schema the response must satisfy, sent as OpenRouter's
87
87
  * `response_format`. Providers without structured output ignore it, so the
88
- * model is also asked for a fenced JSON block in the prompt (CORE-40). */
88
+ * model is also asked for a fenced JSON block in the prompt. */
89
89
  responseFormat?: Json;
90
90
  reasoningEffort?: "max" | "xhigh" | "high" | "medium" | "low" | "none";
91
91
  };
@@ -95,8 +95,14 @@ export type AiResponse = {
95
95
  tokensIn: number;
96
96
  tokensOut: number;
97
97
  cost?: number;
98
+ /** Set on a response merged from several calls, so the caller can tell a
99
+ * partial cost from a missing one. */
100
+ costCalls?: {
101
+ known: number;
102
+ unknown: number;
103
+ };
98
104
  model: string;
99
- provider: "openrouter" | "hetzner";
105
+ provider: "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
100
106
  };
101
107
  export type AiProvider = {
102
108
  supportsTools?: boolean;
@@ -0,0 +1,52 @@
1
+ /** What an AI run cost, and how sure we are. A missing cost is never `$0`:
2
+ * `known` includes a real zero (a free model), `unknown` carries a reason.
3
+ * The reason texts live here so the CLI, the JSON and the dashboard say the
4
+ * same thing. */
5
+ export type CostReason = "provider_did_not_report" | "partial" | "recorded_before_0_5_1" | "not_recorded";
6
+ export type CostStatus = "known" | "unknown";
7
+ export type BilledTo = "server" | "byok";
8
+ export type CostOutcome = {
9
+ status: "known";
10
+ usd: number;
11
+ } | {
12
+ status: "unknown";
13
+ reason: CostReason;
14
+ };
15
+ /** How many AI calls reported a cost and how many did not. */
16
+ export type CostCalls = {
17
+ known: number;
18
+ unknown: number;
19
+ };
20
+ export type CostTally = {
21
+ cost: number;
22
+ knownCalls: number;
23
+ unknownCalls: number;
24
+ };
25
+ export declare function emptyTally(): CostTally;
26
+ /** A response merged from several calls carries its own `costCalls`. A plain
27
+ * response is one call. */
28
+ export declare function addResponseCost(tally: CostTally, response: {
29
+ cost?: number;
30
+ costCalls?: CostCalls;
31
+ }): void;
32
+ /** Every call reported: known. None did: the provider does not report. Some
33
+ * did: partial, and the known part is dropped so a total never looks
34
+ * complete. No call at all: the run ended before a cost was recorded. */
35
+ export declare function settle(tally: CostTally): CostOutcome;
36
+ /** `provider` names the service in the first sentence, as in "OpenAI did not
37
+ * report the cost for this review." */
38
+ export declare function costReasonText(reason: CostReason, provider?: string): string;
39
+ /** The cost part of a review JSON `usage` object. `costUsd` stays `null`
40
+ * when the cost is unknown, as it always did. */
41
+ export declare function costUsage(tally: CostTally): {
42
+ costUsd: number | null;
43
+ costStatus: CostStatus;
44
+ costNote: CostReason | null;
45
+ billedTo: BilledTo;
46
+ };
47
+ /** The columns `setReviewStatus` writes for a finished review. */
48
+ export declare function costColumns(outcome: CostOutcome): {
49
+ cost: number | undefined;
50
+ cost_status: CostStatus;
51
+ cost_note: CostReason | null;
52
+ };
@@ -0,0 +1,70 @@
1
+ /** What an AI run cost, and how sure we are. A missing cost is never `$0`:
2
+ * `known` includes a real zero (a free model), `unknown` carries a reason.
3
+ * The reason texts live here so the CLI, the JSON and the dashboard say the
4
+ * same thing. */
5
+ export function emptyTally() {
6
+ return { cost: 0, knownCalls: 0, unknownCalls: 0 };
7
+ }
8
+ /** A response merged from several calls carries its own `costCalls`. A plain
9
+ * response is one call. */
10
+ export function addResponseCost(tally, response) {
11
+ const calls = response.costCalls ??
12
+ (response.cost === undefined
13
+ ? { known: 0, unknown: 1 }
14
+ : { known: 1, unknown: 0 });
15
+ tally.knownCalls += calls.known;
16
+ tally.unknownCalls += calls.unknown;
17
+ tally.cost += response.cost ?? 0;
18
+ }
19
+ /** Every call reported: known. None did: the provider does not report. Some
20
+ * did: partial, and the known part is dropped so a total never looks
21
+ * complete. No call at all: the run ended before a cost was recorded. */
22
+ export function settle(tally) {
23
+ if (tally.knownCalls === 0 && tally.unknownCalls === 0) {
24
+ return { status: "unknown", reason: "not_recorded" };
25
+ }
26
+ if (tally.unknownCalls === 0)
27
+ return { status: "known", usd: tally.cost };
28
+ return {
29
+ status: "unknown",
30
+ reason: tally.knownCalls === 0 ? "provider_did_not_report" : "partial",
31
+ };
32
+ }
33
+ const REASON_TEXT = {
34
+ provider_did_not_report: "The provider did not report the cost for this review.",
35
+ partial: "Some AI calls in this review did not report a cost.",
36
+ recorded_before_0_5_1: "Recorded before 0.5.1, the cost was not saved.",
37
+ not_recorded: "The review ended before its cost was recorded.",
38
+ };
39
+ /** `provider` names the service in the first sentence, as in "OpenAI did not
40
+ * report the cost for this review." */
41
+ export function costReasonText(reason, provider) {
42
+ if (reason === "provider_did_not_report" && provider) {
43
+ return `${provider} did not report the cost for this review.`;
44
+ }
45
+ return REASON_TEXT[reason];
46
+ }
47
+ /** The cost part of a review JSON `usage` object. `costUsd` stays `null`
48
+ * when the cost is unknown, as it always did. */
49
+ export function costUsage(tally) {
50
+ const outcome = settle(tally);
51
+ return outcome.status === "known"
52
+ ? {
53
+ costUsd: outcome.usd,
54
+ costStatus: "known",
55
+ costNote: null,
56
+ billedTo: "server",
57
+ }
58
+ : {
59
+ costUsd: null,
60
+ costStatus: "unknown",
61
+ costNote: outcome.reason,
62
+ billedTo: "server",
63
+ };
64
+ }
65
+ /** The columns `setReviewStatus` writes for a finished review. */
66
+ export function costColumns(outcome) {
67
+ return outcome.status === "known"
68
+ ? { cost: outcome.usd, cost_status: "known", cost_note: null }
69
+ : { cost: undefined, cost_status: "unknown", cost_note: outcome.reason };
70
+ }
@@ -2,7 +2,7 @@
2
2
  * with `withLogSink`), it also reaches `job_logs` and any live SSE
3
3
  * subscriber, without threading a logger through every function signature. */
4
4
  type Sink = (phase: string, message: string) => void;
5
- /** Plan §8.4 — progress and logs on stderr during CLI review. */
5
+ /** Progress and logs go to stderr during a CLI review. */
6
6
  export declare function withCliLogsToStderr<T>(fn: () => Promise<T>): Promise<T>;
7
7
  export declare function withLogSink<T>(sink: Sink, fn: () => Promise<T>): Promise<T>;
8
8
  export declare function log(phase: string, message: string): void;
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  const sinkStorage = new AsyncLocalStorage();
3
3
  let cliLogsToStderr = false;
4
- /** Plan §8.4 — progress and logs on stderr during CLI review. */
4
+ /** Progress and logs go to stderr during a CLI review. */
5
5
  export function withCliLogsToStderr(fn) {
6
6
  const prev = cliLogsToStderr;
7
7
  cliLogsToStderr = true;
@@ -32,7 +32,7 @@ export function startHeartbeat(phase) {
32
32
  const timer = setInterval(() => {
33
33
  log("progress", `still running ${label()} · ${Math.round((Date.now() - started) / 1000)}s elapsed`);
34
34
  }, 15_000);
35
- // Never keep the process alive on its own (CORE-11). A long operation holds
35
+ // Never keep the process alive on its own. A long operation holds
36
36
  // the loop open through its own pending async work (a fetch, a child), so the
37
37
  // heartbeat still fires; but once that work is gone the timer must not be the
38
38
  // reason the process cannot drain and apply its exit code. Every call site
@@ -4,6 +4,8 @@ const PATTERNS = [
4
4
  /gh[pousr]_[A-Za-z0-9]{20,}/g, // ghp_, gho_, ghu_, ghs_, ghr_ (classic GitHub PATs and tokens)
5
5
  /github_pat_[A-Za-z0-9_]{20,}/g, // fine-grained GitHub PATs
6
6
  /sk-or-[A-Za-z0-9-]{10,}/g, // OpenRouter keys
7
+ /sk-ant-[A-Za-z0-9_-]{10,}/g, // Anthropic keys
8
+ /sk-[A-Za-z0-9_-]{20,}/g, // OpenAI keys, old `sk-` and new `sk-proj-` alike
7
9
  /cmr_[A-Za-z0-9_-]{20,}/g, // remote review bearer tokens
8
10
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
9
11
  ];
@@ -1,6 +1,6 @@
1
- /** The one-line cost and time summary every AI command ends with (CORE-26).
1
+ /** The one-line cost and time summary every AI command ends with.
2
2
  *
3
- * F18: the JSON already carried `usage.costUsd` and a duration, but the human
3
+ * The JSON already carried `usage.costUsd` and a duration, but the human
4
4
  * output had neither and `init` printed no total at all, so the only place to
5
5
  * see what a run cost was the OpenRouter dashboard. This renders the same
6
6
  * numbers as a single stderr line:
@@ -12,6 +12,7 @@
12
12
  * report a cost the dollar field is `cost unknown` rather than `$0.0000`.
13
13
  */
14
14
  import type { AiMetrics } from "../services/setup.ts";
15
+ import { type CostTally } from "./cost.ts";
15
16
  export type RunSummary = {
16
17
  /** Wall-clock milliseconds, or null when only the AI time is known. */
17
18
  durationMs: number | null;
@@ -19,6 +20,8 @@ export type RunSummary = {
19
20
  tokensOut: number;
20
21
  /** `null` when any call left the cost unknown, matching `usage.costUsd`. */
21
22
  costUsd: number | null;
23
+ /** Why the cost is unknown, as a sentence. */
24
+ costNote?: string;
22
25
  };
23
26
  /** `21.0s`, `1.3s`, `2m 03s`; one decimal below a minute, whole seconds above. */
24
27
  export declare function formatDuration(ms: number): string;
@@ -31,3 +34,5 @@ export declare function formatRunSummary(summary: RunSummary): string;
31
34
  export declare function printRunSummary(summary: RunSummary): void;
32
35
  /** The metrics half of a summary, so call sites do not repeat the spread. */
33
36
  export declare function summaryFromMetrics(metrics: AiMetrics, durationMs: number | null): RunSummary;
37
+ /** `0.0016` for a known cost, `unknown` otherwise. For the timing log line. */
38
+ export declare function costLabel(metrics: CostTally): string;