vibe-coding-master 0.9.6 → 0.9.8
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.
- package/README.md +20 -2
- package/dist/backend/adapters/command-runner.js +7 -2
- package/dist/backend/adapters/git-adapter.js +83 -5
- package/dist/backend/api/claude-hook-routes.js +5 -0
- package/dist/backend/server.js +35 -3
- package/dist/backend/services/artifact-service.js +8 -0
- package/dist/backend/services/claude-hook-service.js +5 -1
- package/dist/backend/services/environment-check.js +245 -0
- package/dist/backend/services/gate-review-service.js +19 -0
- package/dist/backend/services/harness-service.js +11 -0
- package/dist/backend/services/message-service.js +13 -1
- package/dist/backend/services/no-proxy-environment.js +29 -0
- package/dist/backend/services/project-service.js +50 -2
- package/dist/backend/services/session-service.js +9 -0
- package/dist/backend/services/workflow-control-service.js +17 -2
- package/dist-frontend/assets/index-C6tXxGlk.css +32 -0
- package/dist-frontend/assets/{index-Bsd4k8YI.js → index-Ct8NwH3l.js} +2 -2
- package/dist-frontend/index.html +2 -2
- package/package.json +1 -1
- package/dist-frontend/assets/index-CRUqb0PX.css +0 -32
package/README.md
CHANGED
|
@@ -17,12 +17,30 @@ orchestration, translation, harness setup, and mobile gateway control.
|
|
|
17
17
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
|
-
- Node.js 20 LTS or 22
|
|
20
|
+
- Node.js 20 LTS or 22+, with `node` on the `PATH` VCM starts with (role hooks
|
|
21
|
+
and `.ai/tools` scripts run with it)
|
|
21
22
|
- npm
|
|
22
|
-
- Git
|
|
23
|
+
- Git 2.22 or newer
|
|
24
|
+
- `sh` and `curl` (role hooks report Claude Code events to VCM through them)
|
|
25
|
+
- Non-interactive credentials for the repository's upstream remote (an SSH key
|
|
26
|
+
in ssh-agent or a Git credential helper). Creating a task pulls the upstream
|
|
27
|
+
branch, and VCM runs Git without credential prompts.
|
|
28
|
+
- Optional: GitHub CLI (`gh`). VCM never runs it, but when it is installed and
|
|
29
|
+
logged out VCM warns, because roles that use it will fail.
|
|
23
30
|
- Claude Code installed and available as `claude` (verified with 2.1.310 or
|
|
24
31
|
newer; VCM shows a Repository warning when an older release is detected)
|
|
25
32
|
- Claude Code authenticated in the same environment where VCM runs
|
|
33
|
+
- A Git commit identity (`user.name` and `user.email`) in the same environment.
|
|
34
|
+
When either is missing, VCM and its roles commit as `VCM <vcm@localhost>`
|
|
35
|
+
and the Repository panel shows a warning; VCM never changes your Git config.
|
|
36
|
+
|
|
37
|
+
VCM checks these programs at startup, runs a hook self-test that sends one
|
|
38
|
+
request through the installed hook pipeline, and checks access to the upstream
|
|
39
|
+
remote when a repository connects. Any problem appears in the startup log and
|
|
40
|
+
the Repository warnings, and the Repository header shows how many there are
|
|
41
|
+
while the section is collapsed; none of them stops VCM from running. Role sessions
|
|
42
|
+
add the VCM host to `NO_PROXY`, so an `http_proxy` setting does not capture
|
|
43
|
+
their hook requests.
|
|
26
44
|
|
|
27
45
|
For Linux containers, make sure common native build/runtime tools are available:
|
|
28
46
|
|
|
@@ -10,10 +10,15 @@ export function createCommandRunner() {
|
|
|
10
10
|
input: options.input,
|
|
11
11
|
reject: false
|
|
12
12
|
});
|
|
13
|
+
// A command that could not start, timed out, or died from a signal has
|
|
14
|
+
// no exit code; it failed, so it must not read as a clean exit.
|
|
15
|
+
const exitCode = result.exitCode ?? (result.failed ? 1 : 0);
|
|
13
16
|
return {
|
|
14
17
|
stdout: result.stdout,
|
|
15
|
-
stderr: result.
|
|
16
|
-
|
|
18
|
+
stderr: result.exitCode === undefined && result.failed
|
|
19
|
+
? [result.stderr, result.shortMessage].filter(Boolean).join("\n")
|
|
20
|
+
: result.stderr,
|
|
21
|
+
exitCode
|
|
17
22
|
};
|
|
18
23
|
}
|
|
19
24
|
catch (error) {
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { VcmError } from "../errors.js";
|
|
4
|
+
// Network Git commands run without a terminal prompt: VCM is driven from the
|
|
5
|
+
// browser, so a credential prompt would hang the request on the terminal that
|
|
6
|
+
// started VCM instead of failing with an error the UI can show.
|
|
7
|
+
const REMOTE_COMMAND_TIMEOUT_MS = 20 * 1000;
|
|
8
|
+
const PULL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
// Commit identity used when neither Git config nor the environment provides one.
|
|
10
|
+
// Without it, Git either refuses to commit or invents an identity from the OS
|
|
11
|
+
// account and hostname, which silently attributes commits to a machine name.
|
|
12
|
+
export const DEFAULT_GIT_IDENTITY = { name: "VCM", email: "vcm@localhost" };
|
|
13
|
+
const GIT_IDENTITY_CACHE_TTL_MS = 30 * 1000;
|
|
14
|
+
export function renderGitIdentityWarning(location, identity) {
|
|
15
|
+
const missing = identity.defaulted.map((part) => `user.${part}`).join(" and ");
|
|
16
|
+
return `Git ${missing} is not configured for ${location}, so VCM and role commits use ${identity.name} <${identity.email}>. `
|
|
17
|
+
+ "Set your own identity in the environment that runs VCM: "
|
|
18
|
+
+ "git config --global user.name \"<name>\" and git config --global user.email \"<email>\".";
|
|
19
|
+
}
|
|
4
20
|
export function createGitAdapter(runner) {
|
|
21
|
+
const identityCache = new Map();
|
|
5
22
|
return {
|
|
6
23
|
async checkRepo(repoRoot) {
|
|
7
24
|
return checkGitMarker(repoRoot);
|
|
@@ -303,7 +320,7 @@ export function createGitAdapter(runner) {
|
|
|
303
320
|
}
|
|
304
321
|
},
|
|
305
322
|
async commit(repoRoot, message) {
|
|
306
|
-
const result = await runGit(runner, repoRoot, ["commit", "-m", message]);
|
|
323
|
+
const result = await runGit(runner, repoRoot, ["commit", "-m", message], (await this.resolveCommitIdentity(repoRoot)).env);
|
|
307
324
|
if (result.exitCode !== 0) {
|
|
308
325
|
throw new VcmError({
|
|
309
326
|
code: "GIT_COMMIT_FAILED",
|
|
@@ -318,7 +335,7 @@ export function createGitAdapter(runner) {
|
|
|
318
335
|
if (paths.length === 0) {
|
|
319
336
|
return this.getHeadCommit(repoRoot);
|
|
320
337
|
}
|
|
321
|
-
const result = await runGit(runner, repoRoot, ["commit", "--only", "-m", message, "--", ...paths]);
|
|
338
|
+
const result = await runGit(runner, repoRoot, ["commit", "--only", "-m", message, "--", ...paths], (await this.resolveCommitIdentity(repoRoot)).env);
|
|
322
339
|
if (result.exitCode !== 0) {
|
|
323
340
|
throw new VcmError({
|
|
324
341
|
code: "GIT_COMMIT_FAILED",
|
|
@@ -329,6 +346,29 @@ export function createGitAdapter(runner) {
|
|
|
329
346
|
}
|
|
330
347
|
return this.getHeadCommit(repoRoot);
|
|
331
348
|
},
|
|
349
|
+
async resolveCommitIdentity(repoRoot) {
|
|
350
|
+
const cached = identityCache.get(repoRoot);
|
|
351
|
+
if (cached && Date.now() - cached.checkedAt < GIT_IDENTITY_CACHE_TTL_MS) {
|
|
352
|
+
return cached.identity;
|
|
353
|
+
}
|
|
354
|
+
const identity = { name: "", email: "", defaulted: [], env: {} };
|
|
355
|
+
for (const part of ["name", "email"]) {
|
|
356
|
+
const result = await runGit(runner, repoRoot, ["config", "--get", `user.${part}`]);
|
|
357
|
+
const configured = result.exitCode === 0 ? result.stdout.trim() : "";
|
|
358
|
+
const variables = [`GIT_AUTHOR_${part.toUpperCase()}`, `GIT_COMMITTER_${part.toUpperCase()}`];
|
|
359
|
+
for (const variable of variables) {
|
|
360
|
+
if (!configured && !process.env[variable]) {
|
|
361
|
+
identity.env[variable] = DEFAULT_GIT_IDENTITY[part];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (variables.some((variable) => identity.env[variable])) {
|
|
365
|
+
identity.defaulted.push(part);
|
|
366
|
+
}
|
|
367
|
+
identity[part] = process.env[variables[0]] || configured || DEFAULT_GIT_IDENTITY[part];
|
|
368
|
+
}
|
|
369
|
+
identityCache.set(repoRoot, { checkedAt: Date.now(), identity });
|
|
370
|
+
return identity;
|
|
371
|
+
},
|
|
332
372
|
async createWorktree(input) {
|
|
333
373
|
const result = await runGit(runner, input.repoRoot, [
|
|
334
374
|
"worktree",
|
|
@@ -375,7 +415,7 @@ export function createGitAdapter(runner) {
|
|
|
375
415
|
}
|
|
376
416
|
},
|
|
377
417
|
async pullFastForward(repoRoot) {
|
|
378
|
-
const result = await runGit(runner, repoRoot, ["pull", "--ff-only"]);
|
|
418
|
+
const result = await runGit(runner, repoRoot, ["pull", "--ff-only"], await buildNonInteractiveGitEnv(runner, repoRoot), PULL_TIMEOUT_MS);
|
|
379
419
|
if (result.exitCode !== 0) {
|
|
380
420
|
throw new VcmError({
|
|
381
421
|
code: "GIT_PULL_FAILED",
|
|
@@ -388,9 +428,47 @@ export function createGitAdapter(runner) {
|
|
|
388
428
|
stdout: result.stdout,
|
|
389
429
|
stderr: result.stderr
|
|
390
430
|
};
|
|
431
|
+
},
|
|
432
|
+
async checkRemoteAccess(repoRoot) {
|
|
433
|
+
const branch = (await runGit(runner, repoRoot, ["branch", "--show-current"])).stdout.trim();
|
|
434
|
+
if (!branch) {
|
|
435
|
+
return { status: "skipped" };
|
|
436
|
+
}
|
|
437
|
+
const remoteResult = await runGit(runner, repoRoot, ["config", "--get", `branch.${branch}.remote`]);
|
|
438
|
+
const remote = remoteResult.stdout.trim();
|
|
439
|
+
if (remoteResult.exitCode !== 0 || !remote || remote === ".") {
|
|
440
|
+
return { status: "skipped" };
|
|
441
|
+
}
|
|
442
|
+
const result = await runGit(runner, repoRoot, ["ls-remote", remote, "HEAD"], await buildNonInteractiveGitEnv(runner, repoRoot), REMOTE_COMMAND_TIMEOUT_MS);
|
|
443
|
+
if (result.exitCode === 0) {
|
|
444
|
+
return { status: "ok", remote };
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
status: "failed",
|
|
448
|
+
remote,
|
|
449
|
+
detail: firstMeaningfulLine(result.stderr) || firstMeaningfulLine(result.stdout) || `git ls-remote exited with ${result.exitCode}`
|
|
450
|
+
};
|
|
391
451
|
}
|
|
392
452
|
};
|
|
393
453
|
}
|
|
454
|
+
async function buildNonInteractiveGitEnv(runner, repoRoot) {
|
|
455
|
+
const env = {
|
|
456
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
457
|
+
GCM_INTERACTIVE: "never"
|
|
458
|
+
};
|
|
459
|
+
// Keep a configured SSH command; otherwise make ssh fail instead of asking
|
|
460
|
+
// for a passphrase or host-key confirmation.
|
|
461
|
+
if (!process.env.GIT_SSH_COMMAND && !process.env.GIT_SSH) {
|
|
462
|
+
const configured = await runGit(runner, repoRoot, ["config", "--get", "core.sshCommand"]);
|
|
463
|
+
if (configured.exitCode !== 0 || !configured.stdout.trim()) {
|
|
464
|
+
env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { ...process.env, ...env };
|
|
468
|
+
}
|
|
469
|
+
function firstMeaningfulLine(value) {
|
|
470
|
+
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
|
|
471
|
+
}
|
|
394
472
|
async function checkGitMarker(repoRoot) {
|
|
395
473
|
const markerPath = path.join(repoRoot, ".git");
|
|
396
474
|
try {
|
|
@@ -449,8 +527,8 @@ async function pathExists(targetPath) {
|
|
|
449
527
|
return false;
|
|
450
528
|
}
|
|
451
529
|
}
|
|
452
|
-
async function runGit(runner, repoRoot, args) {
|
|
453
|
-
return runner.run("git", [...await buildSafeDirectoryArgs(repoRoot), ...args], { cwd: repoRoot });
|
|
530
|
+
async function runGit(runner, repoRoot, args, env, timeoutMs) {
|
|
531
|
+
return runner.run("git", [...await buildSafeDirectoryArgs(repoRoot), ...args], { cwd: repoRoot, env, timeoutMs });
|
|
454
532
|
}
|
|
455
533
|
function parseWorktreePaths(output) {
|
|
456
534
|
return output
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { VCM_HOOK_PROBE_PATH } from "../services/harness-service.js";
|
|
1
2
|
export function registerClaudeHookRoutes(app, deps) {
|
|
3
|
+
// The hook self-test posts here through the installed hook pipeline.
|
|
4
|
+
app.post(VCM_HOOK_PROBE_PATH, async (request) => {
|
|
5
|
+
return { received: deps.environmentCheck?.receiveHookProbe(request.body?.runtimeSessionToken) ?? false };
|
|
6
|
+
});
|
|
2
7
|
app.post("/api/hooks/claude-code", async (request) => {
|
|
3
8
|
return deps.claudeHookService.handleHook(request.body);
|
|
4
9
|
});
|
package/dist/backend/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import Fastify from "fastify";
|
|
@@ -10,7 +11,8 @@ import { createCommandRunner } from "./adapters/command-runner.js";
|
|
|
10
11
|
import { createCommandDispatcher } from "./services/command-dispatcher.js";
|
|
11
12
|
import { createClaudeHookService } from "./services/claude-hook-service.js";
|
|
12
13
|
import { createClaudeVersionCheck } from "./services/claude-version-check.js";
|
|
13
|
-
import {
|
|
14
|
+
import { createEnvironmentCheck } from "./services/environment-check.js";
|
|
15
|
+
import { createGitAdapter, renderGitIdentityWarning } from "./adapters/git-adapter.js";
|
|
14
16
|
import { createAppSettingsService } from "./services/app-settings-service.js";
|
|
15
17
|
import { createCodexBridgeIntegrationService } from "./services/codex-bridge-integration-service.js";
|
|
16
18
|
import { createAutoMemoryService } from "./services/auto-memory-service.js";
|
|
@@ -92,7 +94,7 @@ export async function createServer(deps, options = {}) {
|
|
|
92
94
|
appSettings: deps.appSettings,
|
|
93
95
|
codexBridgeIntegration: deps.codexBridgeIntegration
|
|
94
96
|
});
|
|
95
|
-
registerClaudeHookRoutes(app, { claudeHookService: deps.claudeHookService });
|
|
97
|
+
registerClaudeHookRoutes(app, { claudeHookService: deps.claudeHookService, environmentCheck: deps.environmentCheck });
|
|
96
98
|
registerGateReviewRoutes(app, {
|
|
97
99
|
projectService: deps.projectService,
|
|
98
100
|
gateReviewService: deps.gateReviewService
|
|
@@ -215,6 +217,29 @@ async function cleanupRecentTranslationRuntime(deps) {
|
|
|
215
217
|
const repoRoots = await deps.projectService.getRecentRepositoryPaths();
|
|
216
218
|
await Promise.all(repoRoots.map((repoRoot) => deps.translationWorkerService.cleanupStartupRuntime(repoRoot)));
|
|
217
219
|
}
|
|
220
|
+
async function warnOnEnvironmentProblems(environmentCheck) {
|
|
221
|
+
try {
|
|
222
|
+
for (const warning of await environmentCheck?.getWarnings({ refresh: true }) ?? []) {
|
|
223
|
+
console.warn(`[VCM] ${warning}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Project summaries report environment problems when a repository connects.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Projects can still configure their own identity; this only reports the
|
|
231
|
+
// identity VCM falls back to outside any repository-specific Git config.
|
|
232
|
+
async function warnOnDefaultGitIdentity() {
|
|
233
|
+
try {
|
|
234
|
+
const identity = await createGitAdapter(createCommandRunner()).resolveCommitIdentity(os.homedir());
|
|
235
|
+
if (identity.defaulted.length > 0) {
|
|
236
|
+
console.warn(`[VCM] ${renderGitIdentityWarning("the VCM user", identity)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Project summaries report identity problems per repository.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
218
243
|
export async function startServer(options = {}) {
|
|
219
244
|
const host = options.host ?? "127.0.0.1";
|
|
220
245
|
const port = options.port ?? 4173;
|
|
@@ -223,6 +248,8 @@ export async function startServer(options = {}) {
|
|
|
223
248
|
});
|
|
224
249
|
const app = await createServer(deps, options);
|
|
225
250
|
await app.listen({ host, port });
|
|
251
|
+
await warnOnEnvironmentProblems(deps.environmentCheck);
|
|
252
|
+
await warnOnDefaultGitIdentity();
|
|
226
253
|
return {
|
|
227
254
|
url: `http://${host}:${port}`,
|
|
228
255
|
close() {
|
|
@@ -237,6 +264,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
237
264
|
const runner = createCommandRunner();
|
|
238
265
|
const git = createGitAdapter(runner);
|
|
239
266
|
const claude = createClaudeAdapter(runner);
|
|
267
|
+
const environmentCheck = createEnvironmentCheck({ runner, git, apiUrl: options.apiUrl });
|
|
240
268
|
const appSettings = createAppSettingsService({ fs });
|
|
241
269
|
const codexBridgeIntegration = createCodexBridgeIntegrationService({
|
|
242
270
|
settings: appSettings,
|
|
@@ -252,12 +280,14 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
252
280
|
const projectService = createProjectService({
|
|
253
281
|
fs,
|
|
254
282
|
git,
|
|
283
|
+
environmentCheck,
|
|
255
284
|
appSettings,
|
|
256
285
|
claudeVersionCheck: createClaudeVersionCheck(claude)
|
|
257
286
|
});
|
|
258
287
|
const taskService = createTaskService({ fs, git, artifactService, projectService });
|
|
259
288
|
const taskWorkflowService = createTaskWorkflowService({ fs });
|
|
260
289
|
const sessionService = createSessionService({
|
|
290
|
+
gitIdentity: git,
|
|
261
291
|
fs,
|
|
262
292
|
runtime,
|
|
263
293
|
registry,
|
|
@@ -326,6 +356,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
326
356
|
taskService,
|
|
327
357
|
taskWorkflowService,
|
|
328
358
|
workflowControlService,
|
|
359
|
+
probeHookDelivery: () => environmentCheck.probeHookDelivery(),
|
|
329
360
|
onRouteDelivered: ({ repoRoot, taskSlug, message }) => architectRestartService.recordRouteDelivered(repoRoot, taskSlug, message)
|
|
330
361
|
});
|
|
331
362
|
const taskLaunchService = createTaskLaunchService({
|
|
@@ -509,7 +540,8 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
509
540
|
runtime,
|
|
510
541
|
diagnosticsService,
|
|
511
542
|
usageAnalyticsService,
|
|
512
|
-
workflowControlService
|
|
543
|
+
workflowControlService,
|
|
544
|
+
environmentCheck
|
|
513
545
|
};
|
|
514
546
|
}
|
|
515
547
|
export function getDefaultStaticDir() {
|
|
@@ -22,6 +22,9 @@ const ARTIFACT_PATH_KEYS = [
|
|
|
22
22
|
["workflow-progress", "workflowProgressPath"],
|
|
23
23
|
["final-acceptance", "finalAcceptancePath"]
|
|
24
24
|
];
|
|
25
|
+
// Gate requests whose report VCM still waits for; any other status means the
|
|
26
|
+
// run already ended and a report would be ignored.
|
|
27
|
+
const OPEN_GATE_REQUEST_STATUSES = new Set(["requested", "running"]);
|
|
25
28
|
const ROLE_COMMAND_PLACEHOLDER_PATTERN = /(^|\n)\s*(TBD|status:\s*draft)\s*(\n|$)/i;
|
|
26
29
|
const DEFAULT_MESSAGE_ROUTES = [
|
|
27
30
|
["project-manager", "architect"],
|
|
@@ -331,6 +334,11 @@ async function validateDynamicArtifact(fs, input, content, workflowControlServic
|
|
|
331
334
|
`Report path must match the assigned request path ${request.reportPath ?? "<missing>"}.`
|
|
332
335
|
]);
|
|
333
336
|
}
|
|
337
|
+
if (request.status && !OPEN_GATE_REQUEST_STATUSES.has(request.status)) {
|
|
338
|
+
throw artifactRejected(input.kind, [
|
|
339
|
+
`Gate request ${request.requestId ?? requestId} is already ${request.status}, so VCM will not use this report. Stop this review; VCM sends a new request if the gate runs again.`
|
|
340
|
+
]);
|
|
341
|
+
}
|
|
334
342
|
assertManagedArtifactContent(input.kind, content, artifactPath, input.mode, {
|
|
335
343
|
expectedGate: request.gate,
|
|
336
344
|
expectedRequestId: request.requestId ?? requestId
|
|
@@ -1029,7 +1029,11 @@ function normalizeDiagnosticString(value, maxLength) {
|
|
|
1029
1029
|
function stringOrUndefined(value) {
|
|
1030
1030
|
return typeof value === "string" ? value : undefined;
|
|
1031
1031
|
}
|
|
1032
|
+
// VCM submits its prompts as a terminal paste, and newer Claude Code versions
|
|
1033
|
+
// report a pasted prompt wrapped in <pasted_content id="..."> tags, so the
|
|
1034
|
+
// wrapper is removed before looking for VCM's marker.
|
|
1035
|
+
const PASTED_CONTENT_OPEN_TAG = /^<pasted_content\b[^>]*>/i;
|
|
1032
1036
|
function isDirectUserPrompt(prompt) {
|
|
1033
|
-
const normalized = prompt.trim();
|
|
1037
|
+
const normalized = prompt.trim().replace(PASTED_CONTENT_OPEN_TAG, "").trim();
|
|
1034
1038
|
return normalized.length > 0 && !/^\[VCM(?:\s|\])/i.test(normalized);
|
|
1035
1039
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { renderVcmHookProbeCommand } from "./harness-service.js";
|
|
3
|
+
import { buildVcmNoProxyEnvironment } from "./no-proxy-environment.js";
|
|
4
|
+
// Oldest Git that supports every command VCM runs (`git branch --show-current`).
|
|
5
|
+
export const MIN_GIT_VERSION = [2, 22, 0];
|
|
6
|
+
// Matches package.json engines; harness tools run with the `node` on PATH.
|
|
7
|
+
export const MIN_NODE_MAJOR = 20;
|
|
8
|
+
const CHECK_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
// A failed check is retried sooner so a fixed environment clears the warning.
|
|
10
|
+
const FAILED_CHECK_TTL_MS = 30 * 1000;
|
|
11
|
+
const COMMAND_TIMEOUT_MS = 10 * 1000;
|
|
12
|
+
// The hook pipeline retries curl twice with a 2 s limit per attempt.
|
|
13
|
+
const HOOK_PROBE_TIMEOUT_MS = 15 * 1000;
|
|
14
|
+
const HOOK_PROBE_SCOPE = "__vcm_hook_probe__";
|
|
15
|
+
const HOOK_PROBE_ROLE = "hook-probe";
|
|
16
|
+
export function createEnvironmentCheck(deps) {
|
|
17
|
+
const now = deps.now ?? Date.now;
|
|
18
|
+
const baseEnv = deps.baseEnv ?? process.env;
|
|
19
|
+
const hookProbeTimeoutMs = deps.hookProbeTimeoutMs ?? HOOK_PROBE_TIMEOUT_MS;
|
|
20
|
+
const pendingProbes = new Map();
|
|
21
|
+
let cached;
|
|
22
|
+
let inFlight;
|
|
23
|
+
const repositoryCache = new Map();
|
|
24
|
+
const repositoryInFlight = new Map();
|
|
25
|
+
return {
|
|
26
|
+
async getWarnings(options = {}) {
|
|
27
|
+
if (!cached || options.refresh) {
|
|
28
|
+
return runOnce();
|
|
29
|
+
}
|
|
30
|
+
const ttl = cached.warnings.length > 0 ? FAILED_CHECK_TTL_MS : CHECK_CACHE_TTL_MS;
|
|
31
|
+
if (now() - cached.checkedAt >= ttl) {
|
|
32
|
+
void runOnce();
|
|
33
|
+
}
|
|
34
|
+
return cached.warnings;
|
|
35
|
+
},
|
|
36
|
+
async getRepositoryWarnings(repoRoot, options = {}) {
|
|
37
|
+
const current = repositoryCache.get(repoRoot);
|
|
38
|
+
if (!current || options.refresh) {
|
|
39
|
+
return runRepositoryOnce(repoRoot);
|
|
40
|
+
}
|
|
41
|
+
const ttl = current.warnings.length > 0 ? FAILED_CHECK_TTL_MS : CHECK_CACHE_TTL_MS;
|
|
42
|
+
if (now() - current.checkedAt >= ttl) {
|
|
43
|
+
void runRepositoryOnce(repoRoot);
|
|
44
|
+
}
|
|
45
|
+
return current.warnings;
|
|
46
|
+
},
|
|
47
|
+
probeHookDelivery,
|
|
48
|
+
receiveHookProbe(probeToken) {
|
|
49
|
+
if (typeof probeToken !== "string") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const resolve = pendingProbes.get(probeToken);
|
|
53
|
+
if (!resolve) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
pendingProbes.delete(probeToken);
|
|
57
|
+
resolve();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
function runOnce() {
|
|
62
|
+
if (inFlight) {
|
|
63
|
+
return inFlight;
|
|
64
|
+
}
|
|
65
|
+
inFlight = (async () => {
|
|
66
|
+
try {
|
|
67
|
+
const warnings = await runChecks();
|
|
68
|
+
cached = { warnings, checkedAt: now() };
|
|
69
|
+
return warnings;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
inFlight = undefined;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
return inFlight;
|
|
76
|
+
}
|
|
77
|
+
function runRepositoryOnce(repoRoot) {
|
|
78
|
+
const running = repositoryInFlight.get(repoRoot);
|
|
79
|
+
if (running) {
|
|
80
|
+
return running;
|
|
81
|
+
}
|
|
82
|
+
const check = (async () => {
|
|
83
|
+
try {
|
|
84
|
+
const warnings = await runRepositoryChecks(repoRoot);
|
|
85
|
+
repositoryCache.set(repoRoot, { warnings, checkedAt: now() });
|
|
86
|
+
return warnings;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
repositoryInFlight.delete(repoRoot);
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
repositoryInFlight.set(repoRoot, check);
|
|
93
|
+
return check;
|
|
94
|
+
}
|
|
95
|
+
async function runRepositoryChecks(repoRoot) {
|
|
96
|
+
if (!deps.git) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const access = await deps.git.checkRemoteAccess(repoRoot);
|
|
100
|
+
if (access.status !== "failed") {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
`Git cannot reach remote "${access.remote}": ${trimPeriod(access.detail)}. Creating a task and Pull fetch the upstream branch first, so both will fail. VCM runs Git without credential prompts; check the remote URL and set up credentials for it in the environment that runs VCM, such as an SSH key loaded in ssh-agent or a Git credential helper.`
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
async function runChecks() {
|
|
108
|
+
const [git, sh, node, curl, gh] = await Promise.all([checkGit(), checkSh(), checkNode(), checkCurl(), checkGh()]);
|
|
109
|
+
const warnings = [git, sh, node, curl, gh].filter((warning) => Boolean(warning));
|
|
110
|
+
// A missing program already explains a failed probe; only probe when the
|
|
111
|
+
// pipeline's programs are present, to catch PATH, proxy, or network faults.
|
|
112
|
+
if (!sh && !node && !curl) {
|
|
113
|
+
const hookWarning = await probeHookDelivery();
|
|
114
|
+
if (hookWarning) {
|
|
115
|
+
warnings.push(hookWarning);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return warnings;
|
|
119
|
+
}
|
|
120
|
+
async function checkGit() {
|
|
121
|
+
const result = await runCommand("git", ["--version"]);
|
|
122
|
+
if (result.exitCode !== 0) {
|
|
123
|
+
return `Git is not available: ${firstLine(result.stderr)}. VCM needs Git ${formatVersion(MIN_GIT_VERSION)} or newer on PATH to read repositories and create task worktrees.`;
|
|
124
|
+
}
|
|
125
|
+
const version = parseVersion(result.stdout);
|
|
126
|
+
if (!version) {
|
|
127
|
+
return `Git version could not be parsed from "${firstLine(result.stdout)}". VCM needs Git ${formatVersion(MIN_GIT_VERSION)} or newer.`;
|
|
128
|
+
}
|
|
129
|
+
if (compareVersions(version, MIN_GIT_VERSION) < 0) {
|
|
130
|
+
return `Git ${formatVersion(version)} is older than ${formatVersion(MIN_GIT_VERSION)}, the oldest version VCM supports. Upgrade Git.`;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
async function checkSh() {
|
|
135
|
+
const result = await runCommand("sh", ["-c", "exit 0"]);
|
|
136
|
+
return result.exitCode === 0
|
|
137
|
+
? undefined
|
|
138
|
+
: `sh is not available: ${firstLine(result.stderr)}. Claude Code runs every VCM role hook through sh.`;
|
|
139
|
+
}
|
|
140
|
+
async function checkNode() {
|
|
141
|
+
const result = await runCommand("node", ["--version"]);
|
|
142
|
+
if (result.exitCode !== 0) {
|
|
143
|
+
return `node is not available on the PATH role sessions inherit: ${firstLine(result.stderr)}. VCM role hooks, Bash and subagent guards, and .ai/tools scripts run with node; add the Node.js bin directory to PATH before starting VCM.`;
|
|
144
|
+
}
|
|
145
|
+
const version = parseVersion(result.stdout);
|
|
146
|
+
if (version && version[0] < MIN_NODE_MAJOR) {
|
|
147
|
+
return `The node on PATH is ${formatVersion(version)}, but VCM role hooks and .ai/tools scripts need Node.js ${MIN_NODE_MAJOR} or newer. Put a newer Node.js first on PATH before starting VCM.`;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
async function checkCurl() {
|
|
152
|
+
const result = await runCommand("curl", ["--version"]);
|
|
153
|
+
return result.exitCode === 0
|
|
154
|
+
? undefined
|
|
155
|
+
: `curl is not available: ${firstLine(result.stderr)}. VCM role hooks use curl to report Claude Code events; without it VCM cannot see role turns start or finish. Install curl.`;
|
|
156
|
+
}
|
|
157
|
+
// VCM never runs gh, so a missing gh is not a problem; an installed but
|
|
158
|
+
// logged-out gh breaks roles that use it for pull requests or issues.
|
|
159
|
+
async function checkGh() {
|
|
160
|
+
const version = await runCommand("gh", ["--version"]);
|
|
161
|
+
if (version.exitCode !== 0) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const status = await deps.runner.run("gh", ["auth", "status"], {
|
|
165
|
+
env: { ...baseEnv, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
|
|
166
|
+
timeoutMs: COMMAND_TIMEOUT_MS
|
|
167
|
+
});
|
|
168
|
+
if (status.exitCode === 0) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const detail = firstLine(status.stderr) || firstLine(status.stdout) || `gh auth status exited with ${status.exitCode}`;
|
|
172
|
+
return `GitHub CLI (gh) is installed but not logged in: ${detail}. VCM does not run gh itself, but roles that use gh for pull requests or issues will fail. Run gh auth login in the environment that runs VCM.`;
|
|
173
|
+
}
|
|
174
|
+
async function probeHookDelivery() {
|
|
175
|
+
if (!deps.apiUrl) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const probeToken = randomUUID();
|
|
179
|
+
let received = false;
|
|
180
|
+
const arrived = new Promise((resolve) => {
|
|
181
|
+
pendingProbes.set(probeToken, () => {
|
|
182
|
+
received = true;
|
|
183
|
+
resolve();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const run = deps.runner.run("sh", ["-c", renderVcmHookProbeCommand()], {
|
|
188
|
+
env: {
|
|
189
|
+
...baseEnv,
|
|
190
|
+
...buildVcmNoProxyEnvironment(deps.apiUrl, {}, baseEnv),
|
|
191
|
+
VCM_API_URL: deps.apiUrl,
|
|
192
|
+
VCM_TASK_SLUG: HOOK_PROBE_SCOPE,
|
|
193
|
+
VCM_ROLE: HOOK_PROBE_ROLE,
|
|
194
|
+
VCM_RUNTIME_SESSION_TOKEN: probeToken
|
|
195
|
+
},
|
|
196
|
+
input: "{}",
|
|
197
|
+
timeoutMs: hookProbeTimeoutMs
|
|
198
|
+
});
|
|
199
|
+
// The endpoint records the probe before curl exits, so the command's
|
|
200
|
+
// completion bounds the wait.
|
|
201
|
+
const result = await Promise.race([run, arrived.then(() => undefined)]);
|
|
202
|
+
if (received) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const detail = firstLine(result?.stderr ?? "") || "the request did not arrive";
|
|
206
|
+
return `VCM role hooks cannot reach VCM at ${deps.apiUrl}: ${detail}. Role sessions will not report turns to VCM, so dispatches stay unconfirmed. Check that sh, node, and curl are on PATH and that any HTTP proxy excludes ${readHostname(deps.apiUrl)} through NO_PROXY.`;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
pendingProbes.delete(probeToken);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function runCommand(command, args) {
|
|
213
|
+
return deps.runner.run(command, args, { env: baseEnv, timeoutMs: COMMAND_TIMEOUT_MS });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function parseVersion(output) {
|
|
217
|
+
const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(output);
|
|
218
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] : undefined;
|
|
219
|
+
}
|
|
220
|
+
function compareVersions(left, right) {
|
|
221
|
+
for (let index = 0; index < 3; index += 1) {
|
|
222
|
+
if (left[index] !== right[index]) {
|
|
223
|
+
return left[index] - right[index];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
function formatVersion(version) {
|
|
229
|
+
return version.join(".");
|
|
230
|
+
}
|
|
231
|
+
// Error details end the sentence they are quoted in.
|
|
232
|
+
function firstLine(value) {
|
|
233
|
+
return trimPeriod(value.trim().split(/\r?\n/)[0]?.trim() ?? "");
|
|
234
|
+
}
|
|
235
|
+
function trimPeriod(value) {
|
|
236
|
+
return value.replace(/\.+$/, "");
|
|
237
|
+
}
|
|
238
|
+
function readHostname(url) {
|
|
239
|
+
try {
|
|
240
|
+
return new URL(url).hostname;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return url;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -15,6 +15,9 @@ const GATE_REVIEW_VERSION = 1;
|
|
|
15
15
|
const HARNESS_COMMIT_PREFIX = "[VCM Harness] ";
|
|
16
16
|
const REVIEWER_ROLE = "reviewer";
|
|
17
17
|
const DEFAULT_REPORT_POLL_INTERVAL_MS = 1000;
|
|
18
|
+
// A Reviewer that just submitted a report usually ends its turn within
|
|
19
|
+
// seconds; a missed Stop hook leaves the activity stale, so the wait is capped.
|
|
20
|
+
const DEFAULT_REVIEWER_IDLE_WAIT_MS = 5 * 60 * 1000;
|
|
18
21
|
const activeRuns = new Map();
|
|
19
22
|
const gateStateLocks = new Map();
|
|
20
23
|
const SOURCE_ARTIFACTS = {
|
|
@@ -54,6 +57,7 @@ const VALID_CODE_DIFF_FINDING_SCOPES = new Set(CODE_DIFF_FINDING_SCOPES);
|
|
|
54
57
|
export function createGateReviewService(deps) {
|
|
55
58
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
56
59
|
const reportPollIntervalMs = deps.reportPollIntervalMs ?? DEFAULT_REPORT_POLL_INTERVAL_MS;
|
|
60
|
+
const reviewerIdleWaitMs = deps.reviewerIdleWaitMs ?? DEFAULT_REVIEWER_IDLE_WAIT_MS;
|
|
57
61
|
async function getContext(repoRoot, taskSlug) {
|
|
58
62
|
const projectConfig = await deps.projectService.loadConfig(repoRoot);
|
|
59
63
|
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
@@ -529,6 +533,7 @@ export function createGateReviewService(deps) {
|
|
|
529
533
|
});
|
|
530
534
|
}
|
|
531
535
|
const session = await ensureReviewerSession(context);
|
|
536
|
+
await waitForReviewerIdle(context, controller.signal);
|
|
532
537
|
throwIfGateRunCancelled(controller.signal);
|
|
533
538
|
await submitTerminalInput(deps.runtime, session.id, prompt);
|
|
534
539
|
await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, REVIEWER_ROLE, session.id);
|
|
@@ -647,6 +652,20 @@ export function createGateReviewService(deps) {
|
|
|
647
652
|
return next.gates[gate];
|
|
648
653
|
});
|
|
649
654
|
}
|
|
655
|
+
// Claude Code queues a prompt typed into a running turn but sends its
|
|
656
|
+
// UserPromptSubmit hook at once, so the running turn's Stop would look like
|
|
657
|
+
// the end of this request's turn and fail it. Wait for that turn to end.
|
|
658
|
+
async function waitForReviewerIdle(context, signal) {
|
|
659
|
+
const deadline = Date.now() + reviewerIdleWaitMs;
|
|
660
|
+
while (true) {
|
|
661
|
+
throwIfGateRunCancelled(signal);
|
|
662
|
+
const session = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, REVIEWER_ROLE);
|
|
663
|
+
if (session?.activityStatus !== "running" || Date.now() >= deadline) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
await delay(reportPollIntervalMs);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
650
669
|
async function ensureReviewerSession(context) {
|
|
651
670
|
const existing = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, REVIEWER_ROLE);
|
|
652
671
|
if (existing?.status === "running" && deps.runtime.getSession(existing.id)) {
|
|
@@ -53,6 +53,17 @@ const LEGACY_CODEX_HARNESS_PATHS = [
|
|
|
53
53
|
".ai/tools/request-codex-review"
|
|
54
54
|
];
|
|
55
55
|
const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
|
|
56
|
+
// The hook self-test runs the installed hook pipeline unchanged except for its
|
|
57
|
+
// endpoint, so a passing probe proves sh, node, curl, PATH, and proxy settings
|
|
58
|
+
// let role hooks reach VCM.
|
|
59
|
+
export const VCM_HOOK_PROBE_PATH = "/api/hooks/probe";
|
|
60
|
+
export function renderVcmHookProbeCommand() {
|
|
61
|
+
const endpoint = '"${VCM_API_URL}/api/hooks/claude-code"';
|
|
62
|
+
if (!VCM_HOOK_COMMAND.includes(endpoint)) {
|
|
63
|
+
throw new Error("VCM hook command no longer posts to /api/hooks/claude-code.");
|
|
64
|
+
}
|
|
65
|
+
return VCM_HOOK_COMMAND.replace(endpoint, () => `"\${VCM_API_URL}${VCM_HOOK_PROBE_PATH}"`);
|
|
66
|
+
}
|
|
56
67
|
const VCM_STOP_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || true'`;
|
|
57
68
|
const VCM_PERMISSION_REQUEST_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --max-time 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/permission-request" -H "content-type: application/json" --data-binary @- || true'`;
|
|
58
69
|
const VCM_BASH_GUARD_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ]; then exit 0; fi; guard=""; repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"; if [ -n "$repo" ] && [ -f "$repo/.ai/tools/vcm-bash-guard" ]; then guard="$repo/.ai/tools/vcm-bash-guard"; else cwd="$(pwd -P 2>/dev/null || pwd)"; dir="$cwd"; while [ -n "$dir" ] && [ "$dir" != "/" ]; do if [ -f "$dir/.ai/tools/vcm-bash-guard" ]; then guard="$dir/.ai/tools/vcm-bash-guard"; break; fi; dir="$(dirname "$dir")"; done; if [ -z "$guard" ] && [ -n "\${CLAUDE_PROJECT_DIR:-}" ] && [ -f "\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard" ]; then guard="\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard"; fi; fi; [ -n "$guard" ] || exit 0; node "$guard" || exit 0'`;
|