limn-review 0.1.152 → 0.1.153

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 (168) hide show
  1. package/README.md +9 -6
  2. package/dist/ai/auto-install.js +81 -0
  3. package/dist/ai/check-tool.js +6 -0
  4. package/dist/ai/codex/config.js +1 -0
  5. package/dist/ai/codex/hook-rule.js +31 -4
  6. package/dist/ai/codex/kinds/story-build.js +2 -0
  7. package/dist/ai/codex/workspace-mcp-main.js +2 -2
  8. package/dist/ai/codex/workspace-mcp.js +42 -10
  9. package/dist/ai/credentials.js +1 -1
  10. package/dist/ai/effort.js +3 -2
  11. package/dist/ai/provider-setting.js +5 -0
  12. package/dist/ai/provider.js +1 -0
  13. package/dist/ai/providers/claude.js +41 -17
  14. package/dist/ai/providers/codex.js +13 -3
  15. package/dist/ai/result-schemas.js +9 -0
  16. package/dist/ai/runtime.js +81 -10
  17. package/dist/api/plugins/rate-limit.js +59 -15
  18. package/dist/api/routes/ai-usage-report.js +49 -0
  19. package/dist/api/routes/conflicts.js +16 -2
  20. package/dist/api/routes/dependency-merge.js +67 -1
  21. package/dist/api/routes/me.js +8 -5
  22. package/dist/api/routes/storage.js +9 -0
  23. package/dist/app.js +9 -0
  24. package/dist/auth/account.js +1 -7
  25. package/dist/cli.js +17 -2
  26. package/dist/coding/agent.js +252 -42
  27. package/dist/coding/ai-fix/checks-preview.js +58 -0
  28. package/dist/coding/ai-fix/manager.js +17 -7
  29. package/dist/coding/ai-fix/persist.js +31 -0
  30. package/dist/coding/ai-fix/prompts.js +41 -5
  31. package/dist/coding/ai-fix/routes.js +45 -3
  32. package/dist/coding/ai-resolve/run.js +2 -1
  33. package/dist/coding/checks/deny-scan.js +75 -0
  34. package/dist/coding/checks/discover.js +541 -0
  35. package/dist/coding/checks/docker.js +38 -0
  36. package/dist/coding/checks/env.js +88 -0
  37. package/dist/coding/checks/hooks.js +91 -0
  38. package/dist/coding/checks/index.js +8 -0
  39. package/dist/coding/checks/local.js +40 -0
  40. package/dist/coding/checks/sandbox.js +206 -0
  41. package/dist/coding/checks/session.js +518 -0
  42. package/dist/coding/checks/spawn.js +123 -0
  43. package/dist/coding/checks/spec.js +140 -0
  44. package/dist/coding/checks/types.js +6 -0
  45. package/dist/coding/git-ops.js +75 -2
  46. package/dist/coding/git.js +5 -2
  47. package/dist/coding/merge.js +2 -1
  48. package/dist/coding/story-build/base-refs.js +120 -0
  49. package/dist/coding/story-build/build-prompts.js +178 -0
  50. package/dist/coding/story-build/build.js +332 -0
  51. package/dist/coding/story-build/candidates.js +273 -0
  52. package/dist/coding/story-build/checkouts.js +66 -0
  53. package/dist/coding/story-build/coverage.js +167 -0
  54. package/dist/coding/story-build/docs-runner.js +178 -0
  55. package/dist/coding/story-build/docs.js +333 -0
  56. package/dist/coding/story-build/inputs.js +119 -0
  57. package/dist/coding/story-build/limits.js +22 -0
  58. package/dist/coding/story-build/manager.js +999 -0
  59. package/dist/coding/story-build/persist.js +280 -0
  60. package/dist/coding/story-build/plan-agent.js +190 -0
  61. package/dist/coding/story-build/plan.js +338 -0
  62. package/dist/coding/story-build/prompts.js +147 -0
  63. package/dist/coding/story-build/push.js +326 -0
  64. package/dist/coding/story-build/routes-build.js +151 -0
  65. package/dist/coding/story-build/routes-docs.js +113 -0
  66. package/dist/coding/story-build/routes.js +295 -0
  67. package/dist/coding/story-build/schema.js +53 -0
  68. package/dist/coding/story-build/seams.js +29 -0
  69. package/dist/config.js +24 -3
  70. package/dist/db/ai-usage-report.js +607 -0
  71. package/dist/db/blast-radius-query.js +5 -29
  72. package/dist/db/code-loc.js +4 -0
  73. package/dist/db/erase-account.js +25 -1
  74. package/dist/db/export-account.js +74 -0
  75. package/dist/db/migrations/0146_worktree_ledger.sql +30 -0
  76. package/dist/db/migrations/0147_dependency_automation.sql +41 -0
  77. package/dist/db/migrations/0148_ai_fix_checks.sql +5 -0
  78. package/dist/db/migrations/0149_story_builds.sql +116 -0
  79. package/dist/db/migrations/meta/_journal.json +28 -0
  80. package/dist/db/queries.js +41 -3
  81. package/dist/db/retention.js +5 -0
  82. package/dist/db/schema.sqlite.js +235 -5
  83. package/dist/db/sprint-builds.js +72 -0
  84. package/dist/db/sprint-graph-fold.js +12 -3
  85. package/dist/db/sprint-graph.js +5 -0
  86. package/dist/db/sprint-ticket-checks.js +5 -1
  87. package/dist/db/triage.js +6 -0
  88. package/dist/github/mutations.js +27 -0
  89. package/dist/github/repo-files.js +26 -0
  90. package/dist/index.js +17 -4
  91. package/dist/merge/dependency-automation.js +662 -0
  92. package/dist/merge/dependency-policy.js +22 -1
  93. package/dist/merge/dependency-rules.js +246 -0
  94. package/dist/platform/annotations/schema.sqlite.js +3 -3
  95. package/dist/platform/bots/benchmark-routes.js +1 -1
  96. package/dist/platform/context.js +0 -3
  97. package/dist/platform/index.js +3 -5
  98. package/dist/platform/llm/gate.js +2 -2
  99. package/dist/platform/retention.js +2 -2
  100. package/dist/platform/slack/pending-blocks.js +31 -16
  101. package/dist/platform/slack/report.js +34 -28
  102. package/dist/platform/slack/send.js +8 -1
  103. package/dist/platform/slack/signals.js +25 -9
  104. package/dist/platform/slack/tones.js +89 -0
  105. package/dist/reference-docs/store.js +24 -3
  106. package/dist/reference-docs/wiki/build.js +1 -1
  107. package/dist/reference-docs/wiki/confluence.js +141 -27
  108. package/dist/reference-docs/wiki/credentials.js +12 -0
  109. package/dist/reference-docs/wiki/github.js +444 -0
  110. package/dist/reference-docs/wiki/notion.js +10 -0
  111. package/dist/reference-docs/wiki/register.js +3 -0
  112. package/dist/reference-docs/wiki/routes.js +251 -8
  113. package/dist/reference-docs/wiki/source.js +20 -2
  114. package/dist/review/agent-context.js +2 -0
  115. package/dist/review/agentic.js +16 -0
  116. package/dist/review/ci-review/manager.js +2 -1
  117. package/dist/review/claude-review/chat.js +3 -2
  118. package/dist/review/claude-review/manager.js +3 -2
  119. package/dist/review/claude-review/replies-check.js +2 -1
  120. package/dist/review/claude-review/thread-check.js +2 -1
  121. package/dist/review/clone-hygiene.js +18 -59
  122. package/dist/review/clone-manager.js +46 -4
  123. package/dist/review/diagrams/agent.js +13 -6
  124. package/dist/review/diagrams/fingerprint.js +4 -2
  125. package/dist/review/diagrams/manager.js +56 -18
  126. package/dist/review/diagrams/persist.js +41 -16
  127. package/dist/review/diagrams/precheck.js +100 -0
  128. package/dist/review/diagrams/prompts.js +30 -24
  129. package/dist/review/diagrams/routes.js +6 -2
  130. package/dist/review/diagrams/schema.js +30 -23
  131. package/dist/review/diagrams/validate.js +155 -145
  132. package/dist/review/llm.js +1 -1
  133. package/dist/review/story-docs/agent.js +50 -12
  134. package/dist/review/story-docs/build-prompts.js +153 -0
  135. package/dist/review/story-docs/build-validate.js +162 -0
  136. package/dist/review/story-docs/schema.js +32 -1
  137. package/dist/review/ticket-review/manager.js +4 -3
  138. package/dist/review/worktree-gc-policy.js +111 -0
  139. package/dist/review/worktree-gc.js +576 -0
  140. package/dist/review/worktree-ledger.js +67 -0
  141. package/dist/review/worktree-owner.js +11 -0
  142. package/dist/review/write-deny.js +170 -0
  143. package/dist/shared/ai-usage-report.js +93 -0
  144. package/dist/shared/blast-radius.js +0 -3
  145. package/dist/shared/dependency-merge.js +20 -0
  146. package/dist/shared/index.js +3 -0
  147. package/dist/shared/reference-docs-wiki.js +77 -9
  148. package/dist/shared/repo-status.js +97 -0
  149. package/dist/shared/sequence-diagrams.js +81 -3
  150. package/dist/shared/story-build.js +215 -0
  151. package/dist/shared/types.js +2 -0
  152. package/dist/sync/hydrate-detail.js +1 -1
  153. package/dist/sync/scheduler.js +9 -3
  154. package/package.json +3 -3
  155. package/public/assets/AiFixTab-DcXh_zAo.js +3 -0
  156. package/public/assets/ClaudeReviewTab-DLH3LipL.js +20 -0
  157. package/public/assets/{PrBotBehaviourTab-ffhooSzs.js → PrBotBehaviourTab-BzsRHxmq.js} +1 -1
  158. package/public/assets/index-DoDcUqdp.js +88 -0
  159. package/public/assets/index-iiO08SBA.css +1 -0
  160. package/public/index.html +2 -2
  161. package/dist/platform/annotations/prompts.js +0 -84
  162. package/dist/platform/annotations/routes.js +0 -86
  163. package/dist/platform/annotations/runner.js +0 -238
  164. package/dist/platform/annotations/targets.js +0 -203
  165. package/public/assets/AiFixTab-CyinOMIq.js +0 -3
  166. package/public/assets/ClaudeReviewTab-RATDZ8DP.js +0 -20
  167. package/public/assets/index-Bqncs0R5.css +0 -1
  168. package/public/assets/index-DMSHx3ZH.js +0 -85
