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
@@ -2,3 +2,7 @@ export declare function cacheGet(namespace: string, key: string): Promise<string
2
2
  export declare function cacheSet(namespace: string, key: string, value: string): Promise<void>;
3
3
  export declare function cacheDelete(namespace: string, key: string): Promise<void>;
4
4
  export declare function cacheDeletePrefix(namespace: string, prefix: string): Promise<void>;
5
+ /** Every value under `namespace` whose key starts with `prefix`, newest first.
6
+ * `recordAiCost` keys each job `${repo}:${uuid}`, so the probe estimate reads
7
+ * a repository's own job history without a second index to keep in sync. */
8
+ export declare function cacheValues(namespace: string, prefix: string): Promise<string[]>;
@@ -1,11 +1,17 @@
1
1
  import { Database } from "./sqlite.js";
2
2
  import { cacheDbPath, getCacheDir } from "../config.js";
3
3
  import { mkdirSync } from "../util/runtime.js";
4
- let initialized = false;
4
+ /** Paths whose schema this process has already created. Keyed by path rather
5
+ * than a single boolean: `cacheDbPath()` reads the environment at call time
6
+ * and tests point it at a temp directory, so a process-level flag would skip
7
+ * `CREATE TABLE` for the second path and every query would then fail with
8
+ * `no such table: cache`. */
9
+ const initialized = new Set();
5
10
  function openDatabase() {
6
11
  mkdirSync(`${getCacheDir()}/co-maintainer`, { recursive: true });
7
- const db = new Database(cacheDbPath());
8
- if (!initialized) {
12
+ const path = cacheDbPath();
13
+ const db = new Database(path);
14
+ if (!initialized.has(path)) {
9
15
  db.exec(`
10
16
  CREATE TABLE IF NOT EXISTS cache (
11
17
  namespace TEXT NOT NULL,
@@ -15,7 +21,7 @@ function openDatabase() {
15
21
  PRIMARY KEY (namespace, cache_key)
16
22
  )
17
23
  `);
18
- initialized = true;
24
+ initialized.add(path);
19
25
  }
20
26
  return db;
21
27
  }
@@ -61,3 +67,20 @@ export async function cacheDeletePrefix(namespace, prefix) {
61
67
  db.close();
62
68
  }
63
69
  }
70
+ /** Every value under `namespace` whose key starts with `prefix`, newest first.
71
+ * `recordAiCost` keys each job `${repo}:${uuid}`, so the probe estimate reads
72
+ * a repository's own job history without a second index to keep in sync. */
73
+ export async function cacheValues(namespace, prefix) {
74
+ const db = openDatabase();
75
+ try {
76
+ return db
77
+ .prepare(`SELECT value FROM cache
78
+ WHERE namespace = ? AND cache_key LIKE ?
79
+ ORDER BY updated_at DESC`)
80
+ .all(namespace, `${prefix}%`)
81
+ .map((row) => row.value);
82
+ }
83
+ finally {
84
+ db.close();
85
+ }
86
+ }
@@ -10,3 +10,10 @@ export declare function recordDelivery(row: {
10
10
  }): void;
11
11
  export declare function hasDelivery(deliveryId: string): boolean;
12
12
  export declare function listSkipped(repo?: string): DeliveryRow[];
13
+ /** The newest delivery for one repository, whatever its outcome, so a repo
14
+ * page can say when GitHub last reached it instead of showing nothing. */
15
+ export declare function lastDeliveryForRepo(repo: string): DeliveryRow | undefined;
16
+ /** The newest delivery of any kind, for the setup checklist's webhook step:
17
+ * it answers "has GitHub ever reached this server" rather than "for which
18
+ * repo". */
19
+ export declare function lastDelivery(): DeliveryRow | undefined;
@@ -21,3 +21,18 @@ export function listSkipped(repo) {
21
21
  const db = getAppDb().prepare(`SELECT * FROM deliveries ${where} ORDER BY received_at DESC`);
22
22
  return repo ? db.all(repo) : db.all();
23
23
  }
