vibe-coding-master 0.2.4 → 0.2.6
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 +11 -3
- package/dist/backend/adapters/claude-adapter.js +9 -2
- package/dist/backend/adapters/git-adapter.js +60 -0
- package/dist/backend/api/claude-hook-routes.js +7 -0
- package/dist/backend/api/project-routes.js +3 -0
- package/dist/backend/server.js +2 -1
- package/dist/backend/services/app-settings-service.js +49 -1
- package/dist/backend/services/claude-hook-service.js +61 -11
- package/dist/backend/services/harness-service.js +6 -5
- package/dist/backend/services/project-service.js +104 -28
- package/dist/backend/services/round-service.js +40 -7
- package/dist/backend/services/session-service.js +16 -2
- package/dist/shared/types/app-settings.js +17 -0
- package/dist/shared/types/session.js +37 -1
- package/dist-frontend/assets/index-CPXFnxAY.css +32 -0
- package/dist-frontend/assets/index-D0_02lmQ.js +90 -0
- package/dist-frontend/index.html +2 -2
- package/docs/gateway-design.md +585 -0
- package/docs/product-design.md +72 -14
- package/docs/v0.2-implementation-plan.md +78 -68
- package/docs/vcm-cc-best-practices.md +18 -4
- package/package.json +1 -1
- package/scripts/install-vcm-harness.mjs +3 -2
- package/dist-frontend/assets/index-CfZ3VYXY.js +0 -90
- package/dist-frontend/assets/index-CfqduxVB.css +0 -32
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
|
|
|
16
16
|
- GUI-first task workspace.
|
|
17
17
|
- Collapsible sidebar with repository connection, settings, harness status, task creation, and task list.
|
|
18
18
|
- Recent repository path dropdown, stored locally with the five most recent paths.
|
|
19
|
+
- Connected repository status for the base repo, including branch, upstream status, commit hash, dirty state, and fast-forward-only pull.
|
|
19
20
|
- Embedded Claude Code terminals powered by `node-pty` and `xterm.js`.
|
|
20
21
|
- One Claude Code session per role, with role tabs in the task header.
|
|
21
22
|
- Role session recovery through persisted Claude session ids and `claude --resume`.
|
|
@@ -186,7 +187,7 @@ When a task is complete, VCM provides a red `Close Task` action. Closing a task
|
|
|
186
187
|
The left sidebar is intentionally compact and collapsible:
|
|
187
188
|
|
|
188
189
|
- `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
|
|
189
|
-
- `Repository`: connected path, branch,
|
|
190
|
+
- `Connected Repository`: connected base repo path, branch, upstream/ahead-behind status, commit hash, working tree state, and a `Pull` button.
|
|
190
191
|
- `Settings`: `Theme`, `Flow pause alert`, `Try alert`, `Messages`, and `Events`.
|
|
191
192
|
- `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
|
|
192
193
|
- `New Task`: one `task name` input.
|
|
@@ -194,6 +195,13 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
194
195
|
|
|
195
196
|
All sidebar sections are collapsed by default. When no task is selected, `Repository Path` opens by default.
|
|
196
197
|
|
|
198
|
+
Opening `Connected Repository` refreshes the base repo status through the
|
|
199
|
+
backend. VCM does not poll it continuously. The `Pull` button runs
|
|
200
|
+
`git pull --ff-only` against the connected base repo only. It is disabled when
|
|
201
|
+
the base repo has uncommitted changes, when the current branch has no upstream,
|
|
202
|
+
or when the active task is an inline task using the base repo directly. It does
|
|
203
|
+
not stash, merge, or mutate task worktrees.
|
|
204
|
+
|
|
197
205
|
When VCM is connected to an active task, the bottom of the sidebar shows a task status dock. It stays outside the collapsible groups and shows the task title, task status, start time, total elapsed time, total flow-round count, and Claude Code active runtime. If a flow round is currently active or settling, the dock also shows that round's start time and Claude Code active runtime.
|
|
198
206
|
|
|
199
207
|
## Translation
|
|
@@ -212,7 +220,7 @@ The same file stores recent repository paths. The translation API key is stored
|
|
|
212
220
|
|
|
213
221
|
The sidebar `Settings` section also stores the UI theme preference in this file. The default is `system`, which follows the OS/browser color-scheme preference; users can cycle between `System`, `Light`, and `Dark`.
|
|
214
222
|
|
|
215
|
-
The same sidebar also has a `Flow pause alert` toggle. It is on by default and controls the local alert that fires when VCM detects that the current role flow has stopped advancing. Short flows use a weak reminder: the soft two-note chime plays 3 times, 1.4 seconds apart. Flows lasting
|
|
223
|
+
The same sidebar also has a `Flow pause alert` toggle. It is on by default and controls the local alert that fires when VCM detects that the current role flow has stopped advancing. Short flows use a weak reminder: the soft two-note chime plays 3 times, 1.4 seconds apart. Flows lasting 2 minutes or longer use a strong reminder: VCM shows an alert dialog and repeats the chime until the user confirms it. The alert sound reuses one browser audio context so repeated reminders remain reliable in stricter browsers such as Safari. Safari users may still need to manually set `Safari > Website Settings > Auto-Play > Allow All Auto-Play`; Chrome is recommended for the most reliable alert sound behavior. The `Try alert` button always triggers the strong reminder for testing.
|
|
216
224
|
|
|
217
225
|
Translation behavior:
|
|
218
226
|
|
|
@@ -384,7 +392,7 @@ When a `Stop` hook fires, VCM marks the round as settling for 10 seconds. If ano
|
|
|
384
392
|
|
|
385
393
|
The normal path is timer-driven: `Stop` starts a backend settle timer, and `UserPromptSubmit` cancels it. Reading the round state also checks expired deadlines as a recovery fallback after process restarts, sleep, or missed timers.
|
|
386
394
|
|
|
387
|
-
When `Flow pause alert` is enabled, the frontend polls the task round state and deduplicates each paused round so the same paused state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `pausedAt`, falling back to the last `Stop` when needed. If the paused flow lasted less than
|
|
395
|
+
When `Flow pause alert` is enabled, the frontend polls the task round state and deduplicates each paused round so the same paused state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `pausedAt`, falling back to the last `Stop` when needed. If the paused flow lasted less than 2 minutes, it plays the local chime 3 times at 1.4 second intervals. If the paused flow lasted 2 minutes or longer, it shows a modal `Flow paused` alert and repeats the local chime until the user clicks `Confirm`. A pause can mean normal completion, user decision needed, dispatch failure, or another workflow interruption; the point is to get the user to look with the right amount of urgency.
|
|
388
396
|
|
|
389
397
|
## Resume Behavior
|
|
390
398
|
|
|
@@ -17,19 +17,26 @@ export function createClaudeAdapter(runner) {
|
|
|
17
17
|
}
|
|
18
18
|
return result.stdout.trim();
|
|
19
19
|
},
|
|
20
|
-
buildRoleStartCommand(role, command = "claude", permissionMode = "default", claudeSessionId, resume = false) {
|
|
20
|
+
buildRoleStartCommand(role, command = "claude", permissionMode = "default", claudeSessionId, resume = false, model = "default") {
|
|
21
21
|
const args = ["--agent", role];
|
|
22
22
|
if (claudeSessionId) {
|
|
23
23
|
args.push(resume ? "--resume" : "--session-id", claudeSessionId);
|
|
24
24
|
}
|
|
25
|
+
args.push("--model", model);
|
|
25
26
|
if (permissionMode === "bypassPermissions") {
|
|
26
27
|
args.push("--permission-mode", "bypassPermissions");
|
|
27
28
|
}
|
|
28
29
|
return {
|
|
29
30
|
command,
|
|
30
31
|
args,
|
|
31
|
-
display:
|
|
32
|
+
display: [command, ...args].map(formatDisplayArg).join(" ")
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
};
|
|
35
36
|
}
|
|
37
|
+
function formatDisplayArg(value) {
|
|
38
|
+
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
42
|
+
}
|
|
@@ -21,6 +21,51 @@ export function createGitAdapter(runner) {
|
|
|
21
21
|
}
|
|
22
22
|
return result.stdout.trim() || "detached";
|
|
23
23
|
},
|
|
24
|
+
async getHeadCommit(repoRoot) {
|
|
25
|
+
const result = await runGit(runner, repoRoot, ["rev-parse", "HEAD"]);
|
|
26
|
+
if (result.exitCode !== 0) {
|
|
27
|
+
throw new VcmError({
|
|
28
|
+
code: "GIT_ERROR",
|
|
29
|
+
message: "Unable to read current Git commit.",
|
|
30
|
+
statusCode: 400,
|
|
31
|
+
hint: result.stderr
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return result.stdout.trim();
|
|
35
|
+
},
|
|
36
|
+
async getUpstreamBranch(repoRoot) {
|
|
37
|
+
const result = await runGit(runner, repoRoot, [
|
|
38
|
+
"rev-parse",
|
|
39
|
+
"--abbrev-ref",
|
|
40
|
+
"--symbolic-full-name",
|
|
41
|
+
"@{u}"
|
|
42
|
+
]);
|
|
43
|
+
if (result.exitCode !== 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return result.stdout.trim() || null;
|
|
47
|
+
},
|
|
48
|
+
async getAheadBehind(repoRoot, upstreamBranch) {
|
|
49
|
+
const result = await runGit(runner, repoRoot, [
|
|
50
|
+
"rev-list",
|
|
51
|
+
"--left-right",
|
|
52
|
+
"--count",
|
|
53
|
+
`HEAD...${upstreamBranch}`
|
|
54
|
+
]);
|
|
55
|
+
if (result.exitCode !== 0) {
|
|
56
|
+
throw new VcmError({
|
|
57
|
+
code: "GIT_ERROR",
|
|
58
|
+
message: "Unable to read Git ahead/behind status.",
|
|
59
|
+
statusCode: 400,
|
|
60
|
+
hint: result.stderr
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const [aheadValue, behindValue] = result.stdout.trim().split(/\s+/).map((value) => Number.parseInt(value, 10));
|
|
64
|
+
return {
|
|
65
|
+
ahead: Number.isFinite(aheadValue) ? aheadValue : 0,
|
|
66
|
+
behind: Number.isFinite(behindValue) ? behindValue : 0
|
|
67
|
+
};
|
|
68
|
+
},
|
|
24
69
|
async isDirty(repoRoot) {
|
|
25
70
|
return (await this.getStatusPorcelain(repoRoot)).trim().length > 0;
|
|
26
71
|
},
|
|
@@ -110,6 +155,21 @@ export function createGitAdapter(runner) {
|
|
|
110
155
|
hint: result.stderr
|
|
111
156
|
});
|
|
112
157
|
}
|
|
158
|
+
},
|
|
159
|
+
async pullFastForward(repoRoot) {
|
|
160
|
+
const result = await runGit(runner, repoRoot, ["pull", "--ff-only"]);
|
|
161
|
+
if (result.exitCode !== 0) {
|
|
162
|
+
throw new VcmError({
|
|
163
|
+
code: "GIT_PULL_FAILED",
|
|
164
|
+
message: "Unable to pull connected repository with fast-forward only.",
|
|
165
|
+
statusCode: 409,
|
|
166
|
+
hint: result.stderr || result.stdout
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
stdout: result.stdout,
|
|
171
|
+
stderr: result.stderr
|
|
172
|
+
};
|
|
113
173
|
}
|
|
114
174
|
};
|
|
115
175
|
}
|
|
@@ -5,4 +5,11 @@ export function registerClaudeHookRoutes(app, deps) {
|
|
|
5
5
|
app.post("/api/hooks/claude-code/stop", async (request) => {
|
|
6
6
|
return deps.claudeHookService.handleStopHook(request.body);
|
|
7
7
|
});
|
|
8
|
+
app.post("/api/hooks/claude-code/permission-request", async (request, reply) => {
|
|
9
|
+
const result = await deps.claudeHookService.handlePermissionRequestHook(request.body);
|
|
10
|
+
if (!result) {
|
|
11
|
+
return reply.code(204).send();
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
});
|
|
8
15
|
}
|
|
@@ -9,4 +9,7 @@ export function registerProjectRoutes(app, deps) {
|
|
|
9
9
|
app.get("/api/projects/current", async () => {
|
|
10
10
|
return deps.projectService.getCurrentProject();
|
|
11
11
|
});
|
|
12
|
+
app.post("/api/projects/current/pull", async () => {
|
|
13
|
+
return deps.projectService.pullCurrentProject();
|
|
14
|
+
});
|
|
12
15
|
}
|
package/dist/backend/server.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { ROLE_NAMES } from "../../shared/constants.js";
|
|
5
|
+
import { createDefaultLaunchTemplate } from "../../shared/types/app-settings.js";
|
|
6
|
+
import { CLAUDE_MODEL_OPTIONS } from "../../shared/types/session.js";
|
|
4
7
|
const MAX_RECENT_REPOSITORIES = 5;
|
|
5
8
|
export function createAppSettingsService(deps) {
|
|
6
9
|
const settingsPath = deps.settingsPath ?? path.join(homedir(), ".vcm", "settings.json");
|
|
@@ -184,7 +187,9 @@ function normalizePreferences(input) {
|
|
|
184
187
|
: candidate.roundCompletionAlerts;
|
|
185
188
|
return {
|
|
186
189
|
themeMode: normalizeThemeMode(candidate.themeMode),
|
|
187
|
-
flowPauseAlerts: rawFlowPauseAlerts !== false
|
|
190
|
+
flowPauseAlerts: rawFlowPauseAlerts !== false,
|
|
191
|
+
permissionRequestMode: normalizePermissionRequestMode(candidate.permissionRequestMode),
|
|
192
|
+
launchTemplate: normalizeLaunchTemplate(candidate.launchTemplate)
|
|
188
193
|
};
|
|
189
194
|
}
|
|
190
195
|
function normalizeThemeMode(input) {
|
|
@@ -193,6 +198,49 @@ function normalizeThemeMode(input) {
|
|
|
193
198
|
}
|
|
194
199
|
return "system";
|
|
195
200
|
}
|
|
201
|
+
function normalizePermissionRequestMode(input) {
|
|
202
|
+
if (input === "allowAll") {
|
|
203
|
+
return input;
|
|
204
|
+
}
|
|
205
|
+
return "off";
|
|
206
|
+
}
|
|
207
|
+
function normalizeLaunchTemplate(input) {
|
|
208
|
+
const defaults = createDefaultLaunchTemplate();
|
|
209
|
+
if (!isObject(input)) {
|
|
210
|
+
return defaults;
|
|
211
|
+
}
|
|
212
|
+
const rawRoles = isObject(input.roles) ? input.roles : {};
|
|
213
|
+
const roles = {};
|
|
214
|
+
for (const role of ROLE_NAMES) {
|
|
215
|
+
roles[role] = normalizeRoleLaunchTemplateEntry(rawRoles[role], defaults.roles[role]);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
version: 1,
|
|
219
|
+
roles,
|
|
220
|
+
autoOrchestration: input.autoOrchestration !== false,
|
|
221
|
+
translationEnabled: input.translationEnabled !== false
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function normalizeRoleLaunchTemplateEntry(input, fallback) {
|
|
225
|
+
const candidate = isObject(input) ? input : {};
|
|
226
|
+
return {
|
|
227
|
+
permissionMode: normalizeClaudePermissionMode(candidate.permissionMode, fallback.permissionMode),
|
|
228
|
+
model: normalizeClaudeModel(candidate.model, fallback.model)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function normalizeClaudePermissionMode(input, fallback) {
|
|
232
|
+
if (input === "bypassPermissions" || input === "default") {
|
|
233
|
+
return input;
|
|
234
|
+
}
|
|
235
|
+
return fallback;
|
|
236
|
+
}
|
|
237
|
+
function normalizeClaudeModel(input, fallback) {
|
|
238
|
+
if (typeof input !== "string") {
|
|
239
|
+
return fallback;
|
|
240
|
+
}
|
|
241
|
+
const model = CLAUDE_MODEL_OPTIONS.find((option) => option.value === input);
|
|
242
|
+
return model?.value ?? fallback;
|
|
243
|
+
}
|
|
196
244
|
function normalizeTranslationConfig(input) {
|
|
197
245
|
if (!input || typeof input !== "object") {
|
|
198
246
|
return undefined;
|
|
@@ -86,6 +86,23 @@ export function createClaudeHookService(deps) {
|
|
|
86
86
|
throwUnsupportedEvent(eventName);
|
|
87
87
|
}
|
|
88
88
|
const context = await getHookContext(input);
|
|
89
|
+
const scopedRouteDispatchInput = {
|
|
90
|
+
repoRoot: context.project.repoRoot,
|
|
91
|
+
taskRepoRoot: context.taskRepoRoot,
|
|
92
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
93
|
+
stateRoot: context.config.stateRoot,
|
|
94
|
+
handoffDir: context.task.handoffDir,
|
|
95
|
+
taskSlug: input.taskSlug,
|
|
96
|
+
stoppedRole: input.role
|
|
97
|
+
};
|
|
98
|
+
const settleRouteDispatchInput = {
|
|
99
|
+
repoRoot: context.project.repoRoot,
|
|
100
|
+
taskRepoRoot: context.taskRepoRoot,
|
|
101
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
102
|
+
stateRoot: context.config.stateRoot,
|
|
103
|
+
handoffDir: context.task.handoffDir,
|
|
104
|
+
taskSlug: input.taskSlug
|
|
105
|
+
};
|
|
89
106
|
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
90
107
|
taskSlug: input.taskSlug,
|
|
91
108
|
role: input.role,
|
|
@@ -99,7 +116,17 @@ export function createClaudeHookService(deps) {
|
|
|
99
116
|
stateRoot: context.config.stateRoot,
|
|
100
117
|
taskSlug: input.taskSlug,
|
|
101
118
|
role: input.role,
|
|
102
|
-
eventName
|
|
119
|
+
eventName,
|
|
120
|
+
settleGuard: async () => {
|
|
121
|
+
const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
|
|
122
|
+
if (pending.length === 0) {
|
|
123
|
+
return { action: "pause" };
|
|
124
|
+
}
|
|
125
|
+
const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
|
|
126
|
+
return retried.some((result) => result.delivered)
|
|
127
|
+
? { action: "continue", reason: "pending route message dispatched" }
|
|
128
|
+
: { action: "pause" };
|
|
129
|
+
}
|
|
103
130
|
});
|
|
104
131
|
if (session) {
|
|
105
132
|
await deps.translationService.recordConversationBoundary({
|
|
@@ -112,15 +139,7 @@ export function createClaudeHookService(deps) {
|
|
|
112
139
|
occurredAt: session.lastStopAt ?? session.updatedAt
|
|
113
140
|
});
|
|
114
141
|
}
|
|
115
|
-
const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles(
|
|
116
|
-
repoRoot: context.project.repoRoot,
|
|
117
|
-
taskRepoRoot: context.taskRepoRoot,
|
|
118
|
-
stateRepoRoot: context.taskRepoRoot,
|
|
119
|
-
stateRoot: context.config.stateRoot,
|
|
120
|
-
handoffDir: context.task.handoffDir,
|
|
121
|
-
taskSlug: input.taskSlug,
|
|
122
|
-
stoppedRole: input.role
|
|
123
|
-
});
|
|
142
|
+
const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput);
|
|
124
143
|
return {
|
|
125
144
|
ok: true,
|
|
126
145
|
eventName,
|
|
@@ -130,6 +149,36 @@ export function createClaudeHookService(deps) {
|
|
|
130
149
|
dispatchedCount: dispatched.filter((result) => result.delivered).length
|
|
131
150
|
};
|
|
132
151
|
}
|
|
152
|
+
async function handlePermissionRequestHook(input) {
|
|
153
|
+
if (!isRoleName(input.role)) {
|
|
154
|
+
throw new VcmError({
|
|
155
|
+
code: "HOOK_ROLE_INVALID",
|
|
156
|
+
message: `Unknown hook role: ${input.role}`,
|
|
157
|
+
statusCode: 400
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const eventName = input.event.hook_event_name;
|
|
161
|
+
if (eventName !== "PermissionRequest") {
|
|
162
|
+
throw new VcmError({
|
|
163
|
+
code: "HOOK_EVENT_UNSUPPORTED",
|
|
164
|
+
message: `Unsupported Claude Code permission hook event: ${String(eventName)}`,
|
|
165
|
+
statusCode: 400,
|
|
166
|
+
hint: "Use this endpoint for Claude Code PermissionRequest hooks only."
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const preferences = await deps.appSettings.getPreferences();
|
|
170
|
+
if (preferences.permissionRequestMode !== "allowAll") {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
hookSpecificOutput: {
|
|
175
|
+
hookEventName: "PermissionRequest",
|
|
176
|
+
decision: {
|
|
177
|
+
behavior: "allow"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
133
182
|
return {
|
|
134
183
|
async handleHook(input) {
|
|
135
184
|
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
@@ -138,7 +187,8 @@ export function createClaudeHookService(deps) {
|
|
|
138
187
|
}
|
|
139
188
|
return handleStopHook(input);
|
|
140
189
|
},
|
|
141
|
-
handleStopHook
|
|
190
|
+
handleStopHook,
|
|
191
|
+
handlePermissionRequestHook
|
|
142
192
|
};
|
|
143
193
|
}
|
|
144
194
|
function parseHookEvent(value) {
|
|
@@ -26,7 +26,8 @@ const MANAGED_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=(\d+))? -->[\s\S]*?<!
|
|
|
26
26
|
const HASH_MANAGED_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=(\d+))?\n[\s\S]*?# VCM:END/m;
|
|
27
27
|
const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
|
|
28
28
|
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,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
|
|
29
|
-
const
|
|
29
|
+
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,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'`;
|
|
30
|
+
const VCM_HOOK_EVENTS = ["UserPromptSubmit", "Stop", "PermissionRequest"];
|
|
30
31
|
const HARNESS_FILES = [
|
|
31
32
|
{
|
|
32
33
|
kind: "root-claude",
|
|
@@ -397,7 +398,7 @@ async function analyzeClaudeSettingsFile(fs, repoRoot) {
|
|
|
397
398
|
path: CLAUDE_SETTINGS_PATH,
|
|
398
399
|
action,
|
|
399
400
|
reason: exists
|
|
400
|
-
? "Claude Code hook settings do not contain the VCM
|
|
401
|
+
? "Claude Code hook settings do not contain the VCM hook bridge."
|
|
401
402
|
: "Claude Code hook settings are missing; VCM will create them."
|
|
402
403
|
},
|
|
403
404
|
nextContent: action === "ok" ? undefined : nextContent
|
|
@@ -432,7 +433,7 @@ function withVcmClaudeHooks(settings) {
|
|
|
432
433
|
: [];
|
|
433
434
|
hooks[eventName] = [
|
|
434
435
|
...existingMatchers.filter((entry) => !isVcmHookMatcher(entry)),
|
|
435
|
-
createVcmHookMatcher()
|
|
436
|
+
createVcmHookMatcher(eventName)
|
|
436
437
|
];
|
|
437
438
|
}
|
|
438
439
|
return {
|
|
@@ -440,12 +441,12 @@ function withVcmClaudeHooks(settings) {
|
|
|
440
441
|
hooks
|
|
441
442
|
};
|
|
442
443
|
}
|
|
443
|
-
function createVcmHookMatcher() {
|
|
444
|
+
function createVcmHookMatcher(eventName) {
|
|
444
445
|
return {
|
|
445
446
|
hooks: [
|
|
446
447
|
{
|
|
447
448
|
type: "command",
|
|
448
|
-
command: VCM_HOOK_COMMAND,
|
|
449
|
+
command: eventName === "PermissionRequest" ? VCM_PERMISSION_REQUEST_HOOK_COMMAND : VCM_HOOK_COMMAND,
|
|
449
450
|
timeout: 5
|
|
450
451
|
}
|
|
451
452
|
]
|
|
@@ -33,38 +33,43 @@ export function createProjectService(deps) {
|
|
|
33
33
|
await deps.fs.ensureDir(path.join(this.getProjectDataRoot(repoRoot), "tasks"));
|
|
34
34
|
await deps.fs.ensureDir(path.join(repoRoot, ".claude", "worktrees"));
|
|
35
35
|
await this.saveConfig(config, true);
|
|
36
|
-
|
|
37
|
-
let branch = "unknown";
|
|
38
|
-
let isDirty = false;
|
|
39
|
-
try {
|
|
40
|
-
branch = await deps.git.getCurrentBranch(repoRoot);
|
|
41
|
-
}
|
|
42
|
-
catch (caught) {
|
|
43
|
-
warnings.push(`Unable to read current Git branch. ${getErrorHint(caught)}`);
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
isDirty = await deps.git.isDirty(repoRoot);
|
|
47
|
-
}
|
|
48
|
-
catch (caught) {
|
|
49
|
-
warnings.push(`Unable to read Git dirty status. ${getErrorHint(caught)}`);
|
|
50
|
-
}
|
|
51
|
-
if (branch === "main" || branch === "master") {
|
|
52
|
-
warnings.push(`You are on ${branch}. Consider creating a task branch before coding.`);
|
|
53
|
-
}
|
|
54
|
-
if (!(await deps.claude.isAvailable(config.claudeCommand))) {
|
|
55
|
-
warnings.push("Claude Code command is not available. You can still inspect artifacts, but sessions will not start.");
|
|
56
|
-
}
|
|
57
|
-
currentProject = {
|
|
58
|
-
repoRoot,
|
|
59
|
-
branch,
|
|
60
|
-
isDirty,
|
|
61
|
-
config,
|
|
62
|
-
warnings
|
|
63
|
-
};
|
|
36
|
+
currentProject = await readConnectedProjectSummary(repoRoot, config);
|
|
64
37
|
await deps.appSettings.recordRecentRepositoryPath(repoRoot);
|
|
65
38
|
return currentProject;
|
|
66
39
|
},
|
|
67
40
|
async getCurrentProject() {
|
|
41
|
+
if (currentProject) {
|
|
42
|
+
currentProject = await readConnectedProjectSummary(currentProject.repoRoot, currentProject.config);
|
|
43
|
+
}
|
|
44
|
+
return currentProject;
|
|
45
|
+
},
|
|
46
|
+
async pullCurrentProject() {
|
|
47
|
+
if (!currentProject) {
|
|
48
|
+
throw new VcmError({
|
|
49
|
+
code: "NO_PROJECT",
|
|
50
|
+
message: "No connected repository.",
|
|
51
|
+
statusCode: 409
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const beforePull = await readConnectedProjectSummary(currentProject.repoRoot, currentProject.config);
|
|
55
|
+
if (beforePull.isDirty) {
|
|
56
|
+
throw new VcmError({
|
|
57
|
+
code: "BASE_REPO_DIRTY",
|
|
58
|
+
message: "The connected repository has uncommitted changes.",
|
|
59
|
+
statusCode: 409,
|
|
60
|
+
hint: "Commit, stash, or discard base repository changes before pulling."
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!beforePull.upstreamBranch) {
|
|
64
|
+
throw new VcmError({
|
|
65
|
+
code: "NO_UPSTREAM",
|
|
66
|
+
message: "The connected repository branch has no upstream.",
|
|
67
|
+
statusCode: 409,
|
|
68
|
+
hint: "Set an upstream branch before using Pull."
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
await deps.git.pullFastForward(currentProject.repoRoot);
|
|
72
|
+
currentProject = await readConnectedProjectSummary(currentProject.repoRoot, currentProject.config);
|
|
68
73
|
return currentProject;
|
|
69
74
|
},
|
|
70
75
|
async getRecentRepositoryPaths() {
|
|
@@ -92,6 +97,77 @@ export function createProjectService(deps) {
|
|
|
92
97
|
return path.dirname(deps.appSettings.getProjectConfigPath(repoRoot));
|
|
93
98
|
}
|
|
94
99
|
};
|
|
100
|
+
async function readConnectedProjectSummary(repoRoot, config) {
|
|
101
|
+
const warnings = [];
|
|
102
|
+
let branch = "unknown";
|
|
103
|
+
let isDirty = false;
|
|
104
|
+
let headCommit = "unknown";
|
|
105
|
+
let upstreamBranch = null;
|
|
106
|
+
let ahead = null;
|
|
107
|
+
let behind = null;
|
|
108
|
+
try {
|
|
109
|
+
branch = await deps.git.getCurrentBranch(repoRoot);
|
|
110
|
+
}
|
|
111
|
+
catch (caught) {
|
|
112
|
+
warnings.push(`Unable to read current Git branch. ${getErrorHint(caught)}`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
isDirty = await deps.git.isDirty(repoRoot);
|
|
116
|
+
}
|
|
117
|
+
catch (caught) {
|
|
118
|
+
warnings.push(`Unable to read Git dirty status. ${getErrorHint(caught)}`);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
headCommit = await deps.git.getHeadCommit(repoRoot);
|
|
122
|
+
}
|
|
123
|
+
catch (caught) {
|
|
124
|
+
warnings.push(`Unable to read current Git commit. ${getErrorHint(caught)}`);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
upstreamBranch = await deps.git.getUpstreamBranch(repoRoot);
|
|
128
|
+
}
|
|
129
|
+
catch (caught) {
|
|
130
|
+
warnings.push(`Unable to read Git upstream branch. ${getErrorHint(caught)}`);
|
|
131
|
+
}
|
|
132
|
+
if (upstreamBranch) {
|
|
133
|
+
try {
|
|
134
|
+
const status = await deps.git.getAheadBehind(repoRoot, upstreamBranch);
|
|
135
|
+
ahead = status.ahead;
|
|
136
|
+
behind = status.behind;
|
|
137
|
+
}
|
|
138
|
+
catch (caught) {
|
|
139
|
+
warnings.push(`Unable to read Git ahead/behind status. ${getErrorHint(caught)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!(await deps.claude.isAvailable(config.claudeCommand))) {
|
|
143
|
+
warnings.push("Claude Code command is not available. You can still inspect artifacts, but sessions will not start.");
|
|
144
|
+
}
|
|
145
|
+
const pullDisabledReason = getPullDisabledReason({ isDirty, upstreamBranch });
|
|
146
|
+
return {
|
|
147
|
+
repoRoot,
|
|
148
|
+
branch,
|
|
149
|
+
isDirty,
|
|
150
|
+
headCommit,
|
|
151
|
+
shortHeadCommit: headCommit === "unknown" ? "unknown" : headCommit.slice(0, 12),
|
|
152
|
+
upstreamBranch,
|
|
153
|
+
ahead,
|
|
154
|
+
behind,
|
|
155
|
+
canPull: !pullDisabledReason,
|
|
156
|
+
pullDisabledReason,
|
|
157
|
+
checkedAt: new Date().toISOString(),
|
|
158
|
+
config,
|
|
159
|
+
warnings
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function getPullDisabledReason(input) {
|
|
164
|
+
if (input.isDirty) {
|
|
165
|
+
return "Base repository has uncommitted changes.";
|
|
166
|
+
}
|
|
167
|
+
if (!input.upstreamBranch) {
|
|
168
|
+
return "Current branch has no upstream.";
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
95
171
|
}
|
|
96
172
|
function getErrorHint(caught) {
|
|
97
173
|
if (caught instanceof VcmError) {
|
|
@@ -51,7 +51,7 @@ export function createRoundService(deps) {
|
|
|
51
51
|
await save(input, next);
|
|
52
52
|
return next;
|
|
53
53
|
}
|
|
54
|
-
async function settleIfStillCurrent(input, roundId, settleDeadlineAt) {
|
|
54
|
+
async function settleIfStillCurrent(input, roundId, settleDeadlineAt, settleGuard) {
|
|
55
55
|
await withTaskLock(input, async () => {
|
|
56
56
|
const state = await load(input);
|
|
57
57
|
const current = state.currentRound;
|
|
@@ -60,7 +60,30 @@ export function createRoundService(deps) {
|
|
|
60
60
|
current.settleDeadlineAt !== settleDeadlineAt) {
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
const timestamp = maxIsoTimestamp(now(), settleDeadlineAt);
|
|
64
|
+
if (settleGuard) {
|
|
65
|
+
const decision = await runSettleGuard(settleGuard, {
|
|
66
|
+
...input,
|
|
67
|
+
role: current.activeRole,
|
|
68
|
+
roundId,
|
|
69
|
+
settleDeadlineAt
|
|
70
|
+
});
|
|
71
|
+
if (decision.action === "continue") {
|
|
72
|
+
const nextRound = {
|
|
73
|
+
...current,
|
|
74
|
+
settleDeadlineAt: addMilliseconds(timestamp, settleMs)
|
|
75
|
+
};
|
|
76
|
+
const next = {
|
|
77
|
+
...state,
|
|
78
|
+
currentRound: nextRound,
|
|
79
|
+
updatedAt: timestamp
|
|
80
|
+
};
|
|
81
|
+
await save(input, next);
|
|
82
|
+
scheduleSettleTimer(input, nextRound, timestamp, settleGuard);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await settleIfNeeded(input, state, timestamp);
|
|
64
87
|
});
|
|
65
88
|
}
|
|
66
89
|
function clearSettleTimer(input) {
|
|
@@ -81,7 +104,7 @@ export function createRoundService(deps) {
|
|
|
81
104
|
settleTimers.delete(key);
|
|
82
105
|
}
|
|
83
106
|
}
|
|
84
|
-
function scheduleSettleTimer(input, round, timestamp) {
|
|
107
|
+
function scheduleSettleTimer(input, round, timestamp, settleGuard) {
|
|
85
108
|
if (round.status !== "settling" || !round.settleDeadlineAt) {
|
|
86
109
|
clearSettleTimer(input);
|
|
87
110
|
return;
|
|
@@ -90,7 +113,7 @@ export function createRoundService(deps) {
|
|
|
90
113
|
const delayMs = Math.max(0, new Date(round.settleDeadlineAt).getTime() - new Date(timestamp).getTime());
|
|
91
114
|
const timer = setTimer(() => {
|
|
92
115
|
settleTimers.delete(getRoundStatePath(input));
|
|
93
|
-
void settleIfStillCurrent(input, round.id, round.settleDeadlineAt ?? "").catch(() => undefined);
|
|
116
|
+
void settleIfStillCurrent(input, round.id, round.settleDeadlineAt ?? "", settleGuard).catch(() => undefined);
|
|
94
117
|
}, delayMs);
|
|
95
118
|
settleTimers.set(getRoundStatePath(input), timer);
|
|
96
119
|
}
|
|
@@ -112,8 +135,7 @@ export function createRoundService(deps) {
|
|
|
112
135
|
async getTaskRoundState(input) {
|
|
113
136
|
return withTaskLock(input, async () => {
|
|
114
137
|
const timestamp = now();
|
|
115
|
-
|
|
116
|
-
return toTaskRoundState(state, timestamp);
|
|
138
|
+
return toTaskRoundState(await load(input), timestamp);
|
|
117
139
|
});
|
|
118
140
|
},
|
|
119
141
|
async recordClaudeHookEvent(input) {
|
|
@@ -136,7 +158,7 @@ export function createRoundService(deps) {
|
|
|
136
158
|
clearSettleTimer(input);
|
|
137
159
|
}
|
|
138
160
|
else if (next.currentRound) {
|
|
139
|
-
scheduleSettleTimer(input, next.currentRound, timestamp);
|
|
161
|
+
scheduleSettleTimer(input, next.currentRound, timestamp, input.settleGuard);
|
|
140
162
|
}
|
|
141
163
|
return toTaskRoundState(next, timestamp);
|
|
142
164
|
});
|
|
@@ -314,6 +336,17 @@ function appendUniqueRole(roles, role) {
|
|
|
314
336
|
function addMilliseconds(value, milliseconds) {
|
|
315
337
|
return new Date(new Date(value).getTime() + milliseconds).toISOString();
|
|
316
338
|
}
|
|
339
|
+
function maxIsoTimestamp(left, right) {
|
|
340
|
+
return Date.parse(left) >= Date.parse(right) ? left : right;
|
|
341
|
+
}
|
|
342
|
+
async function runSettleGuard(settleGuard, input) {
|
|
343
|
+
try {
|
|
344
|
+
return await settleGuard(input);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return { action: "pause" };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
317
350
|
function getDurationMs(start, end) {
|
|
318
351
|
const startMs = Date.parse(start);
|
|
319
352
|
const endMs = Date.parse(end);
|