package/README.md CHANGED
@@ -44,10 +44,12 @@ there is nothing to set up. An Anthropic API key is the alternative: set
44
44
  `ANTHROPIC_API_KEY`. Limn stores no key and charges nothing for it. They can
45
45
  run on Codex instead (below).
46
46
 
47
- - **First use downloads the AI runtime, once (about 110 MB).** Press
48
- **Set up AI** in the app, or run `limn ai install`. It goes to
49
- `~/.limn/ai-runtime`, at the exact versions this release was tested with. The
50
- package itself stays small for everyone who never uses AI.
47
+ - **Claude and Codex work out of the box.** The first time Limn starts, it
48
+ downloads both in the background (Claude about 110 MB, Codex about 360 MB
49
+ unpacked) into `~/.limn/ai-runtime`, at the exact versions this release was
50
+ tested with. The package itself stays small. Signing in is the only step.
51
+ `limn ai install codex` runs the same download by hand;
52
+ `LIMN_AI_AUTO_INSTALL=false` skips the automatic one.
51
53
  - Set `LIMN_CLAUDE_PATH` to the `claude` you already have to use it instead of
52
54
  the bundled copy (the download then skips it).
53
55
  - The reviewer reads code. It cannot edit files, run commands or reach the web.
@@ -62,12 +64,13 @@ run on Codex instead (below).
62
64
  Every AI feature can run on Codex, on your own ChatGPT plan. One provider runs
63
65
  everything at a time.
64
66
 
67
+ Codex comes with Limn. Sign in from **Settings → AI**, or:
68
+
65
69
  ```bash
66
- limn ai install codex # adds the Codex CLI to the AI download
67
70
  limn ai login codex # sign in with ChatGPT (add --device-auth on a headless machine)
68
71
  ```
