co-maintainer 0.4.13 → 0.5.0-beta.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 (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +57 -38
  3. package/dist/main.js +2 -3
  4. package/dist/package.json +21 -15
  5. package/dist/src/ai/batch.d.ts +24 -1
  6. package/dist/src/ai/batch.js +75 -23
  7. package/dist/src/ai/estimate.d.ts +40 -0
  8. package/dist/src/ai/estimate.js +113 -0
  9. package/dist/src/ai/fake.d.ts +1 -1
  10. package/dist/src/ai/fake.js +1 -1
  11. package/dist/src/ai/hetzner.js +1 -1
  12. package/dist/src/ai/openrouter.d.ts +13 -0
  13. package/dist/src/ai/openrouter.js +66 -12
  14. package/dist/src/ai/pricing.d.ts +14 -0
  15. package/dist/src/ai/pricing.js +77 -0
  16. package/dist/src/ai/provider.js +8 -0
  17. package/dist/src/ai/verify.d.ts +25 -0
  18. package/dist/src/ai/verify.js +78 -0
  19. package/dist/src/cli/args.js +25 -29
  20. package/dist/src/cli/commands/config.d.ts +44 -0
  21. package/dist/src/cli/commands/config.js +220 -0
  22. package/dist/src/cli/commands/probe.js +70 -103
  23. package/dist/src/cli/commands/registry.d.ts +57 -0
  24. package/dist/src/cli/commands/registry.js +713 -0
  25. package/dist/src/cli/commands/review.js +66 -43
  26. package/dist/src/cli/commands/serve.d.ts +4 -2
  27. package/dist/src/cli/commands/serve.js +14 -10
  28. package/dist/src/cli/commands/set.d.ts +2 -1
  29. package/dist/src/cli/commands/set.js +63 -17
  30. package/dist/src/cli/commands/view.d.ts +1 -0
  31. package/dist/src/cli/commands/view.js +180 -0
  32. package/dist/src/cli/error.d.ts +39 -0
  33. package/dist/src/cli/error.js +64 -0
  34. package/dist/src/cli/main.d.ts +7 -0
  35. package/dist/src/cli/main.js +82 -1
  36. package/dist/src/cli/prompt.js +21 -7
  37. package/dist/src/cli/review_args.d.ts +4 -0
  38. package/dist/src/cli/review_args.js +22 -9
  39. package/dist/src/cli/review_output.d.ts +2 -2
  40. package/dist/src/cli/review_output.js +7 -6
  41. package/dist/src/cli/review_result.d.ts +59 -8
  42. package/dist/src/cli/review_result.js +145 -103
  43. package/dist/src/config.d.ts +15 -0
  44. package/dist/src/config.js +31 -0
  45. package/dist/src/github/app.d.ts +13 -0
  46. package/dist/src/github/app.js +23 -0
  47. package/dist/src/github/app_manifest.d.ts +50 -0
  48. package/dist/src/github/app_manifest.js +138 -0
  49. package/dist/src/github/client.js +7 -1
  50. package/dist/src/github/collect.js +1 -1
  51. package/dist/src/github/gh.js +75 -18
  52. package/dist/src/knowledge/facts.js +8 -3
  53. package/dist/src/knowledge/guide.d.ts +8 -0
  54. package/dist/src/knowledge/guide.js +32 -8
  55. package/dist/src/knowledge/probe.js +13 -3
  56. package/dist/src/knowledge/sections.d.ts +9 -0
  57. package/dist/src/knowledge/sections.js +16 -0
  58. package/dist/src/knowledge/skill.d.ts +5 -0
  59. package/dist/src/knowledge/skill.js +68 -21
  60. package/dist/src/knowledge/synthesis.d.ts +17 -1
  61. package/dist/src/knowledge/synthesis.js +70 -15
  62. package/dist/src/knowledge/types.d.ts +5 -0
  63. package/dist/src/local/codegraph_prepare.d.ts +3 -2
  64. package/dist/src/local/codegraph_prepare.js +4 -2
  65. package/dist/src/local/git_ops.d.ts +5 -4
  66. package/dist/src/local/git_ops.js +8 -9
  67. package/dist/src/local/review_local.js +36 -14
  68. package/dist/src/pr/checkout.js +14 -3
  69. package/dist/src/pr/codegraph_tools.js +3 -3
  70. package/dist/src/pr/diff_summary.js +3 -3
  71. package/dist/src/pr/findings.js +16 -5
  72. package/dist/src/pr/findings_json.d.ts +26 -0
  73. package/dist/src/pr/findings_json.js +187 -0
  74. package/dist/src/pr/review_copy.d.ts +20 -0
  75. package/dist/src/pr/review_copy.js +40 -0
  76. package/dist/src/pr/reviewer.d.ts +2 -1
  77. package/dist/src/pr/reviewer.js +84 -91
  78. package/dist/src/remote/client.js +42 -42
  79. package/dist/src/remote/http.d.ts +5 -0
  80. package/dist/src/remote/http.js +49 -0
  81. package/dist/src/remote/server/guides.d.ts +22 -0
  82. package/dist/src/remote/server/guides.js +81 -0
  83. package/dist/src/remote/server/routes.js +24 -0
  84. package/dist/src/review/blocking.d.ts +35 -0
  85. package/dist/src/review/blocking.js +45 -0
  86. package/dist/src/review/carry_over.d.ts +12 -1
  87. package/dist/src/review/carry_over.js +35 -9
  88. package/dist/src/review/engine.d.ts +1 -0
  89. package/dist/src/review/guides.js +28 -1
  90. package/dist/src/server/api/installations.js +1 -1
  91. package/dist/src/server/api/repos.js +86 -3
  92. package/dist/src/server/api/settings.js +3 -3
  93. package/dist/src/server/app.d.ts +3 -0
  94. package/dist/src/server/app.js +12 -5
  95. package/dist/src/server/pages/activity.d.ts +1 -0
  96. package/dist/src/server/pages/activity.js +2 -2
  97. package/dist/src/server/pages/add_repo.js +73 -5
  98. package/dist/src/server/pages/client.d.ts +1 -1
  99. package/dist/src/server/pages/client.js +1 -1
  100. package/dist/src/server/pages/home.d.ts +6 -1
  101. package/dist/src/server/pages/home.js +25 -2
  102. package/dist/src/server/pages/knowledge.js +1 -1
  103. package/dist/src/server/pages/layout.js +2 -2
  104. package/dist/src/server/pages/repo.d.ts +1 -1
  105. package/dist/src/server/pages/repo.js +21 -2
  106. package/dist/src/server/pages/repo_prs.d.ts +5 -1
  107. package/dist/src/server/pages/repo_prs.js +36 -1
  108. package/dist/src/server/pages/repo_remote.js +2 -2
  109. package/dist/src/server/pages/repo_settings.js +2 -2
  110. package/dist/src/server/pages/router.d.ts +6 -0
  111. package/dist/src/server/pages/router.js +129 -9
  112. package/dist/src/server/pages/settings.d.ts +1 -1
  113. package/dist/src/server/pages/settings.js +87 -9
  114. package/dist/src/server/pages/styles.d.ts +1 -1
  115. package/dist/src/server/pages/styles.js +1 -1
  116. package/dist/src/services/probe.d.ts +28 -0
  117. package/dist/src/services/probe.js +133 -0
  118. package/dist/src/services/remake_cron.d.ts +1 -1
  119. package/dist/src/services/remake_cron.js +3 -3
  120. package/dist/src/services/remote_review.js +14 -4
  121. package/dist/src/services/review.d.ts +11 -0
  122. package/dist/src/services/review.js +39 -11
  123. package/dist/src/services/setup.js +37 -19
  124. package/dist/src/services/setup_checklist.d.ts +10 -0
  125. package/dist/src/services/setup_checklist.js +87 -0
  126. package/dist/src/store/app_db.js +5 -2
  127. package/dist/src/store/cache_db.d.ts +4 -0
  128. package/dist/src/store/cache_db.js +27 -4
  129. package/dist/src/store/deliveries.d.ts +7 -0
  130. package/dist/src/store/deliveries.js +15 -0
  131. package/dist/src/tools/codegraph.d.ts +30 -0
  132. package/dist/src/tools/codegraph.js +49 -12
  133. package/dist/src/types.d.ts +10 -0
  134. package/dist/src/util/log.d.ts +3 -0
  135. package/dist/src/util/log.js +12 -0
  136. package/dist/src/util/run_summary.d.ts +33 -0
  137. package/dist/src/util/run_summary.js +47 -0
  138. package/dist/src/util/webhook_reachability.d.ts +11 -0
  139. package/dist/src/util/webhook_reachability.js +64 -0
  140. package/package.json +21 -15
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Murat Kirazkaya
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Murat Kirazkaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,15 +2,14 @@
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/co-maintainer)](https://www.npmjs.com/package/co-maintainer) [![](https://img.shields.io/badge/blog-co_maintainer_an_imitator_of_yours-blue)](https://muratkirazkaya.com/blogs/co-maintainer-an-imitator-of-yours)
4
4
 
5
- [Website](https://groophylifefor.github.io/co-maintainer/) ·
6
- [Docs](https://groophylifefor.github.io/co-maintainer/getting-started.html) ·
7
- [Cloud](https://dashboard.cm.ordu.dev/) (hosted, free, early access, invite only for now)
5
+ [Website](https://co-maintainer.com) ·
6
+ [Docs](https://co-maintainer.com/docs) ·
7
+ [Cloud](https://cloud.co-maintainer.com) (hosted, free, early access, invite only for now)
8
8
 
9
- `co-maintainer` analyzes a GitHub repository and writes repository-specific
10
- `SKILL.md` guidance that helps developers contribute changes more reliably.
9
+ An imitator of yours, A PR review tool like co-maintainer.
11
10
 
12
11
  It collects current code and workflows, selected pull requests and diffs, and
13
- default-branch commits. A low-cost model extracts evidence-bound observations; a
12
+ default-branch commits. A low-cost model extracts evidence-bound observations. A
14
13
  higher-reasoning model turns them into concise contribution guidance for
15
14
  implementation, testing, review, and release decisions.
16
15
 
@@ -27,7 +26,7 @@ Requires Node.js 24 or newer.
27
26
  ```sh
28
27
  co-maintainer probe owner/repo --auth=gh
29
28
 
30
- co-maintainer init owner/repo --auth=gh --ai=openrouter --token=... \
29
+ co-maintainer init owner/repo --auth=gh --ai=openrouter \
31
30
  --low-model=openai/gpt-oss-120b \
32
31
  --high-model=openai/gpt-5.6-luna \
33
32
  --include-codebase --include-pull-requests \
@@ -37,30 +36,39 @@ co-maintainer init owner/repo --auth=gh --ai=openrouter --token=... \
37
36
  co-maintainer review owner/repo 123 --improve-matrix=2 --debug
38
37
 
39
38
  co-maintainer review # local branch (staged, unstaged, untracked)
40
- co-maintainer review --json # machine-readable stdout; logs on stderr
39
+ co-maintainer review --json # machine-readable stdout, logs on stderr
40
+ ```
41
+
42
+ Settings live in `config.json`, so a flag you use every time can be saved once:
41
43
 
42
- npm run review-local-e2e # fake-AI local loop (developers, from repo root)
44
+ ```sh
45
+ co-maintainer config set ai-key sk-or-... # OpenRouter key
46
+ co-maintainer config set high-model openai/gpt-5.6-luna
47
+ co-maintainer config set low-model openai/gpt-oss-120b
48
+ co-maintainer config list # every key, its value and its source
43
49
  ```
44
50
 
45
- Documentation: [Getting started](https://groophylifefor.github.io/co-maintainer/getting-started.html)
46
- (install → first review), [full docs site](https://groophylifefor.github.io/co-maintainer/),
51
+ Documentation: [Getting started](https://co-maintainer.com/docs/getting-started.html)
52
+ (install to first review) and the [full docs site](https://co-maintainer.com/docs),
47
53
  sources in [`docs/md/`](docs/md/).
48
54
 
49
55
  Use `co-maintainer help`, `co-maintainer -h`, or `co-maintainer --help` for the
50
- full CLI help.
56
+ full CLI help. Every guide the CLI writes is also readable from the dashboard,
57
+ and the dashboard keeps its guides separate from the CLI's. See
58
+ [dashboard.md](docs/md/dashboard.md).
51
59
 
52
60
  ## Compared to other tools
53
61
 
54
62
  co-maintainer is a new tool, so it has not been compared with many others yet.
55
- It was benchmarked against [OCR](https://open-codereview.ai/). I cannot offer any
56
- guarantees because the datasets are very small, but it shows promise.
63
+ It was benchmarked against [OCR](https://open-codereview.ai/) on `core_v2`. The
64
+ datasets are small, so read the numbers as a signal, not a guarantee.
57
65
 
58
- In core_v2 benchmarks:
59
-
60
- - **2-3x better results** than OCR
61
- - **2-8x faster** than OCR
62
- - **70-200x fewer tokens** than OCR
63
- - **60-270x cheaper** than OCR
66
+ | Claim, as measured on `core_v2` | Result |
67
+ | ------------------------------- | ------------------ |
68
+ | Result quality against OCR | 2 to 3x better |
69
+ | Speed against OCR | 2 to 8x faster |
70
+ | Tokens per review against OCR | 70 to 200x fewer |
71
+ | Cost per review against OCR | 60 to 270x cheaper |
64
72
 
65
73
  ```mermaid
66
74
  xychart-beta
@@ -71,26 +79,37 @@ xychart-beta
71
79
  line "co-maintainer" [2, 10, 20, 40, 60, 80, 100, 120, 140, 160, 200]
72
80
  ```
73
81
 
74
- _Calculated based on co-maintainer's `core_v2` benchmark, which reports co-maintainer as 65x cheaper per review than OCR ($0.025/review vs. $1.62/review). Assumes 80 PR reviews per developer per month (40 PRs merged, avg. ~2 reviews each)._
82
+ _Calculated from co-maintainer's `core_v2` benchmark, which reports co-maintainer as 65x cheaper per review than OCR ($0.025/review vs. $1.62/review). Assumes 80 PR reviews per developer per month (40 PRs merged, about 2 reviews each)._
75
83
 
76
84
  ## Benchmarks
77
85
 
78
- We recommend consider core_v3 benchmark results because of more suits for
79
- co-maintainer use cases. It is a much more meaningful metric than other
80
- benchmarks. core_v3 is co-maintainer only benchmark.
81
-
82
- Unlike the OCR comparison above (real human review comments as gold, see
83
- `benchmark/swe-prbench`), `bench core v3` scores against deliberately seeded,
84
- hand-verified defects in a dedicated target repo, half of the PRs kept as
85
- defect-free controls so precision is measurable too, not just recall. Full
86
- breakdown, per-PR results, and methodology:
86
+ `core_v3` fits co-maintainer's use case better than the OCR comparison, so it is
87
+ the more meaningful metric here. Unlike the OCR comparison (real human review
88
+ comments as gold, see `benchmark/swe-prbench`), `bench core v3` scores against
89
+ deliberately seeded, hand-verified defects in a dedicated target repo. Half of
90
+ the PRs are kept as defect-free controls, so precision is measurable too, not
91
+ just recall. Full breakdown, per-PR results, and methodology:
87
92
  [`benchmark/core_v3/README.md`](benchmark/core_v3/README.md).
88
93
 
89
- | metric | value |
90
- | ------------- | ----------------------------- |
91
- | PRs reviewed | 30 (14 defective, 16 control) |
92
- | F1 | 0.500 |
93
- | Precision | 0.353 |
94
- | Recall | 0.857 |
95
- | Avg cost / PR | $0.0045 |
96
- | Avg time / PR | 28.9s |
94
+ | metric | value | context |
95
+ | ------------- | ----------------------------- | --------------------------- |
96
+ | PRs reviewed | 30 (14 defective, 16 control) | one target repo |
97
+ | F1 | 0.500 | against seeded defects only |
98
+ | Precision | 0.353 | 1.00 on the control PRs |
99
+ | Recall | 0.857 | 12 of 14 defects found |
100
+ | Avg cost / PR | $0.0045 | low and high model together |
101
+ | Avg time / PR | 28.9s | sequential, no concurrency |
102
+
103
+ ## Development
104
+
105
+ From a checkout:
106
+
107
+ ```sh
108
+ npm install
109
+ npm test # unit, script and benchmark tests
110
+ npm run check # tsc --noEmit
111
+ npm run docs:build # rebuild docs/ from docs/md/
112
+ npm run e2e # end-to-end CLI loop
113
+ npm run review-local-e2e # fake-AI local review loop
114
+ npm run dashboard:smoke # browser smoke test
115
+ ```
package/dist/main.js CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { run } from "./src/cli/main.js";
2
+ import { reportCliError, run } from "./src/cli/main.js";
3
3
  try {
4
4
  await run(process.argv.slice(2));
5
5
  }
6
6
  catch (error) {
7
- console.error(`[error] ${error instanceof Error ? error.message : String(error)}`);
8
- process.exit(1);
7
+ reportCliError(error);
9
8
  }
package/dist/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.13",
4
- "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
3
+ "version": "0.5.0-beta.1",
4
+ "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
+ "keywords": [
6
+ "ai",
7
+ "code-review",
8
+ "github",
9
+ "maintainer",
10
+ "pull-request"
11
+ ],
12
+ "homepage": "https://co-maintainer.com",
13
+ "bugs": {
14
+ "url": "https://github.com/GroophyLifefor/co-maintainer/issues"
15
+ },
5
16
  "license": "MIT",
6
17
  "repository": {
7
18
  "type": "git",
8
19
  "url": "https://github.com/GroophyLifefor/co-maintainer"
9
20
  },
10
- "homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
11
- "bugs": {
12
- "url": "https://github.com/GroophyLifefor/co-maintainer/issues"
13
- },
14
- "keywords": [
15
- "github",
16
- "pull-request",
17
- "code-review",
18
- "maintainer",
19
- "ai"
20
- ],
21
21
  "bin": {
22
22
  "co-maintainer": "dist/main.js"
23
23
  },
@@ -34,7 +34,7 @@
34
34
  "build": "npm run assets:embed && tsc -p tsconfig.build.json",
35
35
  "assets:embed": "node scripts/embed_assets.mjs",
36
36
  "prepack": "npm run build",
37
- "test": "node --test \"src/**/*.test.ts\" \"benchmark/**/*.test.ts\"",
37
+ "test": "node --test \"src/**/*.test.ts\" \"scripts/**/*.test.ts\" \"benchmark/**/*.test.ts\"",
38
38
  "check": "tsc --noEmit",
39
39
  "lint": "oxlint",
40
40
  "fmt": "oxfmt --write .",
@@ -43,6 +43,7 @@
43
43
  "probe": "node main.ts probe",
44
44
  "init": "node main.ts init",
45
45
  "remake": "node main.ts remake",
46
+ "sync": "node main.ts sync",
46
47
  "review": "node main.ts review",
47
48
  "serve": "node main.ts serve",
48
49
  "bench": "node benchmark/core_v2/run.ts",
@@ -52,7 +53,11 @@
52
53
  "swe-prbench-prepare": "node benchmark/swe-prbench/prepare_dataset.ts",
53
54
  "e2e": "node scripts/e2e.ts",
54
55
  "review-local-e2e": "node scripts/review_local_e2e.ts",
55
- "docs:build": "node scripts/build_docs.ts"
56
+ "docs:build": "node scripts/build_docs.ts",
57
+ "pack:smoke": "node scripts/pack_smoke.ts",
58
+ "downgrade:check": "node scripts/downgrade_check.ts",
59
+ "dx:scenario": "node scripts/dx_scenario.ts",
60
+ "dashboard:smoke": "node scripts/dashboard_smoke.ts"
56
61
  },
57
62
  "dependencies": {
58
63
  "marked": "^15.0.7"
@@ -61,6 +66,7 @@
61
66
  "@types/node": "^24.0.0",
62
67
  "oxfmt": "^0.68.0",
63
68
  "oxlint": "^1.83.0",
69
+ "playwright": "^1.63.0",
64
70
  "typescript": "^5.7.0"
65
71
  },
66
72
  "engines": {
@@ -1,5 +1,16 @@
1
1
  import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
2
2
  type UsageSink = (job: string, response: AiResponse) => Promise<void>;
3
+ /** Returns `null` when a response is usable, or a short reason when it is not.
4
+ * `synthesis.ts` supplies one that parses the model text, because a syntactically
5
+ * fine HTTP response can still be unparseable output, and caching that is what
6
+ * poisoned every later `sync` (CORE-30 / F04). */
7
+ export type AiValidator = (request: AiRequest, response: AiResponse) => string | null;
8
+ /** A unit whose output never parsed, kept so the run can say what it dropped. */
9
+ export type SkippedUnit = {
10
+ index: number;
11
+ job: string;
12
+ reason: string;
13
+ };
3
14
  export declare class AiBatch {
4
15
  private records;
5
16
  private loaded;
@@ -8,8 +19,20 @@ export declare class AiBatch {
8
19
  private readonly repo;
9
20
  private readonly concurrency;
10
21
  private readonly profile;
11
- constructor(provider: AiProvider, repo: string, concurrency: number, profile: string);
22
+ private readonly validate?;
23
+ private readonly skipped;
24
+ constructor(provider: AiProvider, repo: string, concurrency: number, profile: string, validate?: AiValidator);
25
+ /** Units whose output did not parse this run (or on re-try), in request order. */
26
+ skippedUnits(): SkippedUnit[];
12
27
  run(requests: AiRequest[], usage?: UsageSink): Promise<(AiResponse | undefined)[]>;
28
+ /** Runs one unit, caching its response only when the validator accepts it.
29
+ * A rejected response is retried once; a second rejection is recorded as
30
+ * skipped and never cached, so the next `sync` retries it rather than
31
+ * replaying the same unusable output (CORE-30 / F04). */
32
+ private completeUnit;
33
+ /** Records a unit as skipped: no cache write, so it stays retried and its
34
+ * absence is never mistaken for a deliberate empty result. */
35
+ private markSkipped;
13
36
  private load;
14
37
  private persist;
15
38
  }
@@ -1,5 +1,6 @@
1
1
  import { cacheGet, cacheSet } from "../store/cache_db.js";
2
2
  import { isNotFound, readTextFile } from "../util/runtime.js";
3
+ import { formatError } from "../cli/error.js";
3
4
  async function digest(value) {
4
5
  const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
5
6
  return [...new Uint8Array(bytes)]
@@ -14,11 +15,18 @@ export class AiBatch {
14
15
  repo;
15
16
  concurrency;
16
17
  profile;
17
- constructor(provider, repo, concurrency, profile) {
18
+ validate;
19
+ skipped = [];
20
+ constructor(provider, repo, concurrency, profile, validate) {
18
21
  this.provider = provider;
19
22
  this.repo = repo;
20
23
  this.concurrency = concurrency;
21
24
  this.profile = profile;
25
+ this.validate = validate;
26
+ }
27
+ /** Units whose output did not parse this run (or on re-try), in request order. */
28
+ skippedUnits() {
29
+ return [...this.skipped];
22
30
  }
23
31
  async run(requests, usage) {
24
32
  await this.load();
@@ -35,8 +43,20 @@ export class AiBatch {
35
43
  }));
36
44
  const cached = this.records[id];
37
45
  if (cached?.status === "done" && cached.response) {
38
- results[index] = cached.response;
39
- console.log(`[ai] cache hit ${request.job} ${id.slice(0, 8)}`);
46
+ // A `done` record from 0.4.x can hold output that never parsed. Treat
47
+ // that as a miss and delete it so the retry below replaces it; the
48
+ // corrupted record heals on its own the next time its unit runs.
49
+ const reason = this.validate?.(request, cached.response) ?? null;
50
+ if (reason === null) {
51
+ results[index] = cached.response;
52
+ console.log(`[ai] cache hit ${request.job} ${id.slice(0, 8)}`);
53
+ }
54
+ else {
55
+ delete this.records[id];
56
+ await this.persist();
57
+ console.log(`[ai] dropped unusable cache record ${request.job} ${id.slice(0, 8)}: ${reason}`);
58
+ pending.push({ index, id, request });
59
+ }
40
60
  }
41
61
  else if (cached?.status === "quarantine") {
42
62
  console.log(`[ai] quarantined job skipped: ${request.job} ${id.slice(0, 8)}`);
@@ -57,26 +77,7 @@ export class AiBatch {
57
77
  console.log(`[ai] still running ${item.request.job} ${item.id.slice(0, 8)} · ${seconds}s`);
58
78
  }, 15_000);
59
79
  try {
60
- const response = await this.provider.complete(item.request);
61
- this.records[item.id] = {
62
- status: "done",
63
- response,
64
- updatedAt: new Date().toISOString(),
65
- };
66
- results[item.index] = response;
67
- await this.persist();
68
- if (usage)
69
- await usage(item.request.job, response);
70
- console.log(`[ai] done ${item.request.job} ${item.id.slice(0, 8)} · ${Math.round((Date.now() - startedAt) / 1000)}s`);
71
- }
72
- catch (error) {
73
- this.records[item.id] = {
74
- status: "quarantine",
75
- error: String(error),
76
- updatedAt: new Date().toISOString(),
77
- };
78
- await this.persist();
79
- console.log(`[ai] quarantined ${item.request.job} ${item.id.slice(0, 8)}: ${String(error)}`);
80
+ await this.completeUnit(item, results, usage, startedAt);
80
81
  }
81
82
  finally {
82
83
  clearInterval(heartbeat);
@@ -87,6 +88,57 @@ export class AiBatch {
87
88
  await this.saveChain;
88
89
  return results;
89
90
  }
91
+ /** Runs one unit, caching its response only when the validator accepts it.
92
+ * A rejected response is retried once; a second rejection is recorded as
93
+ * skipped and never cached, so the next `sync` retries it rather than
94
+ * replaying the same unusable output (CORE-30 / F04). */
95
+ async completeUnit(item, results, usage, startedAt) {
96
+ for (const attempt of [1, 2]) {
97
+ try {
98
+ const response = await this.provider.complete(item.request);
99
+ const reason = this.validate?.(item.request, response) ?? null;
100
+ if (reason !== null) {
101
+ if (attempt === 2) {
102
+ this.markSkipped(item, reason);
103
+ return;
104
+ }
105
+ console.log(`[ai] retrying ${item.request.job} ${item.id.slice(0, 8)}: ${reason}`);
106
+ continue;
107
+ }
108
+ this.records[item.id] = {
109
+ status: "done",
110
+ response,
111
+ updatedAt: new Date().toISOString(),
112
+ };
113
+ results[item.index] = response;
114
+ await this.persist();
115
+ if (usage)
116
+ await usage(item.request.job, response);
117
+ console.log(`[ai] ${attempt === 1 ? "done" : "retried"} ${item.request.job} ${item.id.slice(0, 8)} · ${Math.round((Date.now() - startedAt) / 1000)}s`);
118
+ return;
119
+ }
120
+ catch (error) {
121
+ this.records[item.id] = {
122
+ status: "quarantine",
123
+ error: formatError(error),
124
+ updatedAt: new Date().toISOString(),
125
+ };
126
+ await this.persist();
127
+ console.log(`[ai] quarantined ${item.request.job} ${item.id.slice(0, 8)}: ${formatError(error)}`);
128
+ return;
129
+ }
130
+ }
131
+ }
132
+ /** Records a unit as skipped: no cache write, so it stays retried and its
133
+ * absence is never mistaken for a deliberate empty result. */
134
+ markSkipped(item, reason) {
135
+ this.skipped.push({
136
+ index: item.index,
137
+ job: item.request.job,
138
+ reason,
139
+ });
140
+ console.log(`[ai] skipped ${item.request.job} ${item.index}: ${reason}`);
141
+ }
90
142
  async load() {
91
143
  if (this.loaded)
92
144
  return;
@@ -0,0 +1,40 @@
1
+ import type { ModelPrice } from "./pricing.ts";
2
+ type RecordedJob = {
3
+ tokensIn: number;
4
+ tokensOut: number;
5
+ seconds: number;
6
+ };
7
+ /** Averages this repository's recorded AI jobs from cache.db. `recordAiCost`
8
+ * keys each job `${repo}:${uuid}`, so the history belongs to the repo the
9
+ * estimate is for. Fewer than four jobs is not a trend, so it is ignored. */
10
+ export declare function readJobHistory(repo: string): Promise<RecordedJob | undefined>;
11
+ export type EstimateInput = {
12
+ /** How many pull requests the recommended flags would read. */
13
+ pullRequests: number;
14
+ /** Whether the recommended flags read the codebase. */
15
+ includeCodebase: boolean;
16
+ lowModel?: string;
17
+ highModel?: string;
18
+ prices: Map<string, ModelPrice>;
19
+ history?: RecordedJob;
20
+ };
21
+ export type Estimate = {
22
+ extract: number;
23
+ synth: number;
24
+ seconds: [number, number];
25
+ tokensIn: [number, number];
26
+ tokensOut: [number, number];
27
+ /** Absent when no price is known for the models. */
28
+ usd?: [number, number];
29
+ basis: "history" | "calibration";
30
+ };
31
+ /** The number of `extract_unit` jobs the recommended init would queue: one per
32
+ * pull request plus one for the codebase/documents, matching
33
+ * `extractAiFacts`. */
34
+ export declare function extractJobCount(input: {
35
+ pullRequests: number;
36
+ includeCodebase: boolean;
37
+ includeHowRepoWorks: boolean;
38
+ }): number;
39
+ export declare function estimateInit(input: EstimateInput): Estimate;
40
+ export {};
@@ -0,0 +1,113 @@
1
+ /** Cost and time estimate for the recommended `init` (CORE-24, F17).
2
+ *
3
+ * Three sources, in order of trust:
4
+ *
5
+ * 1. Job counts come from the same inputs the real run uses — the number of
6
+ * pull requests, whether the codebase is read, and the ten synthesis
7
+ * sections — so the shape is exact.
8
+ * 2. Dollars come from the live OpenRouter price for the configured models.
9
+ * When prices are unavailable the estimate reports tokens but no dollars
10
+ * rather than guessing one.
11
+ * 3. Time and per-job tokens come from this repository's recorded jobs when
12
+ * there are enough, and from the calibration below otherwise.
13
+ *
14
+ * Calibration (plan §CORE-24): cm-dx-lab, 4 pull requests and 8 files, 5
15
+ * extract and 9 synth jobs, 151 s, $0.0014 — about 151/14 ≈ 10.8 s per job.
16
+ * Token counts per job are near 2.4k in and 0.7k out. The text always says
17
+ * the numbers are an estimate. */
18
+ import { cacheValues } from "../store/cache_db.js";
19
+ import { sectionKeys } from "../knowledge/sections.js";
20
+ /** Measured averages used when no history exists (cm-dx-lab calibration). */
21
+ const CALIBRATION = {
22
+ seconds: 10.8,
23
+ tokensIn: 2_400,
24
+ tokensOut: 700,
25
+ };
26
+ /** A spread around the point estimate, since a real run varies. */
27
+ const SPREAD = 0.25;
28
+ const MAX_HISTORY_ROWS = 200;
29
+ /** Averages this repository's recorded AI jobs from cache.db. `recordAiCost`
30
+ * keys each job `${repo}:${uuid}`, so the history belongs to the repo the
31
+ * estimate is for. Fewer than four jobs is not a trend, so it is ignored. */
32
+ export async function readJobHistory(repo) {
33
+ let rows;
34
+ try {
35
+ rows = await cacheValues("cost", `${repo}:`);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ if (rows.length < 4)
41
+ return undefined;
42
+ let tokensIn = 0;
43
+ let tokensOut = 0;
44
+ let seconds = 0;
45
+ let counted = 0;
46
+ for (const raw of rows.slice(0, MAX_HISTORY_ROWS)) {
47
+ try {
48
+ const row = JSON.parse(raw);
49
+ tokensIn += Number(row.tokensIn ?? 0);
50
+ tokensOut += Number(row.tokensOut ?? 0);
51
+ seconds += Number(row.seconds ?? CALIBRATION.seconds);
52
+ counted++;
53
+ }
54
+ catch {
55
+ // A malformed row is skipped, not counted as a zero.
56
+ }
57
+ }
58
+ if (counted === 0)
59
+ return undefined;
60
+ return {
61
+ tokensIn: tokensIn / counted,
62
+ tokensOut: tokensOut / counted,
63
+ seconds: seconds / counted,
64
+ };
65
+ }
66
+ /** A low/high band around a point estimate. */
67
+ function band(value) {
68
+ return [value * (1 - SPREAD), value * (1 + SPREAD)];
69
+ }
70
+ /** The number of `extract_unit` jobs the recommended init would queue: one per
71
+ * pull request plus one for the codebase/documents, matching
72
+ * `extractAiFacts`. */
73
+ export function extractJobCount(input) {
74
+ let count = input.pullRequests;
75
+ if (input.includeCodebase || input.includeHowRepoWorks)
76
+ count += 1;
77
+ return count;
78
+ }
79
+ export function estimateInit(input) {
80
+ const extract = extractJobCount({
81
+ pullRequests: input.pullRequests,
82
+ includeCodebase: input.includeCodebase,
83
+ includeHowRepoWorks: true,
84
+ });
85
+ const synth = sectionKeys.length;
86
+ const jobs = extract + synth;
87
+ const per = input.history ?? CALIBRATION;
88
+ const seconds = band(jobs * per.seconds);
89
+ const tokensIn = band(jobs * per.tokensIn);
90
+ const tokensOut = band(jobs * per.tokensOut);
91
+ // Price the extract jobs with the low model and the synth jobs with the
92
+ // high model, which is how `init` splits the work. A model with no known
93
+ // price makes the dollar range unavailable rather than zero.
94
+ const low = input.lowModel ? input.prices.get(input.lowModel) : undefined;
95
+ const high = input.highModel ? input.prices.get(input.highModel) : undefined;
96
+ let usd;
97
+ if (low && high) {
98
+ const costPerJob = (per.tokensIn / 1_000_000) * low.usdPerMillionIn +
99
+ (per.tokensOut / 1_000_000) * low.usdPerMillionOut;
100
+ const costPerSynth = (per.tokensIn / 1_000_000) * high.usdPerMillionIn +
101
+ (per.tokensOut / 1_000_000) * high.usdPerMillionOut;
102
+ usd = band(extract * costPerJob + synth * costPerSynth);
103
+ }
104
+ return {
105
+ extract,
106
+ synth,
107
+ seconds,
108
+ tokensIn,
109
+ tokensOut,
110
+ usd,
111
+ basis: input.history ? "history" : "calibration",
112
+ };
113
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
2
- export declare const FAKE_REVIEW_MARKDOWN = "## Findings\n\n### [P2 \u00B7 non-blocking] `src/app.ts` \u2014 `helper()`\nLocation: `src/app.ts:4`\n\nThe helper ignores its argument, so the new behavior is never applied.\n\nThe helper accepts an argument but returns the same result without reading it.\n\nIf you'd like me to explain it in more detail, please ask.\n";
2
+ export declare const FAKE_REVIEW_MARKDOWN = "## Findings\n\n### [P2 \u00B7 non-blocking] `src/app.ts`: `helper()`\nLocation: `src/app.ts:4`\n\nThe helper ignores its argument, so the new behavior is never applied.\n\nThe helper accepts an argument but returns the same result without reading it.\n\nIf you'd like me to explain it in more detail, please ask.\n";
3
3
  export declare class FakeAiProvider implements AiProvider {
4
4
  private readonly text;
5
5
  constructor(text?: string);
@@ -1,7 +1,7 @@
1
1
  import { getEnv, readTextFileSync } from "../util/runtime.js";
2
2
  export const FAKE_REVIEW_MARKDOWN = `## Findings
3
3
 
4
- ### [P2 · non-blocking] \`src/app.ts\` — \`helper()\`
4
+ ### [P2 · non-blocking] \`src/app.ts\`: \`helper()\`
5
5
  Location: \`src/app.ts:4\`
6
6
 
7
7
  The helper ignores its argument, so the new behavior is never applied.
@@ -59,7 +59,7 @@ export class HetznerProvider {
59
59
  const wait = this.nextRequest - Date.now();
60
60
  if (wait > 0) {
61
61
  const jittered = this.jitter(wait);
62
- console.log(`[ai] hetzner quota spacing; waiting ${Math.round(jittered / 1000)}s`);
62
+ console.log(`[ai] hetzner quota spacing, waiting ${Math.round(jittered / 1000)}s`);
63
63
  await this.sleeper(jittered);
64
64
  }
65
65
  }
@@ -1,9 +1,22 @@
1
1
  import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
2
+ import { CliError } from "../cli/error.ts";
3
+ /** Provider failures as actionable text (CORE-12, F24/F28). A 401 and a 400 for
4
+ * an unknown model are preconditions the user can fix, so they are usage
5
+ * errors; anything else is a runtime failure. Only the provider's own
6
+ * `error.message` is surfaced: the raw body can carry account metadata
7
+ * (`user_id`) that has no business in a terminal. */
8
+ export declare function openRouterError(model: string, status: number, body: string): CliError;
2
9
  export declare class OpenRouterProvider implements AiProvider {
3
10
  readonly supportsTools = true;
11
+ /** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
12
+ * only a default override: with the env unset the real endpoint is used, so
13
+ * nothing changes for users. */
4
14
  private readonly endpoint;
5
15
  private readonly apiKey;
6
16
  private readonly model;
7
17
  constructor(apiKey: string, model: string);
18
+ /** One POST. Returns the raw response so `complete` can decide whether the
19
+ * failure is worth a retry without the schema. */
20
+ private post;
8
21
  complete(request: AiRequest): Promise<AiResponse>;
9
22
  }