24
+ /** The newest delivery for one repository, whatever its outcome, so a repo
25
+ * page can say when GitHub last reached it instead of showing nothing. */
26
+ export function lastDeliveryForRepo(repo) {
27
+ return getAppDb()
28
+ .prepare(`SELECT * FROM deliveries WHERE repo = ? ORDER BY received_at DESC LIMIT 1`)
29
+ .get(repo);
30
+ }
31
+ /** The newest delivery of any kind, for the setup checklist's webhook step:
32
+ * it answers "has GitHub ever reached this server" rather than "for which
33
+ * repo". */
34
+ export function lastDelivery() {
35
+ return getAppDb()
36
+ .prepare(`SELECT * FROM deliveries ORDER BY received_at DESC LIMIT 1`)
37
+ .get();
38
+ }
@@ -23,6 +23,36 @@ export type EnsureOptions = {
23
23
  /** Overrides `process.exit`, for tests. */
24
24
  exit?: (code: number) => never;
25
25
  };
26
+ /** codegraph ships a small launcher plus a per-platform binary (an
27
+ * `optionalDependencies` split), so the download a user is about to accept is
28
+ * the platform package, not the tiny launcher. Measured from the npm registry
29
+ * for {@link CODEGRAPH_VERSION}: ~249 MB unpacked on win32-x64. Kept as a
30
+ * rounded figure because it moves between releases and the point is the order
31
+ * of magnitude, not the exact byte count. */
32
+ export declare const CODEGRAPH_APPROX_SIZE = "~250 MB";
33
+ /** A prompt may only be shown when there is a human on the other end of stdin
34
+ * *and* stdout, and no CI marker. A closed stdin (`</dev/null`), a piped
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
37
+ * prompt printed into a pipe is invisible.
38
+ *
39
+ * `streams` is injectable so the tests can stand in for a TTY without a real
40
+ * terminal. */
41
+ export declare function canPrompt(streams?: {
42
+ stdin?: {
43
+ isTTY?: boolean;
44
+ };
45
+ stdout?: {
46
+ isTTY?: boolean;
47
+ };
48
+ }, env?: Record<string, string | undefined>): boolean;
49
+ /** The one line a non-interactive run prints instead of asking (F01). It names
50
+ * the reason and both ways forward, so a CI log explains itself. */
51
+ export declare function skippedNotice(version?: string): string;
52
+ /** What the install prompt says: the package and version, where it comes from,
53
+ * what it is for, the approximate size, and the exact directory. A bare
54
+ * "Install ...? [y/N]" left the user guessing (F01). */
55
+ export declare function installPrompt(version: string, root: string): string;
26
56
  export declare function runCommand(command: string, args: string[]): Promise<CommandResult>;
27
57
  /** One version per directory, so several can sit side by side and an upgrade
28
58
  * never half-overwrites a working install. */
@@ -7,6 +7,41 @@ import { askConfirm } from "../cli/prompt.js";
7
7
  * someone else's machine. Raise it, adjust whatever reads the output, done. */
8
8
  export const CODEGRAPH_VERSION = "1.6.0";
9
9
  export const CODEGRAPH_PACKAGE = "@colbymchenry/codegraph";