69
72
 
70
- Or press **Set up AI** and sign in from **Settings → AI**. Limn keeps its own
73
+ Limn keeps its own
71
74
  Codex sign-in in `~/.limn/codex`; your `~/.codex` is not read or changed. Codex
72
75
  with an OpenAI API key is not available yet.
73
76
 
@@ -0,0 +1,81 @@
1
+ // THE FIRST-BOOT AI DOWNLOAD — Claude and Codex come with Limn, without a click.
2
+ //
3
+ // The SDKs and the Codex CLI stay OUT of the npm manifest (a fast `npx limn-review`; the guardrails in
4
+ // scripts/build-release.mjs are unchanged), so on a boot that finds either runtime missing, stale or
5
+ // without its native program, Limn downloads what is missing in the background — the same
6
+ // single-flight `installAiRuntime` that `limn ai install codex` and the SPA's Retry run. ⚠ A Claude
7
+ // that is already ready is never fetched again or swapped (`['codex']` alone: the existing install
8
+ // adding Codex). After it, signing in to each provider is the only step.
9
+ //
10
+ // ⚠ IT NEVER DELAYS THE SERVER. `index.ts` calls `startAiAutoInstall` AFTER `listen`, with `void`;
11
+ // nothing awaits it. `/api/me` reads `runtime: 'installing'` (and the current step in
12
+ // `runtimeMessage`) the moment it starts, because `install()` claims its slot synchronously.
13
+ //
14
+ // ⚠ A FAILURE IS RETRIED ONCE, a few minutes later (a laptop waking on a dead network), then left for
15
+ // the reader's Retry, `limn ai install`, or the next boot — never a loop. A Codex-only failure is
16
+ // Codex's alone (ai/runtime.ts): Claude stays set up.
17
+ //
18
+ // Skipped when AI is off (LIMN_AI_DISABLED=true) or LIMN_AI_AUTO_INSTALL=false.
19
+ import { config } from '../config.js';
20
+ import { getAiRuntimeStatus, getCodexRuntimeStatus, installAiRuntime, } from './runtime.js';
21
+ /** How long after a failed automatic download the one automatic retry waits. */
22
+ export const AUTO_INSTALL_RETRY_MS = 3 * 60_000;
23
+ /**
24
+ * The providers a download must bring, or null when nothing is missing. ['codex'] alone over a ready
25
+ * Claude downloads Codex only (ai/runtime.ts): an existing install neither fetches nor swaps Claude.
26
+ */
27
+ export function autoInstallProviders(claude, codex) {
28
+ // Already running (a `limn ai install` started by the same process cannot be, but a Retry can).
29
+ if (claude.runtime === 'installing' || codex.runtime === 'installing')
30
+ return null;
31
+ const needClaude = claude.runtime !== 'ready';
32
+ const needCodex = codex.runtime !== 'ready';
33
+ if (!needClaude && !needCodex)
34
+ return null;
35
+ if (!needCodex)
36
+ return ['claude'];
37
+ return needClaude ? ['claude', 'codex'] : ['codex'];
38
+ }
39
+ /** The testable body: one attempt, and on failure ONE scheduled retry. */
40
+ export function runAutoInstall(deps, attempt = 1) {
41
+ if (!deps.enabled)
42
+ return;
43
+ const providers = autoInstallProviders(deps.claude(), deps.codex());
44
+ if (!providers)
45
+ return;
46
+ const names = providers.map((p) => (p === 'codex' ? 'Codex' : 'Claude')).join(' and ');
47
+ deps.log.info(`ai: downloading ${names} in the background`);
48
+ void deps
49
+ .install(providers)
50
+ .then((r) => {
51
+ if (r.ok) {
52
+ deps.log.info(`ai: ${names} downloaded — sign in to use ${providers.length > 1 ? 'them' : 'it'}`);
53
+ return;
54
+ }
55
+ deps.log.warn(`ai: the download did not finish: ${r.message}`);
56
+ if (attempt === 1)
57
+ deps.schedule(() => runAutoInstall(deps, 2), AUTO_INSTALL_RETRY_MS);
58
+ })
59
+ .catch(() => {
60
+ /* installAiRuntime never throws; belt and braces for the boot */
61
+ });
62
+ }
63
+ /** Start the first-boot download (a no-op when nothing is missing). Never throws, never awaited. */
64
+ export function startAiAutoInstall(log) {
65
+ try {
66
+ runAutoInstall({
67
+ enabled: config.aiEnabled && config.aiAutoInstall,
68
+ claude: getAiRuntimeStatus,
69
+ codex: getCodexRuntimeStatus,
70
+ install: (providers) => installAiRuntime(undefined, { providers }),
71
+ schedule: (fn, ms) => {
72
+ setTimeout(fn, ms).unref?.();
73
+ },
74
+ log,
75
+ });
76
+ }
77
+ catch (err) {
78
+ log.warn(`ai: the background download could not start: ${err.message}`);
79
+ }
80
+ }
81
+ //# sourceMappingURL=auto-install.js.map
@@ -0,0 +1,6 @@
1
+ // The project-checks tool's names (ai/provider.ts `AgentCheckTool`), in a module of their own so the
2
+ // providers can import them without a cycle through the registry.
3
+ /** The checks tool's server and name on Claude (`mcp__checks__run_check`). */
4
+ export const CHECKS_TOOL_SERVER = 'checks';
5
+ export const RUN_CHECK_TOOL = 'run_check';
6
+ //# sourceMappingURL=check-tool.js.map
@@ -209,6 +209,7 @@ export function mcpConfig(mcp) {
209
209
  ...c(`${base}.args`, mcp.args),
210
210
  ...(mcp.env && Object.keys(mcp.env).length > 0 ? c(`${base}.env`, mcp.env) : []),
211
211
  ...c(`${base}.default_tools_approval_mode`, mcp.default_tools_approval_mode),
212
+ ...(mcp.tool_timeout_sec != null ? c(`${base}.tool_timeout_sec`, mcp.tool_timeout_sec) : []),
212
213
  ];
213
214
  }
