vibe-coding-master 0.2.6 → 0.2.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 +164 -9
- package/dist/backend/api/claude-hook-routes.js +7 -1
- package/dist/backend/api/gateway-routes.js +17 -0
- package/dist/backend/api/round-routes.js +1 -1
- package/dist/backend/gateway/channels/weixin-ilink-channel.js +304 -0
- package/dist/backend/gateway/gateway-audit-log.js +39 -0
- package/dist/backend/gateway/gateway-command-parser.js +77 -0
- package/dist/backend/gateway/gateway-service.js +848 -0
- package/dist/backend/gateway/gateway-settings-service.js +214 -0
- package/dist/backend/server.js +42 -2
- package/dist/backend/services/claude-hook-service.js +46 -7
- package/dist/backend/services/harness-service.js +37 -32
- package/dist/backend/services/job-guard-service.js +126 -0
- package/dist/backend/services/round-service.js +110 -64
- package/dist/backend/services/session-service.js +10 -4
- package/dist/backend/services/task-service.js +32 -3
- package/dist/backend/services/translation-service.js +15 -0
- package/dist/backend/templates/harness/architect-agent.js +19 -8
- package/dist/backend/templates/harness/claude-root.js +7 -11
- package/dist/backend/templates/harness/coder-agent.js +45 -17
- package/dist/backend/templates/harness/gitignore.js +3 -2
- package/dist/backend/templates/harness/project-manager-agent.js +16 -11
- package/dist/backend/templates/harness/reviewer-agent.js +25 -28
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +42 -31
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +37 -18
- package/dist/shared/types/gateway.js +1 -0
- package/dist-frontend/assets/{index-CPXFnxAY.css → index-7lq6YPCq.css} +1 -1
- package/dist-frontend/assets/index-DHuS-DYr.js +92 -0
- package/dist-frontend/index.html +2 -2
- package/docs/full-harness-baseline.md +7 -5
- package/docs/gateway-design.md +200 -27
- package/docs/product-design.md +35 -14
- package/docs/v0.2-implementation-plan.md +22 -7
- package/docs/vcm-cc-best-practices.md +11 -4
- package/package.json +2 -1
- package/scripts/harness-tools/run-long-check +401 -0
- package/scripts/harness-tools/vcm-bash-guard +107 -0
- package/scripts/harness-tools/watch-job +204 -0
- package/scripts/install-vcm-harness.mjs +93 -387
- package/scripts/uninstall-vcm-harness.mjs +18 -1
- package/scripts/verify-package.mjs +3 -0
- package/dist/backend/templates/harness/known-issues-doc.js +0 -22
- package/dist-frontend/assets/index-D0_02lmQ.js +0 -90
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
4
|
+
const MAX_DEDUPE_IDS = 1000;
|
|
5
|
+
export function createGatewaySettingsService(deps) {
|
|
6
|
+
const settingsPath = deps.settingsPath ?? path.join(homedir(), ".vcm", "gateway", "settings.json");
|
|
7
|
+
const auditPath = deps.auditPath ?? path.join(homedir(), ".vcm", "gateway", "audit.jsonl");
|
|
8
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
9
|
+
let cachedSettings = null;
|
|
10
|
+
async function loadSettings() {
|
|
11
|
+
if (cachedSettings) {
|
|
12
|
+
return cachedSettings;
|
|
13
|
+
}
|
|
14
|
+
if (!(await deps.fs.pathExists(settingsPath))) {
|
|
15
|
+
cachedSettings = normalizeSettings({}, now());
|
|
16
|
+
await saveSettings(cachedSettings);
|
|
17
|
+
return cachedSettings;
|
|
18
|
+
}
|
|
19
|
+
cachedSettings = normalizeSettings(await deps.fs.readJson(settingsPath), now());
|
|
20
|
+
return cachedSettings;
|
|
21
|
+
}
|
|
22
|
+
async function saveSettings(settings) {
|
|
23
|
+
cachedSettings = normalizeSettings(settings, now());
|
|
24
|
+
await deps.fs.writeJsonAtomic(settingsPath, cachedSettings);
|
|
25
|
+
return cachedSettings;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
loadSettings,
|
|
29
|
+
async updateSettings(input) {
|
|
30
|
+
const current = await loadSettings();
|
|
31
|
+
return saveSettings({
|
|
32
|
+
...current,
|
|
33
|
+
enabled: input.enabled ?? current.enabled,
|
|
34
|
+
translationEnabled: input.translationEnabled ?? current.translationEnabled,
|
|
35
|
+
currentProjectId: input.currentProjectId !== undefined ? normalizeNullableString(input.currentProjectId) : current.currentProjectId,
|
|
36
|
+
currentTaskSlug: input.currentTaskSlug !== undefined ? normalizeNullableString(input.currentTaskSlug) : current.currentTaskSlug,
|
|
37
|
+
updatedAt: now()
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
saveSettings,
|
|
41
|
+
async resetBinding() {
|
|
42
|
+
const current = await loadSettings();
|
|
43
|
+
return saveSettings({
|
|
44
|
+
...current,
|
|
45
|
+
enabled: false,
|
|
46
|
+
binding: createDefaultBinding(),
|
|
47
|
+
dedupe: {
|
|
48
|
+
recentInboundMessageIds: []
|
|
49
|
+
},
|
|
50
|
+
pushCursors: {},
|
|
51
|
+
lastPollStatus: {
|
|
52
|
+
state: "idle"
|
|
53
|
+
},
|
|
54
|
+
lastMessageStatus: null,
|
|
55
|
+
updatedAt: now()
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
expose(settings, running = false) {
|
|
59
|
+
return {
|
|
60
|
+
version: 1,
|
|
61
|
+
enabled: settings.enabled,
|
|
62
|
+
running,
|
|
63
|
+
channel: settings.channel,
|
|
64
|
+
translationEnabled: settings.translationEnabled,
|
|
65
|
+
currentProjectId: settings.currentProjectId,
|
|
66
|
+
currentTaskSlug: settings.currentTaskSlug,
|
|
67
|
+
binding: {
|
|
68
|
+
accountId: settings.binding.accountId,
|
|
69
|
+
baseUrl: settings.binding.baseUrl,
|
|
70
|
+
boundUserId: settings.binding.boundUserId,
|
|
71
|
+
loginUserId: settings.binding.loginUserId,
|
|
72
|
+
tokenConfigured: Boolean(settings.binding.token)
|
|
73
|
+
},
|
|
74
|
+
pendingConfirmations: settings.pendingConfirmations,
|
|
75
|
+
lastPollStatus: settings.lastPollStatus,
|
|
76
|
+
lastMessageStatus: settings.lastMessageStatus,
|
|
77
|
+
updatedAt: settings.updatedAt
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
getSettingsPath() {
|
|
81
|
+
return settingsPath;
|
|
82
|
+
},
|
|
83
|
+
getAuditPath() {
|
|
84
|
+
return auditPath;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function normalizeSettings(input, timestamp) {
|
|
89
|
+
const bindingInput = isObject(input.binding) ? input.binding : {};
|
|
90
|
+
const dedupeInput = isObject(input.dedupe) ? input.dedupe : {};
|
|
91
|
+
const pendingInput = isObject(input.pendingConfirmations)
|
|
92
|
+
? input.pendingConfirmations
|
|
93
|
+
: {};
|
|
94
|
+
const pushCursors = isObject(input.pushCursors) ? input.pushCursors : {};
|
|
95
|
+
return {
|
|
96
|
+
version: 1,
|
|
97
|
+
enabled: input.enabled === true,
|
|
98
|
+
channel: "weixin-ilink",
|
|
99
|
+
translationEnabled: input.translationEnabled !== false,
|
|
100
|
+
currentProjectId: normalizeNullableString(input.currentProjectId),
|
|
101
|
+
currentTaskSlug: normalizeNullableString(input.currentTaskSlug),
|
|
102
|
+
binding: {
|
|
103
|
+
...createDefaultBinding(),
|
|
104
|
+
accountId: normalizeNullableString(bindingInput.accountId),
|
|
105
|
+
baseUrl: normalizeBaseUrl(bindingInput.baseUrl),
|
|
106
|
+
boundUserId: normalizeNullableString(bindingInput.boundUserId),
|
|
107
|
+
loginUserId: normalizeNullableString(bindingInput.loginUserId),
|
|
108
|
+
token: normalizeNullableString(bindingInput.token),
|
|
109
|
+
getUpdatesBuf: typeof bindingInput.getUpdatesBuf === "string" ? bindingInput.getUpdatesBuf : "",
|
|
110
|
+
contextTokens: isObject(bindingInput.contextTokens)
|
|
111
|
+
? normalizeStringRecord(bindingInput.contextTokens)
|
|
112
|
+
: {}
|
|
113
|
+
},
|
|
114
|
+
dedupe: {
|
|
115
|
+
recentInboundMessageIds: Array.isArray(dedupeInput.recentInboundMessageIds)
|
|
116
|
+
? dedupeInput.recentInboundMessageIds.filter((value) => typeof value === "string").slice(-MAX_DEDUPE_IDS)
|
|
117
|
+
: []
|
|
118
|
+
},
|
|
119
|
+
pendingConfirmations: {
|
|
120
|
+
closeTask: normalizeCloseTaskConfirmation(pendingInput.closeTask)
|
|
121
|
+
},
|
|
122
|
+
pushCursors: normalizePushCursors(pushCursors),
|
|
123
|
+
lastPollStatus: normalizePollStatus(input.lastPollStatus),
|
|
124
|
+
lastMessageStatus: normalizeMessageStatus(input.lastMessageStatus),
|
|
125
|
+
updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : timestamp
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function createDefaultBinding() {
|
|
129
|
+
return {
|
|
130
|
+
accountId: null,
|
|
131
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
132
|
+
boundUserId: null,
|
|
133
|
+
loginUserId: null,
|
|
134
|
+
token: null,
|
|
135
|
+
getUpdatesBuf: "",
|
|
136
|
+
contextTokens: {}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function normalizeCloseTaskConfirmation(input) {
|
|
140
|
+
if (!isObject(input)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const taskSlug = normalizeNullableString(input.taskSlug);
|
|
144
|
+
const createdAt = normalizeNullableString(input.createdAt);
|
|
145
|
+
const expiresAt = normalizeNullableString(input.expiresAt);
|
|
146
|
+
if (!taskSlug || !createdAt || !expiresAt) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return { taskSlug, createdAt, expiresAt };
|
|
150
|
+
}
|
|
151
|
+
function normalizePushCursors(input) {
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const [key, value] of Object.entries(input)) {
|
|
154
|
+
if (!isObject(value)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
out[key] = {
|
|
158
|
+
lastTranscriptEventId: normalizeNullableString(value.lastTranscriptEventId),
|
|
159
|
+
lastTranscriptTimestamp: normalizeNullableString(value.lastTranscriptTimestamp)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function normalizePollStatus(input) {
|
|
165
|
+
if (!isObject(input)) {
|
|
166
|
+
return { state: "idle" };
|
|
167
|
+
}
|
|
168
|
+
const state = input.state === "running" || input.state === "error" || input.state === "expired"
|
|
169
|
+
? input.state
|
|
170
|
+
: "idle";
|
|
171
|
+
return {
|
|
172
|
+
state,
|
|
173
|
+
checkedAt: normalizeNullableString(input.checkedAt) ?? undefined,
|
|
174
|
+
error: normalizeNullableString(input.error) ?? undefined
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function normalizeMessageStatus(input) {
|
|
178
|
+
if (!isObject(input)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
checkedAt: normalizeNullableString(input.checkedAt) ?? undefined,
|
|
183
|
+
direction: input.direction === "inbound" || input.direction === "outbound" ? input.direction : undefined,
|
|
184
|
+
command: normalizeNullableString(input.command) ?? undefined,
|
|
185
|
+
result: input.result === "ok" || input.result === "ignored" || input.result === "error" ? input.result : undefined,
|
|
186
|
+
preview: normalizeNullableString(input.preview) ?? undefined,
|
|
187
|
+
error: normalizeNullableString(input.error) ?? undefined
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function normalizeBaseUrl(input) {
|
|
191
|
+
const raw = typeof input === "string" ? input.trim() : "";
|
|
192
|
+
if (!raw) {
|
|
193
|
+
return DEFAULT_BASE_URL;
|
|
194
|
+
}
|
|
195
|
+
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
196
|
+
return raw.replace(/\/+$/, "");
|
|
197
|
+
}
|
|
198
|
+
return `https://${raw.replace(/\/+$/, "")}`;
|
|
199
|
+
}
|
|
200
|
+
function normalizeNullableString(input) {
|
|
201
|
+
return typeof input === "string" && input.trim() ? input.trim() : null;
|
|
202
|
+
}
|
|
203
|
+
function normalizeStringRecord(input) {
|
|
204
|
+
const out = {};
|
|
205
|
+
for (const [key, value] of Object.entries(input)) {
|
|
206
|
+
if (typeof value === "string") {
|
|
207
|
+
out[key] = value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function isObject(value) {
|
|
213
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
214
|
+
}
|
package/dist/backend/server.js
CHANGED
|
@@ -14,6 +14,12 @@ import { createHarnessService, createScriptFixedHarnessInstaller } from "./servi
|
|
|
14
14
|
import { createNodeFileSystemAdapter } from "./adapters/filesystem.js";
|
|
15
15
|
import { createNodePtyTerminalRuntime } from "./runtime/node-pty-runtime.js";
|
|
16
16
|
import { createOpenAiCompatibleTranslationProvider } from "./adapters/translation-provider.js";
|
|
17
|
+
import { registerGatewayRoutes } from "./api/gateway-routes.js";
|
|
18
|
+
import { createWeixinIlinkChannel } from "./gateway/channels/weixin-ilink-channel.js";
|
|
19
|
+
import { createGatewayAuditLog } from "./gateway/gateway-audit-log.js";
|
|
20
|
+
import { createGatewayService } from "./gateway/gateway-service.js";
|
|
21
|
+
import { createGatewaySettingsService } from "./gateway/gateway-settings-service.js";
|
|
22
|
+
import { createJobGuardService } from "./services/job-guard-service.js";
|
|
17
23
|
import { createProjectService } from "./services/project-service.js";
|
|
18
24
|
import { createSessionRegistry } from "./runtime/session-registry.js";
|
|
19
25
|
import { createSessionService } from "./services/session-service.js";
|
|
@@ -90,7 +96,14 @@ export async function createServer(deps, options = {}) {
|
|
|
90
96
|
taskService: deps.taskService,
|
|
91
97
|
translationService: deps.translationService
|
|
92
98
|
});
|
|
99
|
+
registerGatewayRoutes(app, { gatewayService: deps.gatewayService });
|
|
93
100
|
registerTerminalWs(app, { runtime: deps.runtime });
|
|
101
|
+
app.addHook("onReady", async () => {
|
|
102
|
+
await deps.gatewayService.start();
|
|
103
|
+
});
|
|
104
|
+
app.addHook("onClose", async () => {
|
|
105
|
+
deps.gatewayService.stop();
|
|
106
|
+
});
|
|
94
107
|
if (options.staticDir) {
|
|
95
108
|
await app.register(fastifyStatic, {
|
|
96
109
|
root: options.staticDir,
|
|
@@ -162,7 +175,12 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
162
175
|
sessionService,
|
|
163
176
|
taskService
|
|
164
177
|
});
|
|
165
|
-
const roundService = createRoundService({
|
|
178
|
+
const roundService = createRoundService({
|
|
179
|
+
fs,
|
|
180
|
+
onSessionStatusChange: async ({ repoRoot, taskSlug, status }) => {
|
|
181
|
+
await taskService.updateTaskStatus(repoRoot, taskSlug, status);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
166
184
|
const transcripts = createClaudeTranscriptService();
|
|
167
185
|
const translationService = createTranslationService({
|
|
168
186
|
runtime,
|
|
@@ -174,6 +192,25 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
174
192
|
appSettings,
|
|
175
193
|
provider: createOpenAiCompatibleTranslationProvider()
|
|
176
194
|
});
|
|
195
|
+
const gatewaySettings = createGatewaySettingsService({ fs });
|
|
196
|
+
const gatewayAudit = createGatewayAuditLog({
|
|
197
|
+
fs,
|
|
198
|
+
auditPath: gatewaySettings.getAuditPath()
|
|
199
|
+
});
|
|
200
|
+
const gatewayService = createGatewayService({
|
|
201
|
+
fs,
|
|
202
|
+
settings: gatewaySettings,
|
|
203
|
+
audit: gatewayAudit,
|
|
204
|
+
channel: createWeixinIlinkChannel(),
|
|
205
|
+
projectService,
|
|
206
|
+
taskService,
|
|
207
|
+
sessionService,
|
|
208
|
+
messageService,
|
|
209
|
+
translationService,
|
|
210
|
+
roundService,
|
|
211
|
+
runtime,
|
|
212
|
+
appSettings
|
|
213
|
+
});
|
|
177
214
|
const claudeHookService = createClaudeHookService({
|
|
178
215
|
projectService,
|
|
179
216
|
taskService,
|
|
@@ -181,7 +218,9 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
181
218
|
messageService,
|
|
182
219
|
roundService,
|
|
183
220
|
translationService,
|
|
184
|
-
appSettings
|
|
221
|
+
appSettings,
|
|
222
|
+
gatewayService,
|
|
223
|
+
jobGuard: createJobGuardService()
|
|
185
224
|
});
|
|
186
225
|
return {
|
|
187
226
|
appSettings,
|
|
@@ -196,6 +235,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
196
235
|
roundService,
|
|
197
236
|
statusService,
|
|
198
237
|
translationService,
|
|
238
|
+
gatewayService,
|
|
199
239
|
runtime
|
|
200
240
|
};
|
|
201
241
|
}
|
|
@@ -34,6 +34,11 @@ export function createClaudeHookService(deps) {
|
|
|
34
34
|
throwUnsupportedEvent(eventName);
|
|
35
35
|
}
|
|
36
36
|
const context = await getHookContext(input);
|
|
37
|
+
deps.jobGuard?.notePromptSubmitted({
|
|
38
|
+
repoRoot: context.project.repoRoot,
|
|
39
|
+
taskSlug: input.taskSlug,
|
|
40
|
+
role: input.role
|
|
41
|
+
});
|
|
37
42
|
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
38
43
|
taskSlug: input.taskSlug,
|
|
39
44
|
role: input.role,
|
|
@@ -43,6 +48,7 @@ export function createClaudeHookService(deps) {
|
|
|
43
48
|
cwd: stringOrUndefined(input.event.cwd)
|
|
44
49
|
});
|
|
45
50
|
await deps.roundService.recordClaudeHookEvent({
|
|
51
|
+
repoRoot: context.project.repoRoot,
|
|
46
52
|
stateRepoRoot: context.taskRepoRoot,
|
|
47
53
|
stateRoot: context.config.stateRoot,
|
|
48
54
|
taskSlug: input.taskSlug,
|
|
@@ -57,7 +63,7 @@ export function createClaudeHookService(deps) {
|
|
|
57
63
|
role: input.role,
|
|
58
64
|
sessionId: session.id,
|
|
59
65
|
boundaryKind: "start",
|
|
60
|
-
occurredAt: session.
|
|
66
|
+
occurredAt: session.lastTurnStartedAt ?? session.updatedAt
|
|
61
67
|
});
|
|
62
68
|
}
|
|
63
69
|
const submitted = await deps.messageService.confirmPromptSubmitted({
|
|
@@ -80,12 +86,33 @@ export function createClaudeHookService(deps) {
|
|
|
80
86
|
acceptedMessageId: submitted?.id
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
|
-
async function
|
|
89
|
+
async function processStopHook(input, options) {
|
|
84
90
|
const eventName = parseHookEvent(input.event.hook_event_name ?? "Stop");
|
|
85
91
|
if (eventName !== "Stop") {
|
|
86
92
|
throwUnsupportedEvent(eventName);
|
|
87
93
|
}
|
|
88
94
|
const context = await getHookContext(input);
|
|
95
|
+
if (options.allowBlock && deps.jobGuard) {
|
|
96
|
+
const verdict = await deps.jobGuard.evaluateStop({
|
|
97
|
+
repoRoot: context.project.repoRoot,
|
|
98
|
+
taskSlug: input.taskSlug,
|
|
99
|
+
role: input.role,
|
|
100
|
+
taskRepoRoot: context.taskRepoRoot
|
|
101
|
+
});
|
|
102
|
+
if (verdict.behavior === "block") {
|
|
103
|
+
// The role turn stays alive: skip all turn-end bookkeeping so the
|
|
104
|
+
// round keeps running and no route dispatch happens yet.
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
eventName,
|
|
108
|
+
taskSlug: input.taskSlug,
|
|
109
|
+
role: input.role,
|
|
110
|
+
sessionUpdated: false,
|
|
111
|
+
dispatchedCount: 0,
|
|
112
|
+
stopDecision: { behavior: "block", reason: verdict.reason }
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
89
116
|
const scopedRouteDispatchInput = {
|
|
90
117
|
repoRoot: context.project.repoRoot,
|
|
91
118
|
taskRepoRoot: context.taskRepoRoot,
|
|
@@ -112,6 +139,7 @@ export function createClaudeHookService(deps) {
|
|
|
112
139
|
cwd: stringOrUndefined(input.event.cwd)
|
|
113
140
|
});
|
|
114
141
|
await deps.roundService.recordClaudeHookEvent({
|
|
142
|
+
repoRoot: context.project.repoRoot,
|
|
115
143
|
stateRepoRoot: context.taskRepoRoot,
|
|
116
144
|
stateRoot: context.config.stateRoot,
|
|
117
145
|
taskSlug: input.taskSlug,
|
|
@@ -120,12 +148,12 @@ export function createClaudeHookService(deps) {
|
|
|
120
148
|
settleGuard: async () => {
|
|
121
149
|
const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
|
|
122
150
|
if (pending.length === 0) {
|
|
123
|
-
return { action: "
|
|
151
|
+
return { action: "stop" };
|
|
124
152
|
}
|
|
125
153
|
const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
|
|
126
154
|
return retried.some((result) => result.delivered)
|
|
127
155
|
? { action: "continue", reason: "pending route message dispatched" }
|
|
128
|
-
: { action: "
|
|
156
|
+
: { action: "stop" };
|
|
129
157
|
}
|
|
130
158
|
});
|
|
131
159
|
if (session) {
|
|
@@ -136,9 +164,16 @@ export function createClaudeHookService(deps) {
|
|
|
136
164
|
role: input.role,
|
|
137
165
|
sessionId: session.id,
|
|
138
166
|
boundaryKind: "end",
|
|
139
|
-
occurredAt: session.
|
|
167
|
+
occurredAt: session.lastTurnEndedAt ?? session.updatedAt
|
|
140
168
|
});
|
|
141
169
|
}
|
|
170
|
+
if (session && input.role === "project-manager") {
|
|
171
|
+
void deps.gatewayService?.handlePmStop({
|
|
172
|
+
repoRoot: context.project.repoRoot,
|
|
173
|
+
taskSlug: input.taskSlug,
|
|
174
|
+
session
|
|
175
|
+
}).catch(() => undefined);
|
|
176
|
+
}
|
|
142
177
|
const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput);
|
|
143
178
|
return {
|
|
144
179
|
ok: true,
|
|
@@ -185,9 +220,13 @@ export function createClaudeHookService(deps) {
|
|
|
185
220
|
if (eventName === "UserPromptSubmit") {
|
|
186
221
|
return handleUserPromptSubmitHook(input);
|
|
187
222
|
}
|
|
188
|
-
|
|
223
|
+
// Legacy combined endpoint: the installed hook discards the response,
|
|
224
|
+
// so a block decision could not be enforced. Never block here.
|
|
225
|
+
return processStopHook(input, { allowBlock: false });
|
|
226
|
+
},
|
|
227
|
+
handleStopHook(input) {
|
|
228
|
+
return processStopHook(input, { allowBlock: true });
|
|
189
229
|
},
|
|
190
|
-
handleStopHook,
|
|
191
230
|
handlePermissionRequestHook
|
|
192
231
|
};
|
|
193
232
|
}
|
|
@@ -6,7 +6,6 @@ import { renderArchitectHarnessRules } from "../templates/harness/architect-agen
|
|
|
6
6
|
import { renderCoderHarnessRules } from "../templates/harness/coder-agent.js";
|
|
7
7
|
import { renderRootClaudeHarnessRules } from "../templates/harness/claude-root.js";
|
|
8
8
|
import { renderGitignoreHarnessRules } from "../templates/harness/gitignore.js";
|
|
9
|
-
import { renderKnownIssuesDocHarnessRules } from "../templates/harness/known-issues-doc.js";
|
|
10
9
|
import { renderProjectManagerHarnessRules } from "../templates/harness/project-manager-agent.js";
|
|
11
10
|
import { renderPullRequestTemplateHarnessRules } from "../templates/harness/pull-request-template.js";
|
|
12
11
|
import { renderReviewerHarnessRules } from "../templates/harness/reviewer-agent.js";
|
|
@@ -26,13 +25,22 @@ const MANAGED_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=(\d+))? -->[\s\S]*?<!
|
|
|
26
25
|
const HASH_MANAGED_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=(\d+))?\n[\s\S]*?# VCM:END/m;
|
|
27
26
|
const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
|
|
28
27
|
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'`;
|
|
28
|
+
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,event}));});'"'"' | curl -fsS --max-time 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || true'`;
|
|
29
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
|
|
30
|
+
const VCM_BASH_GUARD_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ]; then exit 0; fi; exec python3 "\${CLAUDE_PROJECT_DIR:-.}/.ai/tools/vcm-bash-guard"'`;
|
|
31
|
+
const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
|
|
32
|
+
const VCM_HOOK_DEFINITIONS = [
|
|
33
|
+
{ eventName: "PreToolUse", matcher: "Bash", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
|
|
34
|
+
{ eventName: "UserPromptSubmit", command: VCM_HOOK_COMMAND, timeout: 5 },
|
|
35
|
+
{ eventName: "Stop", command: VCM_STOP_HOOK_COMMAND, timeout: 10 },
|
|
36
|
+
{ eventName: "PermissionRequest", command: VCM_PERMISSION_REQUEST_HOOK_COMMAND, timeout: 5 }
|
|
37
|
+
];
|
|
31
38
|
const HARNESS_FILES = [
|
|
32
39
|
{
|
|
33
40
|
kind: "root-claude",
|
|
34
41
|
path: "CLAUDE.md",
|
|
35
42
|
title: "CLAUDE.md",
|
|
43
|
+
blankLineBeforeEnd: true,
|
|
36
44
|
renderRules: renderRootClaudeHarnessRules
|
|
37
45
|
},
|
|
38
46
|
{
|
|
@@ -42,12 +50,6 @@ const HARNESS_FILES = [
|
|
|
42
50
|
commentStyle: "hash",
|
|
43
51
|
renderRules: renderGitignoreHarnessRules
|
|
44
52
|
},
|
|
45
|
-
{
|
|
46
|
-
kind: "docs-known-issues",
|
|
47
|
-
path: "docs/known-issues.md",
|
|
48
|
-
title: "Known Issues",
|
|
49
|
-
renderRules: renderKnownIssuesDocHarnessRules
|
|
50
|
-
},
|
|
51
53
|
{
|
|
52
54
|
kind: "pull-request-template",
|
|
53
55
|
path: ".github/pull_request_template.md",
|
|
@@ -97,6 +99,7 @@ const HARNESS_FILES = [
|
|
|
97
99
|
kind: "agent-architect",
|
|
98
100
|
path: ".claude/agents/architect.md",
|
|
99
101
|
title: "Architect Agent",
|
|
102
|
+
blankLineBeforeEnd: true,
|
|
100
103
|
frontmatter: renderAgentFrontmatter("architect", "VCM architecture role for plans, module boundaries, public contracts, verifiable behavior, and docs sync."),
|
|
101
104
|
renderRules: renderArchitectHarnessRules
|
|
102
105
|
},
|
|
@@ -111,7 +114,7 @@ const HARNESS_FILES = [
|
|
|
111
114
|
kind: "agent-reviewer",
|
|
112
115
|
path: ".claude/agents/reviewer.md",
|
|
113
116
|
title: "Reviewer Agent",
|
|
114
|
-
frontmatter: renderAgentFrontmatter("reviewer", "VCM independent review role for acceptance, test adequacy, scope checks,
|
|
117
|
+
frontmatter: renderAgentFrontmatter("reviewer", "VCM independent review role for acceptance, test adequacy, scope checks, and risk findings."),
|
|
115
118
|
renderRules: renderReviewerHarnessRules
|
|
116
119
|
}
|
|
117
120
|
];
|
|
@@ -344,10 +347,11 @@ function renderHarnessStatus(analyses) {
|
|
|
344
347
|
};
|
|
345
348
|
}
|
|
346
349
|
function renderManagedBlock(definition, rules) {
|
|
350
|
+
const endSpacing = definition.blankLineBeforeEnd ? "\n\n" : "\n";
|
|
347
351
|
if (definition.commentStyle === "hash") {
|
|
348
|
-
return `# VCM:BEGIN version=${VCM_HARNESS_VERSION}\n${rules.trimEnd()}
|
|
352
|
+
return `# VCM:BEGIN version=${VCM_HARNESS_VERSION}\n${rules.trimEnd()}${endSpacing}# VCM:END`;
|
|
349
353
|
}
|
|
350
|
-
return `<!-- VCM:BEGIN version=${VCM_HARNESS_VERSION} -->\n${rules.trimEnd()}
|
|
354
|
+
return `<!-- VCM:BEGIN version=${VCM_HARNESS_VERSION} -->\n${rules.trimEnd()}${endSpacing}<!-- VCM:END -->`;
|
|
351
355
|
}
|
|
352
356
|
function getManagedBlockPattern(definition) {
|
|
353
357
|
return definition.commentStyle === "hash"
|
|
@@ -427,29 +431,30 @@ function withVcmClaudeHooks(settings) {
|
|
|
427
431
|
delete hooks[eventName];
|
|
428
432
|
}
|
|
429
433
|
}
|
|
430
|
-
for (const
|
|
431
|
-
const existingMatchers = Array.isArray(hooks[eventName])
|
|
432
|
-
? hooks[eventName]
|
|
434
|
+
for (const definition of VCM_HOOK_DEFINITIONS) {
|
|
435
|
+
const existingMatchers = Array.isArray(hooks[definition.eventName])
|
|
436
|
+
? hooks[definition.eventName]
|
|
433
437
|
: [];
|
|
434
|
-
hooks[eventName] = [
|
|
438
|
+
hooks[definition.eventName] = [
|
|
435
439
|
...existingMatchers.filter((entry) => !isVcmHookMatcher(entry)),
|
|
436
|
-
|
|
440
|
+
{
|
|
441
|
+
...(definition.matcher ? { matcher: definition.matcher } : {}),
|
|
442
|
+
hooks: [
|
|
443
|
+
{
|
|
444
|
+
type: "command",
|
|
445
|
+
command: definition.command,
|
|
446
|
+
timeout: definition.timeout
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
}
|
|
437
450
|
];
|
|
438
451
|
}
|
|
452
|
+
const env = isPlainObject(settings.env) ? { ...settings.env } : {};
|
|
453
|
+
env.BASH_DEFAULT_TIMEOUT_MS = VCM_BASH_DEFAULT_TIMEOUT_MS;
|
|
439
454
|
return {
|
|
440
455
|
...settings,
|
|
441
|
-
hooks
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
function createVcmHookMatcher(eventName) {
|
|
445
|
-
return {
|
|
446
|
-
hooks: [
|
|
447
|
-
{
|
|
448
|
-
type: "command",
|
|
449
|
-
command: eventName === "PermissionRequest" ? VCM_PERMISSION_REQUEST_HOOK_COMMAND : VCM_HOOK_COMMAND,
|
|
450
|
-
timeout: 5
|
|
451
|
-
}
|
|
452
|
-
]
|
|
456
|
+
hooks,
|
|
457
|
+
env
|
|
453
458
|
};
|
|
454
459
|
}
|
|
455
460
|
function isVcmHookMatcher(value) {
|
|
@@ -460,12 +465,12 @@ function isVcmHookMatcher(value) {
|
|
|
460
465
|
if (!isPlainObject(hook)) {
|
|
461
466
|
return false;
|
|
462
467
|
}
|
|
463
|
-
|
|
464
|
-
}) || value.hooks.some((hook) => {
|
|
465
|
-
if (!isPlainObject(hook)) {
|
|
468
|
+
if (typeof hook.command !== "string") {
|
|
466
469
|
return false;
|
|
467
470
|
}
|
|
468
|
-
return
|
|
471
|
+
return hook.command.includes("VCM") ||
|
|
472
|
+
hook.command.includes("/api/hooks/claude-code") ||
|
|
473
|
+
hook.command.includes("hook-event");
|
|
469
474
|
});
|
|
470
475
|
}
|
|
471
476
|
function isPlainObject(value) {
|