10
+ /** codegraph ships a small launcher plus a per-platform binary (an
11
+ * `optionalDependencies` split), so the download a user is about to accept is
12
+ * the platform package, not the tiny launcher. Measured from the npm registry
13
+ * for {@link CODEGRAPH_VERSION}: ~249 MB unpacked on win32-x64. Kept as a
14
+ * rounded figure because it moves between releases and the point is the order
15
+ * of magnitude, not the exact byte count. */
16
+ export const CODEGRAPH_APPROX_SIZE = "~250 MB";
17
+ /** A prompt may only be shown when there is a human on the other end of stdin
18
+ * *and* stdout, and no CI marker. A closed stdin (`</dev/null`), a piped
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
21
+ * prompt printed into a pipe is invisible.
22
+ *
23
+ * `streams` is injectable so the tests can stand in for a TTY without a real
24
+ * terminal. */
25
+ export function canPrompt(streams = {
26
+ stdin: process.stdin,
27
+ stdout: process.stdout,
28
+ }, env = process.env) {
29
+ return (streams.stdin?.isTTY === true && streams.stdout?.isTTY === true && !env.CI);
30
+ }
31
+ /** The one line a non-interactive run prints instead of asking (F01). It names
32
+ * the reason and both ways forward, so a CI log explains itself. */
33
+ export function skippedNotice(version = CODEGRAPH_VERSION) {
34
+ return (`codegraph ${version} is not installed, reviewing without it. ` +
35
+ "Pass --allow-tool-install to install it, or --disable-codegraph to skip this notice.");
36
+ }
37
+ /** What the install prompt says: the package and version, where it comes from,
38
+ * what it is for, the approximate size, and the exact directory. A bare
39
+ * "Install ...? [y/N]" left the user guessing (F01). */
40
+ export function installPrompt(version, root) {
41
+ return (`co-maintainer uses codegraph ${version} (${CODEGRAPH_PACKAGE} from npm, ${CODEGRAPH_APPROX_SIZE}) ` +
42
+ "to follow calls between files while reviewing.\n" +
43
+ `Install it into ${versionDir(version, root)} (your global PATH is not touched)?`);
44
+ }
10
45
  export async function runCommand(command, args) {
11
46
  // Windows resolves npm and other shims through the shell, not as bare exes.
12
47
  const windows = isWindows();
@@ -84,7 +119,9 @@ export async function ensureCodegraph(options = {}) {
84
119
  const root = options.root ?? toolsDir();
85
120
  const run = options.run ?? runCommand;
86
121
  const log = options.log ?? ((message) => console.log(message));
87
- const interactive = options.interactive ?? process.stdin.isTTY === true;
122
+ // `canPrompt` is the single source of truth for "may we ask": stdin and
123
+ // stdout must both be a TTY and `CI` must be unset.
124
+ const interactive = options.interactive ?? canPrompt();
88
125
  // Annotated so TypeScript can see the calls below never return and keeps the
89
126
  // `Presence` narrowing intact.
90
127
  const exit = options.exit ?? process.exit;
@@ -97,17 +134,18 @@ export async function ensureCodegraph(options = {}) {
97
134
  }
98
135
  if (!options.allowInstall) {
99
136
  if (!interactive) {
100
- log(`[codegraph] co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
101
- `Run it with --allow-tool-install, or install it yourself:\n ${installHint(CODEGRAPH_VERSION, root)}`);
137
+ // This path exits rather than continuing, so it must not say "reviewing
138
+ // without it"; it says how to proceed.
139
+ log(`[codegraph] codegraph ${CODEGRAPH_VERSION} is not installed.\n` +
140
+ `Run with --allow-tool-install, or install it yourself:\n ${installHint(CODEGRAPH_VERSION, root)}`);
102
141
  exit(1);
103
142
  }
104
143
  const confirm = options.confirm ?? defaultConfirm;
105
144
  // `defaultConfirm` is async, so an unawaited call is always truthy and the
106
145
  // user's "no" was silently ignored. Await it before deciding.
107
- const approved = await confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
108
- `Install it into ${versionDir(CODEGRAPH_VERSION, root)} (your global PATH is not touched)?`);
146
+ const approved = await confirm(installPrompt(CODEGRAPH_VERSION, root));
109
147
  if (!approved) {
110
- log("[codegraph] declined; nothing was installed");
148
+ log("[codegraph] declined. Nothing was installed");
111
149
  exit(1);
112
150
  }
113
151
  }
@@ -132,7 +170,7 @@ export async function ensureCodegraphForReview(options = {}) {
132
170
  const root = options.root ?? toolsDir();
133
171
  const run = options.run ?? runCommand;
134
172
  const log = options.log ?? ((message) => console.error(message));
135
- const interactive = options.interactive ?? process.stdin.isTTY === true;
173
+ const interactive = options.interactive ?? canPrompt();
136
174
  const present = await detect(CODEGRAPH_VERSION, root, run);
137
175
  if (present.state === "ok")
138
176
  return { path: present.path };
@@ -140,15 +178,14 @@ export async function ensureCodegraphForReview(options = {}) {
140
178
  log(`[codegraph] expected ${CODEGRAPH_VERSION} but found ${present.version}; reinstalling`);
141
179
  }
142
180
  if (!options.allowInstall) {
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).
143
183
  if (!interactive) {
144
- return {
145
- reason: `codegraph ${CODEGRAPH_VERSION} is not installed (use --allow-tool-install)`,
146
- };
184
+ return { reason: skippedNotice() };
147
185
  }
148
186
  const confirm = options.confirm ?? defaultConfirm;
149
187
  // Same as `ensureCodegraph`: an unawaited async confirm is always truthy.
150
- const approved = await confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index this repository.\n` +
151
- `Install into ${versionDir(CODEGRAPH_VERSION, root)}?`);
188
+ const approved = await confirm(installPrompt(CODEGRAPH_VERSION, root));
152
189
  if (!approved)
153
190
  return { reason: "codegraph install declined" };
154
191
  }
@@ -43,6 +43,12 @@ export type Options = {
43
43
  maxPrMonths?: number;
44
44
  maxPullRequestChangeLines?: number;
45
45
  maxComments?: number;
46
+ /** `probe --run`: after printing the plan, run the recommended init in the
47
+ * same process (CORE-24). */
48
+ run?: boolean;
49
+ /** How a review decides a blocking finding (CORE-41). `model` keeps the
50
+ * 0.4.13 behavior, `severity` uses a fixed P0/P1 threshold. */
51
+ reviewBlocking?: "model" | "severity";
46
52
  };
47
53
  export type GitHubClient = {
48
54
  request<T>(endpoint: string): Promise<T>;
@@ -77,6 +83,10 @@ export type AiRequest = {
77
83
  tools?: Json[];
78
84
  maxTokens: number;
79
85
  job: string;
86
+ /** A JSON schema the response must satisfy, sent as OpenRouter's
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). */
89
+ responseFormat?: Json;
80
90
  reasoningEffort?: "max" | "xhigh" | "high" | "medium" | "low" | "none";
81
91
  };
82
92
  export type AiResponse = {
@@ -6,6 +6,9 @@ type Sink = (phase: string, message: string) => void;
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;
9
+ /** True inside a dashboard job (one wrapped with `withLogSink`), where a
10
+ * diagnostic line belongs in the job log rather than the server's terminal. */
11
+ export declare function hasLogSink(): boolean;
9
12
  export declare function startHeartbeat(phase: string | (() => string)): () => void;
10
13
  export declare function timed<T>(label: string, enabled: boolean, operation: () => Promise<T>): Promise<T>;
11
14
  export {};
@@ -21,12 +21,24 @@ export function log(phase, message) {
21
21
  else
22
22
  console.log(`[${phase}] ${message}`);
23
23
  }
24
+ /** True inside a dashboard job (one wrapped with `withLogSink`), where a
25
+ * diagnostic line belongs in the job log rather than the server's terminal. */
26
+ export function hasLogSink() {
27
+ return sinkStorage.getStore() !== undefined;
28
+ }
24
29
  export function startHeartbeat(phase) {
25
30
  const started = Date.now();
26
31
  const label = () => (typeof phase === "function" ? phase() : phase);
27
32
  const timer = setInterval(() => {
28
33
  log("progress", `still running ${label()} · ${Math.round((Date.now() - started) / 1000)}s elapsed`);
29
34
  }, 15_000);
35
+ // Never keep the process alive on its own (CORE-11). A long operation holds
36
+ // the loop open through its own pending async work (a fetch, a child), so the
37
+ // heartbeat still fires; but once that work is gone the timer must not be the
38
+ // reason the process cannot drain and apply its exit code. Every call site
39
+ // used to clear this by hand on the success path only, so any thrown error
40
+ // leaked it and the CLI hung instead of exiting.
41
+ timer.unref();
30
42
  return () => clearInterval(timer);
31
43
  }
32
44
  export async function timed(label, enabled, operation) {
@@ -0,0 +1,33 @@
1
+ /** The one-line cost and time summary every AI command ends with (CORE-26).
2
+ *
3
+ * F18: the JSON already carried `usage.costUsd` and a duration, but the human
4
+ * output had neither and `init` printed no total at all, so the only place to
5
+ * see what a run cost was the OpenRouter dashboard. This renders the same
6
+ * numbers as a single stderr line:
7
+ *
8
+ * `Done in 21.0s · 3,125 in, 1,308 out tokens · $0.0016`
9
+ *
10
+ * stdout is untouched; this is progress/diagnostic output, and mixing it into
11
+ * stdout would corrupt `--json` and any piped text. When the provider does not
12
+ * report a cost the dollar field is `cost unknown` rather than `$0.0000`.
13
+ */
14
+ import type { AiMetrics } from "../services/setup.ts";
15
+ export type RunSummary = {
16
+ /** Wall-clock milliseconds, or null when only the AI time is known. */
17
+ durationMs: number | null;
18
+ tokensIn: number;
19
+ tokensOut: number;
20
+ /** `null` when any call left the cost unknown, matching `usage.costUsd`. */
21
+ costUsd: number | null;
22
+ };
23
+ /** `21.0s`, `1.3s`, `2m 03s`; one decimal below a minute, whole seconds above. */
24
+ export declare function formatDuration(ms: number): string;
25
+ /** The whole line, without a trailing newline. */
26
+ export declare function formatRunSummary(summary: RunSummary): string;
27
+ /** Formats and writes the summary. To stderr for the CLI; inside a dashboard
28
+ * job (no stderr) it goes to the job log instead. It prints even when the run
29
+ * made no AI call, because a cache-hit `sync` that cost nothing is a fact the
30
+ * user asked for, not a reason to stay silent. */
31
+ export declare function printRunSummary(summary: RunSummary): void;
32
+ /** The metrics half of a summary, so call sites do not repeat the spread. */
33
+ export declare function summaryFromMetrics(metrics: AiMetrics, durationMs: number | null): RunSummary;
@@ -0,0 +1,47 @@
1
+ import { hasLogSink, log } from "./log.js";
2
+ /** `3,125` — grouped the way the numbers read in the docs. */
3
+ function group(value) {
4
+ return Math.round(value).toLocaleString("en-US");
5
+ }
6
+ /** `21.0s`, `1.3s`, `2m 03s`; one decimal below a minute, whole seconds above. */
7
+ export function formatDuration(ms) {
8
+ const seconds = ms / 1000;
9
+ if (seconds < 60)
10
+ return `${seconds.toFixed(1)}s`;
11
+ const whole = Math.round(seconds);
12
+ const minutes = Math.floor(whole / 60);
13
+ const rest = whole % 60;
14
+ return `${minutes}m ${String(rest).padStart(2, "0")}s`;
15
+ }
16
+ /** The whole line, without a trailing newline. */
17
+ export function formatRunSummary(summary) {
18
+ const parts = [];
19
+ if (summary.durationMs !== null) {
20
+ parts.push(`Done in ${formatDuration(summary.durationMs)}`);
21
+ }
22
+ parts.push(`${group(summary.tokensIn)} in, ${group(summary.tokensOut)} out tokens`);
23
+ parts.push(summary.costUsd === null
24
+ ? "cost unknown"
25
+ : `$${summary.costUsd.toFixed(4)}`);
26
+ return parts.join(" · ");
27
+ }
28
+ /** Formats and writes the summary. To stderr for the CLI; inside a dashboard
29
+ * job (no stderr) it goes to the job log instead. It prints even when the run
30
+ * made no AI call, because a cache-hit `sync` that cost nothing is a fact the
31
+ * user asked for, not a reason to stay silent. */
32
+ export function printRunSummary(summary) {
33
+ const line = formatRunSummary(summary);
34
+ if (hasLogSink())
35
+ log("done", line);
36
+ else
37
+ console.error(line);
38
+ }
39
+ /** The metrics half of a summary, so call sites do not repeat the spread. */
40
+ export function summaryFromMetrics(metrics, durationMs) {
41
+ return {
42
+ durationMs,
43
+ tokensIn: metrics.tokensIn,
44
+ tokensOut: metrics.tokensOut,
45
+ costUsd: metrics.costKnown ? metrics.cost : null,
46
+ };
47
+ }
@@ -0,0 +1,11 @@
1
+ /** Why GitHub cannot deliver webhooks to this URL, or `undefined` when the
2
+ * host looks reachable from the public internet.
3
+ *
4
+ * `serve` accepts any absolute http(s) URL because a single-label internal
5
+ * host (Docker Compose, k8s) or an IPv6 literal is legitimate for other
6
+ * callers. For GitHub's webhook deliveries they are not: localhost and the
7
+ * private ranges below are unreachable from outside, and a fresh install
8
+ * defaults to `http://localhost:<port>/github/webhook`, so the most common
9
+ * first-run mistake is silent otherwise. This only classifies the host, it
10
+ * never rejects a URL: `set` and `serve` keep accepting them. */
11
+ export declare function webhookReachabilityProblem(webhookUrl: string): string | undefined;
@@ -0,0 +1,64 @@
1
+ /** Why GitHub cannot deliver webhooks to this URL, or `undefined` when the
2
+ * host looks reachable from the public internet.
3
+ *
4
+ * `serve` accepts any absolute http(s) URL because a single-label internal
5
+ * host (Docker Compose, k8s) or an IPv6 literal is legitimate for other
6
+ * callers. For GitHub's webhook deliveries they are not: localhost and the
7
+ * private ranges below are unreachable from outside, and a fresh install
8
+ * defaults to `http://localhost:<port>/github/webhook`, so the most common
9
+ * first-run mistake is silent otherwise. This only classifies the host, it
10
+ * never rejects a URL: `set` and `serve` keep accepting them. */
11
+ export function webhookReachabilityProblem(webhookUrl) {
12
+ let host;
13
+ try {
14
+ host = new URL(webhookUrl).hostname;
15
+ }
16
+ catch {
17
+ return "The webhook URL is not a valid absolute URL.";
18
+ }
19
+ // `URL` keeps IPv6 literals in brackets.
20
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
21
+ const lower = bare.toLowerCase();
22
+ if (lower === "localhost" || lower.endsWith(".localhost")) {
23
+ return "localhost only resolves on the machine running serve.";
24
+ }
25
+ if (lower === "0.0.0.0" || lower === "::" || lower === "::1") {
26
+ return `${bare} only resolves on the machine running serve.`;
27
+ }
28
+ if (lower.startsWith("127.")) {
29
+ return "A loopback address only resolves on the machine running serve.";
30
+ }
31
+ if (isPrivateIpv4(lower)) {
32
+ return `${bare} is a private network address, not one GitHub can route to.`;
33
+ }
34
+ if (isPrivateIpv6(lower)) {
35
+ return `${bare} is a private network address, not one GitHub can route to.`;
36
+ }
37
+ return undefined;
38
+ }
39
+ function isPrivateIpv4(host) {
40
+ const parts = host.split(".");
41
+ if (parts.length !== 4)
42
+ return false;
43
+ const octets = parts.map((part) => Number(part));
44
+ if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
45
+ return false;
46
+ }
47
+ const [a, b] = octets;
48
+ if (a === 10)
49
+ return true;
50
+ if (a === 172 && b >= 16 && b <= 31)
51
+ return true;
52
+ if (a === 192 && b === 168)
53
+ return true;
54
+ // Link-local, as in the 169.254.169.254 metadata address.
55
+ if (a === 169 && b === 254)
56
+ return true;
57
+ return false;
58
+ }
59
+ function isPrivateIpv6(host) {
60
+ if (!host.includes(":"))
61
+ return false;
62
+ // Unique local (fc00::/7) and link-local (fe80::/10).
63
+ return /^f[cd]/.test(host) || /^fe[89ab]/.test(host);
64
+ }
package/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": {