214
215
  /**
@@ -7,8 +7,13 @@
7
7
  // paths inside the run's WRITE roots. A read-only run has none, so every write is denied. A patch
8
8
  // is read for its `*** Add File:` / `*** Update File:` / `*** Delete File:` / `*** Move to:`
9
9
  // headers; a write naming NO path we can find is denied (fail closed).
10
+ // • On a STORY BUILD (`roots.protect`) a write inside the write roots is ALSO denied when it touches
11
+ // the build's deny-list (review/write-deny.ts: CI / git / hook / package-manager config, lockfiles,
12
+ // agent settings) — the same rule the Claude hook applies.
10
13
  // • `mcp__workspace__*` (Limn's own server) is allowed when every `path` it names is inside a READ
11
- // root (the server checks again).
14
+ // root (the server checks again) — except `mcp__workspace__run_check` (the project's own checks,
15
+ // coding/checks/), allowed ONLY when the run carries checks (`roots.checks`) and the call names a
16
+ // check by a plain string; the server runs only names from its frozen list.
12
17
  // • Any OTHER MCP tool is denied: only Limn's server may run (a user's own servers never load —
13
18
  // Limn's CODEX_HOME carries none — and this keeps it so).
14
19
  // • ⚠ AN ALLOW-LIST, NOT A DENY-LIST. The hook's matcher is `.*` (hook.ts), so it sees EVERY tool
@@ -20,12 +25,14 @@
20
25
  // workspace server use — and tested with `isWithin`.
21
26
  import { isAbsolute } from 'node:path';
22
27
  import { isWithin, realPathFor, resolveReal } from '../../review/path-guard.js';
28
+ import { protectedWriteFor } from '../../review/write-deny.js';
23
29
  export const WRITE_TOOLS = new Set(['apply_patch', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
24
30
  export const WORKSPACE_TOOL_PREFIX = 'mcp__workspace__';
25
31
  /** Built-ins that read and write nothing on disk; every other unlisted tool is denied. */
26
32
  export const HARMLESS_TOOLS = new Set(['update_plan']);
27
33
  const PATH_KEYS = ['file_path', 'path', 'notebook_path'];
28
34
  const PATCH_HEADER = /^\s*\*\*\*\s*(Add File|Update File|Delete File|Move to)\s*:\s*(.*?)\s*$/;
35
+ export const RUN_CHECK_TOOL_NAME = 'mcp__workspace__run_check';
29
36
  const deny = (reason) => ({ allow: false, reason: `Limn path guard: ${reason}` });
30
37
  /** Every string anywhere in `v` (the patch text may sit under any key, or be the input itself). */
31
38
  function strings(v, out = [], depth = 0) {
@@ -118,9 +125,22 @@ export function decideCodexToolCall(toolName, toolInput, roots, cwd) {
118
125
  if (real == null || !writeRoots.some((r) => isWithin(r, real))) {
119
126
  return deny(`refused: ${raw} is outside the folders this run may change.`);
120
127
  }
128
+ if (roots.protect === true) {
129
+ const why = protectedWriteFor(raw, from, writeRoots);
130
+ if (why)
131
+ return deny(`refused: ${why}`);
132
+ }
121
133
  }
122
134
  return { allow: true };
123
135
  }
136
+ if (toolName === RUN_CHECK_TOOL_NAME) {
137
+ if (roots.checks !== true)
138
+ return deny('refused: this run may not run checks.');
139
+ const name = toolInput && typeof toolInput === 'object' ? toolInput.name : undefined;
140
+ if (typeof name !== 'string' || name === '' || name.length > 120)
141
+ return deny('refused: name the check to run.');
142
+ return { allow: true };
143
+ }
124
144
  if (toolName.startsWith('mcp__')) {
125
145
  if (!toolName.startsWith(WORKSPACE_TOOL_PREFIX))
126
146
  return deny(`refused: only Limn's workspace tools may run, not ${toolName}.`);
@@ -144,7 +164,12 @@ export function decideCodexToolCall(toolName, toolInput, roots, cwd) {
144
164
  /** The roots as they travel to the hook process (the `LIMN_CODEX_GUARD` environment variable). */
145
165
  export const GUARD_ENV = 'LIMN_CODEX_GUARD';
146
166
  export function encodeGuardRoots(roots) {
147
- return JSON.stringify({ read: roots.read, write: roots.write });
167
+ return JSON.stringify({
168
+ read: roots.read,
169
+ write: roots.write,
170
+ ...(roots.checks ? { checks: true } : {}),
171
+ ...(roots.protect ? { protect: true } : {}),
172
+ });
148
173
  }
149
174
  /** Null when the variable is missing or malformed — the hook then denies everything it sees. */
150
175
  export function decodeGuardRoots(raw) {
@@ -154,9 +179,11 @@ export function decodeGuardRoots(raw) {
154
179
  const v = JSON.parse(raw);
155
180
  if (!v || typeof v !== 'object')
156
181
  return null;
157
- const { read, write } = v;
182
+ const { read, write, checks, protect } = v;
158
183
  const ok = (a) => Array.isArray(a) && a.every((x) => typeof x === 'string' && isAbsolute(x));
159
- return ok(read) && ok(write) ? { read, write } : null;
184
+ if (!ok(read) || !ok(write))
185
+ return null;
186
+ return { read, write, ...(checks === true ? { checks: true } : {}), ...(protect === true ? { protect: true } : {}) };
160
187
  }
161
188
  catch {
162
189
  return null;
@@ -0,0 +1,2 @@
1
+ export const CODEX_STORY_BUILD_KINDS = ['story_plan', 'story_build'];
2
+ //# sourceMappingURL=story-build.js.map
@@ -3,8 +3,8 @@
3
3
  // release install that carries no AI package, and from dev under tsx.
4
4
  import { parseServerArgv, serveWorkspaceMcp } from './workspace-mcp.js';
5
5
  try {
6
- const { read, logFile } = parseServerArgv(process.argv.slice(2));
7
- await serveWorkspaceMcp({ read, logFile, input: process.stdin, output: process.stdout });
6
+ const { read, logFile, checksSpec } = parseServerArgv(process.argv.slice(2));
7
+ await serveWorkspaceMcp({ read, logFile, checksSpec, input: process.stdin, output: process.stdout });
8
8
  }
9
9
  catch (e) {
10
10
  process.stderr.write(`limn workspace MCP server: ${e instanceof Error ? e.message : String(e)}\n`);
@@ -20,6 +20,7 @@ import { appendFileSync } from 'node:fs';
20
20
  import { createInterface } from 'node:readline';
21
21
  import { helperCommand } from './entry.js';
22
22
  import { createWorkspaceTools, ToolRefusal, WORKSPACE_TOOL_DEFS } from './workspace-files.js';
23
+ import { CheckToolRunner, loadCheckSpec, runCheckDescription, runCheckInputSchema } from '../../coding/checks/spec.js';
23
24
  export const WORKSPACE_MCP_ID = 'workspace';
24
25
  const SERVER_INFO = { name: 'limn-workspace', version: '1.0.0' };
25
26
  const DEFAULT_PROTOCOL = '2025-06-18';
@@ -27,6 +28,8 @@ const MAX_LINE_CHARS = 1024 * 1024;
27
28
  /**
28
29
  * The `mcp_servers.workspace` entry for one run: Node + the entry script + `--read <root>` per read
29
30
  * root. `opts.logFile` makes the server append one JSON line per tool call (tests, the live check).
31
+ * `opts.checks` adds the project's own checks (`run_check`, coding/checks/spec.ts): the server reads
32
+ * that frozen list ONCE at start-up, and Codex is told a call may take up to `timeoutSec`.
30
33
  */
31
34
  export function workspaceMcpConfig(roots, opts = {}) {
32
35
  if (roots.read.length === 0)
@@ -37,29 +40,49 @@ export function workspaceMcpConfig(roots, opts = {}) {
37
40
  argv.push('--read', r);
38
41
  if (opts.logFile)
39
42
  argv.push('--log', opts.logFile);
40
- return { id: WORKSPACE_MCP_ID, command, args: argv, env: {}, default_tools_approval_mode: 'approve' };
43
+ if (opts.checks)
44
+ argv.push('--checks', opts.checks.specFile);
45
+ return {
46
+ id: WORKSPACE_MCP_ID,
47
+ command,
48
+ args: argv,
49
+ env: {},
50
+ default_tools_approval_mode: 'approve',
51
+ ...(opts.checks ? { tool_timeout_sec: Math.ceil(opts.checks.timeoutSec + 30) } : {}),
52
+ };
41
53
  }
42
- /** Parse the entry's argv (`--read <root>`…, `--log <file>`). */
54
+ /** Parse the entry's argv (`--read <root>`…, `--log <file>`, `--checks <spec file>`). */
43
55
  export function parseServerArgv(argv) {
44
56
  const read = [];
45
57
  let logFile = null;
58
+ let checksSpec = null;
46
59
  for (let i = 0; i < argv.length; i++) {
47
60
  const flag = argv[i];
48
61
  const value = argv[i + 1];
49
- if ((flag === '--read' || flag === '--log') && value !== undefined) {
62
+ if ((flag === '--read' || flag === '--log' || flag === '--checks') && value !== undefined) {
50
63
  if (flag === '--read')
51
64
  read.push(value);
52
- else
65
+ else if (flag === '--log')
53
66
  logFile = value;
67
+ else
68
+ checksSpec = value;
54
69
  i++;
55
70
  }
56
71
  else
57
72
  throw new Error(`workspace MCP server: unexpected argument ${JSON.stringify(flag)}`);
58
73
  }
59
- return { read, logFile };
74
+ return { read, logFile, checksSpec };
60
75
  }
61
- /** Answer one JSON-RPC message; null for a notification (no reply). */
62
- export function handleMessage(tools, msg, log) {
76
+ /** The tool definitions: the three file tools, plus `run_check` when the run carries checks. */
77
+ export function toolDefs(checks) {
78
+ if (!checks || checks.names.length === 0)
79
+ return WORKSPACE_TOOL_DEFS;
80
+ return [
81
+ ...WORKSPACE_TOOL_DEFS,
82
+ { name: 'run_check', description: runCheckDescription(checks.names), inputSchema: runCheckInputSchema(checks.names) },
83
+ ];
84
+ }
85
+ export function handleMessage(tools, msg, log, checks = null) {
63
86
  if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
64
87
  return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
65
88
  }
@@ -87,7 +110,7 @@ export function handleMessage(tools, msg, log) {
87
110
  case 'ping':
88
111
  return { jsonrpc: '2.0', id, result: {} };
89
112
  case 'tools/list':
90
- return { jsonrpc: '2.0', id, result: { tools: WORKSPACE_TOOL_DEFS } };
113
+ return { jsonrpc: '2.0', id, result: { tools: toolDefs(checks) } };
91
114
  case 'resources/list':
92
115
  return { jsonrpc: '2.0', id, result: { resources: [] } };
93
116
  case 'resources/templates/list':
@@ -96,6 +119,12 @@ export function handleMessage(tools, msg, log) {
96
119
  return { jsonrpc: '2.0', id, result: { prompts: [] } };
97
120
  case 'tools/call': {
98
121
  const name = typeof params.name === 'string' ? params.name : '';
122
+ if (name === 'run_check' && checks) {
123
+ return checks.call(params.arguments).then((reply) => {
124
+ log({ tool: name, args: params.arguments, ok: !reply.isError });
125
+ return { jsonrpc: '2.0', id, result: { ...(reply.isError ? { isError: true } : {}), content: [{ type: 'text', text: reply.text }] } };
126
+ }, () => ({ jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: 'The check could not run.' }] } }));
127
+ }
99
128
  try {
100
129
  const text = tools.call(name, params.arguments);
101
130
  log({ tool: name, args: params.arguments, ok: true });
@@ -114,6 +143,7 @@ export function handleMessage(tools, msg, log) {
114
143
  /** Serve newline-delimited JSON-RPC on `input`/`output` until `input` ends. */
115
144
  export function serveWorkspaceMcp(opts) {
116
145
  const tools = createWorkspaceTools(opts.read);
146
+ const checks = opts.checksSpec ? new CheckToolRunner(loadCheckSpec(opts.checksSpec)) : null;
117
147
  const log = (entry) => {
118
148
  if (!opts.logFile)
119
149
  return;
@@ -141,8 +171,10 @@ export function serveWorkspaceMcp(opts) {
141
171
  send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
142
172
  return;
143
173
  }
144
- const reply = handleMessage(tools, msg, log);
145
- if (reply)
174
+ const reply = handleMessage(tools, msg, log, checks);
175
+ if (reply instanceof Promise)
176
+ void reply.then(send);
177
+ else if (reply)
146
178
  send(reply);
147
179
  });
148
180
  return new Promise((resolve) => rl.on('close', () => resolve()));
@@ -2,7 +2,7 @@
2
2
  // Fix, the ticket / CI reviews, diagrams, sprint paths, the docs build and chat, resolve with
3
3
  // Claude, every chat) AND the one-shot completions (review/llm.ts `cheapComplete`, which the
4
4
  // sprint briefs/themes, story labels, docs ranking, sprint Ask Claude and every platform feature —
5
- // reports, synthesis, work-plan wording, pointers, bot themes, the impact note, the Slack
5
+ // reports, synthesis, work-plan wording, pointers, bot themes, the Slack
6
6
  // report — all go through).
7
7
  //
8
8
  // 1. THE LOCAL CLAUDE CODE LOGIN (default) — a `claude` session on disk or
package/dist/ai/effort.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // Neither ⇒ DEFAULT (null). No DB import here, for the same reason as ai/provider-setting.ts.
8
8
  //
9
9
  // WHO IT REACHES (`agenticRunEffort`): the PR review (lead, specialists, follow-up re-reviews), AI Fix
10
- // and Plan first, on BOTH providers — and NOTHING ELSE. REVIEW_EFFORT and REVIEW_DIFF_ONLY_EFFORT reach
10
+ // and Plan first, and "Implement story"'s plan and build passes (`story_plan`, `story_build`; owner
11
+ // decision 7, docs/STORY-BUILD.md), on BOTH providers — and NOTHING ELSE. REVIEW_EFFORT and REVIEW_DIFF_ONLY_EFFORT reach
11
12
  // exactly the same runs as the Settings control (REVIEW_EFFORT IS the control, locked), so "Set by
12
13
  // REVIEW_EFFORT" under it is the whole story. Every other agent kind (CI review, ticket review,
13
14
  // diagrams, Check threads, chats, Resolve with Claude) runs at its provider's own fixed depth
@@ -89,7 +90,7 @@ export function diffOnlyEffortAt(level, atDefault) {
89
90
  return higherOf(stepDownEffort(level), lowerOf(level, atDefault));
90
91
  }
91
92
  /**
92
- * THE EFFORT of a review / AI Fix / Plan first run on `provider`: the chosen level on a worktree run;
93
+ * THE EFFORT of a review / AI Fix / Plan first / story plan / story build run on `provider`: the chosen level on a worktree run;
93
94
  * diff-only, REVIEW_DIFF_ONLY_EFFORT when set (at any level, on both providers), else
94
95
  * `diffOnlyEffortAt`; Default ⇒ `defaultRunEffort`. Read once per run, at its start; the run stores
95
96
  * what it used.
@@ -8,6 +8,7 @@ import { codexCredentialRunnable, resolveAiCredential, resolveCodexCredential }
8
8
  import { CODEX_DOCS_KINDS } from './codex/kinds/docs.js';
9
9
  import { CODEX_FIX_KINDS } from './codex/kinds/fix.js';
10
10
  import { CODEX_REVIEW_KINDS } from './codex/kinds/review.js';
11
+ import { CODEX_STORY_BUILD_KINDS } from './codex/kinds/story-build.js';
11
12
  import { CODEX_TICKETS_KINDS } from './codex/kinds/tickets.js';
12
13
  export const AI_PROVIDER_ENV = 'LIMN_AI_PROVIDER';
13
14
  /**
@@ -59,6 +60,8 @@ export const CODEX_AGENT_KINDS = new Set([
59
60
  ...CODEX_FIX_KINDS,
60
61
  ...CODEX_TICKETS_KINDS,
61
62
  ...CODEX_DOCS_KINDS,
63
+ // "Implement story" (listed from its contract commit — see that file's header).
64
+ ...CODEX_STORY_BUILD_KINDS,
62
65
  ]);
63
66
  /** Can Codex run this agent kind in this version? (`allow` is for tests.) */
64
67
  export function codexKindReady(kind, allow = CODEX_AGENT_KINDS) {
@@ -79,6 +82,8 @@ const KIND_WORDS = {
79
82
  story_docs: 'checks against docs',
80
83
  docs_build: 'the docs library',
81
84
  docs_chat: 'the docs chat',
85
+ story_plan: 'story plans',
86
+ story_build: 'story builds',
82
87
  };
83
88
  /** Every agent kind (the KIND_WORDS keys, so a new kind cannot be missed: the Record is exhaustive). */
84
89
  export const ALL_AGENT_KINDS = Object.keys(KIND_WORDS);
@@ -1,6 +1,7 @@
1
1
  import { CLAUDE_MODEL_FOR_ROLE, claudeProvider } from './providers/claude.js';
2
2
  import { CODEX_MODEL_FOR_ROLE as CODEX_MODEL_FOR_ROLE_SRC, codexProvider } from './providers/codex.js';
3
3
  import { activeProviderId, codexRunnable } from './provider-setting.js';
4
+ export { CHECKS_TOOL_SERVER, RUN_CHECK_TOOL } from './check-tool.js';
4
5
  // ---- the registry -------------------------------------------------------------------------------
5
6
  export { AI_PROVIDER_ENV, aiProviderEnvSetting, aiProviderSetting, aiProviderSettingStatus, setStoredAiProviderSetting, storedAiProviderSetting, CODEX_AGENT_KINDS, CODEX_NOT_AVAILABLE_MESSAGE, codexKindNotAvailableMessage, codexKindReady, codexRunnable, ALL_AGENT_KINDS, ALL_RUN_KINDS, activeProviderId, autoReadiness, codexRunKindReady, pickAutoProvider, } from './provider-setting.js';
6
7
  export { CODEX_MODEL_FOR_ROLE } from './providers/codex.js';
@@ -8,23 +8,26 @@
8
8
  // CLAUDE.md or skills), `ISOLATED_MCP` (no claude.ai connectors), the LIMN_CLAUDE_PATH
9
9
  // executable and the credential ladder's environment (`agentAuthOptions()`);
10
10
  // • ⚠ THE PATH GUARD as the FIRST PreToolUse hook — `createPathGuard(cwd, roots, {readOnlyRoots})`
11
- // — then `ownToolsOnlyHook(<the run's own server>)`, then the run's extra hooks (the specialist
12
- // cap, the docs build's sections-write guard). Under bypassPermissions a PreToolUse deny is the
13
- // only thing that confines an absolute path, and the prompt is attacker-authored;
11
+ // — then `ownToolsOnlyHook(<the run's own server>)`, then (a story build, `spec.protectWrites`) the
12
+ // write deny-list (review/write-deny.ts), then the run's extra hooks (the specialist cap, the docs
13
+ // build's sections-write guard). Under bypassPermissions a PreToolUse deny is the only thing that
14
+ // confines an absolute path, and the prompt is attacker-authored;
14
15
  // • `additionalDirectories` = roots + read-only roots, only when there are any (the SDK adds one
15
16
  // `--add-dir` per entry, so an empty list and no list run the same CLI);
16
17
  // • the result tools as ONE in-process MCP server (`createSdkMcpServer`, version 1.0.0), each
17
18
  // handler's reply as one text block (+ `isError` only when the handler set it).
18
19
  // The SDK module comes from `loadAgentSdk()`, whose `query` is TAPPED (ai/runtime.ts `tapQuery`), so
19
20
  // every message still reaches the spend budget's observer.
20
- import { loadAgentSdk, loadAnthropicSdk, claudeExecutableOptions, getAiRuntimeStatus } from '../runtime.js';
21
+ import { loadAgentSdk, loadAnthropicSdk, loadZod, claudeExecutableOptions, getAiRuntimeStatus } from '../runtime.js';
21
22
  import { agentAuthOptions, agentSdkEnv, resolveAiCredential } from '../credentials.js';
22
23
  import { ISOLATED_MCP, ownToolsOnlyHook } from '../agent-isolation.js';
23
24
  import { config } from '../../config.js';
24
25
  import { NO_CLAUDE_AUTH_MESSAGE } from '../../review/auth.js';
25
26
  import { sdkModelOptions } from '../../review/model-options.js';
26
27
  import { createPathGuard, pathGuardHook } from '../../review/path-guard.js';
28
+ import { protectedWriteHook } from '../../review/write-deny.js';
27
29
  import { recordUsage, sumModelUsage, sumUsageMap } from '../../review/usage.js';
30
+ import { CHECKS_TOOL_SERVER, RUN_CHECK_TOOL } from '../check-tool.js';
28
31
  import { AI_MODEL_FOR_ROLE } from '../../shared/index.js';
29
32
  import { foldRateLimitEvent, parseUsageProbe } from './claude-usage.js';
30
33
  import { assistantText, describeFixBlocks, describeReviewBlocks } from './claude-activity.js';
@@ -42,21 +45,40 @@ async function runAgent(spec) {
42
45
  // From the AI runtime (ai/runtime.ts) — downloaded on first use for an npm install. A missing
43
46
  // runtime throws here, into the caller's catch, as it always did.
44
47
  const { createSdkMcpServer, query, tool } = await loadAgentSdk();
45
- const mcpServers = spec.tools
46
- ? {
47
- [spec.tools.server]: createSdkMcpServer({
48
- name: spec.tools.server,
49
- version: '1.0.0',
50
- tools: spec.tools.tools.map((t) => tool(t.name, t.description, t.shape, async (a) => {
51
- const reply = await t.handle(a);
48
+ const mcpServers = {};
49
+ if (spec.tools) {
50
+ mcpServers[spec.tools.server] = createSdkMcpServer({
51
+ name: spec.tools.server,
52
+ version: '1.0.0',
53
+ tools: spec.tools.tools.map((t) => tool(t.name, t.description, t.shape, async (a) => {
54
+ const reply = await t.handle(a);
55
+ return {
56
+ content: [{ type: 'text', text: reply.text }],
57
+ ...(reply.isError !== undefined ? { isError: reply.isError } : {}),
58
+ };
59
+ })),
60
+ });
61
+ }
62
+ // THE PROJECT'S CHECKS (`mcp__checks__run_check`): a second in-process server, present only when the
63
+ // run carries them. Its one argument is an enum of the frozen names — nothing else reaches the runner.
64
+ const checks = spec.checks;
65
+ if (checks && checks.names.length > 0) {
66
+ const z = await loadZod();
67
+ mcpServers[CHECKS_TOOL_SERVER] = createSdkMcpServer({
68
+ name: CHECKS_TOOL_SERVER,
69
+ version: '1.0.0',
70
+ tools: [
71
+ tool(RUN_CHECK_TOOL, checks.description, { name: z.enum(checks.names).describe('The check to run.') }, async (a) => {
72
+ const reply = await checks.run(a);
52
73
  return {
53
74
  content: [{ type: 'text', text: reply.text }],
54
75
  ...(reply.isError !== undefined ? { isError: reply.isError } : {}),
55
76
  };
56
- })),
57
- }),
58
- }
59
- : undefined;
77
+ }),
78
+ ],
79
+ });
80
+ }
81
+ const ownServers = Object.keys(mcpServers);
60
82
  const guard = createPathGuard(spec.cwd, spec.roots, { readOnlyRoots: spec.readOnlyRoots });
61
83
  const extraDirs = [...spec.roots, ...spec.readOnlyRoots];
62
84
  const c = spec.claude;
@@ -82,7 +104,9 @@ async function runAgent(spec) {
82
104
  hooks: {
83
105
  PreToolUse: [
84
106
  pathGuardHook(guard),
85
- ownToolsOnlyHook(spec.tools ? [spec.tools.server] : []),
107
+ ownToolsOnlyHook(ownServers),
108
+ // The build's write deny-list (review/write-deny.ts), only on a run that asks for it.
109
+ ...(spec.protectWrites && spec.writeRoots.length > 0 ? [protectedWriteHook(spec.writeRoots, spec.cwd)] : []),
86
110
  ...(c.extraPreToolUse ?? []),
87
111
  ],
88
112
  },
@@ -90,7 +114,7 @@ async function runAgent(spec) {
90
114
  // Don't inherit the host's .claude settings / CLAUDE.md / skills.
91
115
  settingSources: [],
92
116
  ...ISOLATED_MCP,
93
- ...(mcpServers ? { mcpServers } : {}),
117
+ ...(ownServers.length > 0 ? { mcpServers: mcpServers } : {}),
94
118
  abortController: spec.abortController,
95
119
  // LIMN_CLAUDE_PATH → the user's own `claude` instead of the SDK's bundled binary.
96
120
  ...claudeExecutableOptions(),
@@ -491,11 +491,20 @@ export async function runAgentUnchecked(spec) {
491
491
  const z = await loadZod();
492
492
  if (resultTool)
493
493
  toolSchema = shapeToJsonSchema(z, resultTool.shape);
494
- const mcp = workspaceMcpConfig(roots);
494
+ // THE PROJECT'S CHECKS (`mcp__workspace__run_check`, coding/checks/): the server reads Limn's frozen
495
+ // list once at start-up, and the guard lets the tool through ONLY on a run that carries checks.
496
+ const checks = spec.checks && spec.checks.names.length > 0 ? spec.checks : null;
497
+ const mcp = workspaceMcpConfig(roots, checks ? { checks: { specFile: checks.specFile, timeoutSec: checks.timeoutSec } } : {});
495
498
  // THE GUARD IS NOT OPTIONAL. Its trust entry is written into Limn's own home first, then Codex is
496
499
  // asked whether it will RUN the hook (and the hook must refuse a canary call). An untrusted hook is
497
500
  // skipped SILENTLY by Codex, so a run whose guard is not proven never starts — read-only or not.
498
- const { hook, home } = await trustedGuard({ read: roots.read, write: roots.write });
501
+ // A story build (`protectWrites`) also carries the write deny-list into the guard (hook-rule.ts).
502
+ const { hook, home } = await trustedGuard({
503
+ read: roots.read,
504
+ write: roots.write,
505
+ ...(checks ? { checks: true } : {}),
506
+ ...(spec.protectWrites && roots.write.length > 0 ? { protect: true } : {}),
507
+ });
499
508
  armCodexThreadSweeps(home);
500
509
  const before = isWrite ? await Promise.all(roots.write.map(gitSnapshot)) : [];
501
510
  return withScratch(async (scratch) => {
@@ -506,7 +515,8 @@ export async function runAgentUnchecked(spec) {
506
515
  }
507
516
  const args = buildExecArgs({ cwd, writeRoots: roots.write, ...(spec.system ? { system: spec.system } : {}) }, { home, mcp, hook, schemaPath, model, effort });
508
517
  const env = codexExecEnv(credential, hook);
509
- const timeoutMs = spec.codex?.timeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
518
+ // A run that may run the project's checks gets longer: each may take up to its own limit.
519
+ const timeoutMs = spec.codex?.timeoutMs ?? (checks ? DEFAULT_AGENT_TIMEOUT_MS * 2 : DEFAULT_AGENT_TIMEOUT_MS);
510
520
  // THE NEUTRAL STREAM (ai/provider.ts): the status-line kinds, then the step the event makes (its
511
521
  // activity line, its text, the run's tokens so far — turn.completed carries the thread's total,
512
522
  // which for a run and its retry is the run's own).
@@ -14,12 +14,18 @@ export const RESULT_TOOL_NAMES = [
14
14
  'submit_design',
15
15
  'submit_paths',
16
16
  'submit_alignment',
17
+ // The story docs read's BUILD mode ("Implement story" → docs consideration).
18
+ 'submit_build_docs',
17
19
  'submit_explanations',
18
20
  'record_sections',
19
21
  // The docs build's closing call (beside record_sections): a result too.
20
22
  'finish_document',
21
23
  // The docs build on CODEX: record_sections + finish_document as ONE final answer (Phase 3).
22
24
  'submit_document',
25
+ // "Implement story": the read-only plan pass (coding/story-build/schema.ts).
26
+ 'submit_story_plan',
27
+ // "Implement story": one repository's build (coding/story-build/build.ts) — the `submit_fix` shape.
28
+ 'submit_build',
23
29
  ];
24
30
  // Each builder is imported LAZILY: an agent's schema module is reached only when its schema is asked
25
31
  // for, never at import time of this registry.
@@ -36,10 +42,13 @@ const BUILDERS = {
36
42
  submit_design: async (z) => (await import('../review/diagrams/schema.js')).buildSubmitDesignShape(z),
37
43
  submit_paths: async (z) => (await import('../review/sprint-paths/schema.js')).buildSubmitPathsShape(z),
38
44
  submit_alignment: async (z) => (await import('../review/story-docs/schema.js')).buildSubmitAlignmentShape(z),
45
+ submit_build_docs: async (z) => (await import('../review/story-docs/schema.js')).buildSubmitBuildDocsShape(z),
39
46
  submit_explanations: async (z) => (await import('../review/claude-review/chat-explain.js')).buildSubmitExplanationsShape(z),
40
47
  record_sections: async (z) => (await import('../reference-docs/schema.js')).buildRecordSectionsShape(z),
41
48
  finish_document: async (z) => (await import('../reference-docs/schema.js')).buildFinishShape(z),
42
49
  submit_document: async (z) => (await import('../reference-docs/schema.js')).buildSubmitDocumentShape(z),
50
+ submit_story_plan: async (z) => (await import('../coding/story-build/schema.js')).buildSubmitStoryPlanShape(z),
51
+ submit_build: async (z) => (await import('../coding/schema.js')).buildSubmitFixShape(z),
43
52
  };
44
53
  /** The raw zod shape the Claude tool `name` is registered with. `z` defaults to the AI runtime's. */
45
54
  export async function resultToolShape(name, opts = {}, z) {