vibe-coding-master 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +226 -0
- package/dist/backend/adapters/claude-adapter.js +38 -0
- package/dist/backend/adapters/command-runner.js +33 -0
- package/dist/backend/adapters/filesystem.js +60 -0
- package/dist/backend/adapters/git-adapter.js +33 -0
- package/dist/backend/api/artifact-routes.js +109 -0
- package/dist/backend/api/message-routes.js +90 -0
- package/dist/backend/api/project-routes.js +17 -0
- package/dist/backend/api/session-routes.js +64 -0
- package/dist/backend/api/task-routes.js +30 -0
- package/dist/backend/errors.js +29 -0
- package/dist/backend/runtime/node-pty-runtime.js +162 -0
- package/dist/backend/runtime/session-registry.js +36 -0
- package/dist/backend/runtime/terminal-runtime.js +1 -0
- package/dist/backend/server.js +159 -0
- package/dist/backend/services/artifact-service.js +170 -0
- package/dist/backend/services/command-dispatcher.js +37 -0
- package/dist/backend/services/message-service.js +217 -0
- package/dist/backend/services/project-service.js +71 -0
- package/dist/backend/services/session-service.js +221 -0
- package/dist/backend/services/status-service.js +21 -0
- package/dist/backend/services/task-service.js +88 -0
- package/dist/backend/templates/handoff.js +76 -0
- package/dist/backend/templates/message-envelope.js +27 -0
- package/dist/backend/templates/role-command.js +21 -0
- package/dist/backend/templates/role-messaging-context.js +44 -0
- package/dist/backend/ws/terminal-ws.js +60 -0
- package/dist/cli/vcmctl.js +141 -0
- package/dist/main.js +63 -0
- package/dist/shared/constants.js +45 -0
- package/dist/shared/types/api.js +1 -0
- package/dist/shared/types/artifact.js +1 -0
- package/dist/shared/types/message.js +1 -0
- package/dist/shared/types/project.js +1 -0
- package/dist/shared/types/role.js +1 -0
- package/dist/shared/types/session.js +1 -0
- package/dist/shared/types/task.js +1 -0
- package/dist/shared/types/terminal.js +1 -0
- package/dist/shared/validation/artifact-check.js +64 -0
- package/dist/shared/validation/slug-check.js +22 -0
- package/dist-frontend/assets/index-Bah6k-Ix.css +32 -0
- package/dist-frontend/assets/index-EMaQuIB6.js +58 -0
- package/dist-frontend/index.html +13 -0
- package/docs/cc-best-practices.md +2142 -0
- package/docs/product-design.md +1597 -0
- package/docs/v1-architecture-design.md +1431 -0
- package/docs/v1-implementation-plan.md +1949 -0
- package/docs/v1-message-bus-orchestration-design.md +534 -0
- package/package.json +60 -0
- package/scripts/clean-build.mjs +12 -0
- package/scripts/fix-node-pty-spawn-helper.mjs +31 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import Fastify from "fastify";
|
|
5
|
+
import fastifyStatic from "@fastify/static";
|
|
6
|
+
import { createArtifactService } from "./services/artifact-service.js";
|
|
7
|
+
import { createClaudeAdapter } from "./adapters/claude-adapter.js";
|
|
8
|
+
import { createCommandRunner } from "./adapters/command-runner.js";
|
|
9
|
+
import { createCommandDispatcher } from "./services/command-dispatcher.js";
|
|
10
|
+
import { createGitAdapter } from "./adapters/git-adapter.js";
|
|
11
|
+
import { createNodeFileSystemAdapter } from "./adapters/filesystem.js";
|
|
12
|
+
import { createNodePtyTerminalRuntime } from "./runtime/node-pty-runtime.js";
|
|
13
|
+
import { createProjectService } from "./services/project-service.js";
|
|
14
|
+
import { createSessionRegistry } from "./runtime/session-registry.js";
|
|
15
|
+
import { createSessionService } from "./services/session-service.js";
|
|
16
|
+
import { createMessageService } from "./services/message-service.js";
|
|
17
|
+
import { createStatusService } from "./services/status-service.js";
|
|
18
|
+
import { createTaskService } from "./services/task-service.js";
|
|
19
|
+
import { registerArtifactRoutes } from "./api/artifact-routes.js";
|
|
20
|
+
import { registerMessageRoutes } from "./api/message-routes.js";
|
|
21
|
+
import { registerProjectRoutes } from "./api/project-routes.js";
|
|
22
|
+
import { registerSessionRoutes } from "./api/session-routes.js";
|
|
23
|
+
import { registerTaskRoutes } from "./api/task-routes.js";
|
|
24
|
+
import { registerTerminalWs } from "./ws/terminal-ws.js";
|
|
25
|
+
import { toVcmError } from "./errors.js";
|
|
26
|
+
export async function createServer(deps, options = {}) {
|
|
27
|
+
const app = Fastify({
|
|
28
|
+
logger: false
|
|
29
|
+
});
|
|
30
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
31
|
+
const vcmError = toVcmError(error);
|
|
32
|
+
reply.status(vcmError.statusCode).send({
|
|
33
|
+
error: {
|
|
34
|
+
code: vcmError.code,
|
|
35
|
+
message: vcmError.message,
|
|
36
|
+
hint: vcmError.hint
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
registerProjectRoutes(app, { projectService: deps.projectService });
|
|
41
|
+
registerTaskRoutes(app, {
|
|
42
|
+
projectService: deps.projectService,
|
|
43
|
+
taskService: deps.taskService,
|
|
44
|
+
statusService: deps.statusService
|
|
45
|
+
});
|
|
46
|
+
registerSessionRoutes(app, {
|
|
47
|
+
projectService: deps.projectService,
|
|
48
|
+
sessionService: deps.sessionService,
|
|
49
|
+
commandDispatcher: deps.commandDispatcher
|
|
50
|
+
});
|
|
51
|
+
registerArtifactRoutes(app, {
|
|
52
|
+
projectService: deps.projectService,
|
|
53
|
+
taskService: deps.taskService,
|
|
54
|
+
artifactService: deps.artifactService
|
|
55
|
+
});
|
|
56
|
+
registerMessageRoutes(app, {
|
|
57
|
+
projectService: deps.projectService,
|
|
58
|
+
taskService: deps.taskService,
|
|
59
|
+
messageService: deps.messageService
|
|
60
|
+
});
|
|
61
|
+
registerTerminalWs(app, { runtime: deps.runtime });
|
|
62
|
+
if (options.staticDir) {
|
|
63
|
+
await app.register(fastifyStatic, {
|
|
64
|
+
root: options.staticDir,
|
|
65
|
+
prefix: "/"
|
|
66
|
+
});
|
|
67
|
+
app.setNotFoundHandler((_request, reply) => {
|
|
68
|
+
reply.sendFile("index.html");
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return app;
|
|
72
|
+
}
|
|
73
|
+
export async function startServer(options = {}) {
|
|
74
|
+
const host = options.host ?? "127.0.0.1";
|
|
75
|
+
const port = options.port ?? 4173;
|
|
76
|
+
const deps = createDefaultServerDeps({
|
|
77
|
+
apiUrl: `http://${host}:${port}`
|
|
78
|
+
});
|
|
79
|
+
const app = await createServer(deps, options);
|
|
80
|
+
await app.listen({ host, port });
|
|
81
|
+
return {
|
|
82
|
+
url: `http://${host}:${port}`,
|
|
83
|
+
close() {
|
|
84
|
+
return app.close();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function createDefaultServerDeps(options = {}) {
|
|
89
|
+
const fs = createNodeFileSystemAdapter();
|
|
90
|
+
const runner = createCommandRunner();
|
|
91
|
+
const git = createGitAdapter(runner);
|
|
92
|
+
const claude = createClaudeAdapter(runner);
|
|
93
|
+
const runtime = createNodePtyTerminalRuntime({ fs });
|
|
94
|
+
const registry = createSessionRegistry();
|
|
95
|
+
const artifactService = createArtifactService(fs);
|
|
96
|
+
const projectService = createProjectService({ fs, git, claude });
|
|
97
|
+
const taskService = createTaskService({ fs, git, artifactService, projectService });
|
|
98
|
+
const sessionService = createSessionService({
|
|
99
|
+
fs,
|
|
100
|
+
runtime,
|
|
101
|
+
registry,
|
|
102
|
+
claude,
|
|
103
|
+
artifactService,
|
|
104
|
+
projectService,
|
|
105
|
+
taskService,
|
|
106
|
+
apiUrl: options.apiUrl,
|
|
107
|
+
vcmctlCommand: options.vcmctlCommand ?? resolveVcmctlCommand()
|
|
108
|
+
});
|
|
109
|
+
const commandDispatcher = createCommandDispatcher({
|
|
110
|
+
runtime,
|
|
111
|
+
sessionService,
|
|
112
|
+
taskService,
|
|
113
|
+
artifactService
|
|
114
|
+
});
|
|
115
|
+
const statusService = createStatusService({
|
|
116
|
+
taskService,
|
|
117
|
+
sessionService,
|
|
118
|
+
artifactService
|
|
119
|
+
});
|
|
120
|
+
const messageService = createMessageService({
|
|
121
|
+
fs,
|
|
122
|
+
runtime,
|
|
123
|
+
sessionService,
|
|
124
|
+
taskService
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
projectService,
|
|
128
|
+
taskService,
|
|
129
|
+
sessionService,
|
|
130
|
+
artifactService,
|
|
131
|
+
commandDispatcher,
|
|
132
|
+
messageService,
|
|
133
|
+
statusService,
|
|
134
|
+
runtime
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function getDefaultStaticDir() {
|
|
138
|
+
return path.resolve("dist-frontend");
|
|
139
|
+
}
|
|
140
|
+
function resolveVcmctlCommand() {
|
|
141
|
+
const currentModulePath = fileURLToPath(import.meta.url);
|
|
142
|
+
const appRoot = path.resolve(path.dirname(currentModulePath), "../..");
|
|
143
|
+
const sourceCli = path.join(appRoot, "src", "cli", "vcmctl.ts");
|
|
144
|
+
const tsxCli = path.join(appRoot, "node_modules", "tsx", "dist", "cli.mjs");
|
|
145
|
+
if (currentModulePath.includes(`${path.sep}src${path.sep}`) && existsSync(tsxCli) && existsSync(sourceCli)) {
|
|
146
|
+
return `${quoteShellArg(process.execPath)} ${quoteShellArg(tsxCli)} ${quoteShellArg(sourceCli)}`;
|
|
147
|
+
}
|
|
148
|
+
const distCli = path.join(appRoot, "dist", "cli", "vcmctl.js");
|
|
149
|
+
if (existsSync(distCli)) {
|
|
150
|
+
return `${quoteShellArg(process.execPath)} ${quoteShellArg(distCli)}`;
|
|
151
|
+
}
|
|
152
|
+
if (existsSync(tsxCli) && existsSync(sourceCli)) {
|
|
153
|
+
return `${quoteShellArg(process.execPath)} ${quoteShellArg(tsxCli)} ${quoteShellArg(sourceCli)}`;
|
|
154
|
+
}
|
|
155
|
+
return "vcmctl";
|
|
156
|
+
}
|
|
157
|
+
function quoteShellArg(value) {
|
|
158
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { DISPATCHABLE_ROLES, ROLE_NAMES } from "../../shared/constants.js";
|
|
3
|
+
import { checkMarkdownArtifact } from "../../shared/validation/artifact-check.js";
|
|
4
|
+
import { VcmError } from "../errors.js";
|
|
5
|
+
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
|
+
import { renderArchitecturePlanTemplate, renderImplementationLogTemplate, renderReviewReportTemplate, renderValidationLogTemplate } from "../templates/handoff.js";
|
|
7
|
+
import { renderRoleCommandTemplate } from "../templates/role-command.js";
|
|
8
|
+
const ARTIFACT_PATH_KEYS = [
|
|
9
|
+
["architecture-plan", "architecturePlanPath"],
|
|
10
|
+
["implementation-log", "implementationLogPath"],
|
|
11
|
+
["validation-log", "validationLogPath"],
|
|
12
|
+
["review-report", "reviewReportPath"]
|
|
13
|
+
];
|
|
14
|
+
const ROLE_COMMAND_PLACEHOLDER_PATTERN = /(^|\n)\s*(TBD|status:\s*draft)\s*(\n|$)/i;
|
|
15
|
+
export function createArtifactService(fs) {
|
|
16
|
+
return {
|
|
17
|
+
getHandoffPaths(_repoRoot, handoffDir) {
|
|
18
|
+
const roleCommandsDir = path.posix.join(handoffDir, "role-commands");
|
|
19
|
+
const logsDir = path.posix.join(handoffDir, "logs");
|
|
20
|
+
return {
|
|
21
|
+
handoffDir,
|
|
22
|
+
roleCommandsDir,
|
|
23
|
+
logsDir,
|
|
24
|
+
roleCommandPaths: {
|
|
25
|
+
architect: path.posix.join(roleCommandsDir, "architect.md"),
|
|
26
|
+
coder: path.posix.join(roleCommandsDir, "coder.md"),
|
|
27
|
+
reviewer: path.posix.join(roleCommandsDir, "reviewer.md")
|
|
28
|
+
},
|
|
29
|
+
roleLogPaths: {
|
|
30
|
+
"project-manager": path.posix.join(logsDir, "project-manager.log"),
|
|
31
|
+
architect: path.posix.join(logsDir, "architect.log"),
|
|
32
|
+
coder: path.posix.join(logsDir, "coder.log"),
|
|
33
|
+
reviewer: path.posix.join(logsDir, "reviewer.log")
|
|
34
|
+
},
|
|
35
|
+
architecturePlanPath: path.posix.join(handoffDir, "architecture-plan.md"),
|
|
36
|
+
implementationLogPath: path.posix.join(handoffDir, "implementation-log.md"),
|
|
37
|
+
validationLogPath: path.posix.join(handoffDir, "validation-log.md"),
|
|
38
|
+
reviewReportPath: path.posix.join(handoffDir, "review-report.md")
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
async ensureHandoffStructure(input) {
|
|
42
|
+
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
43
|
+
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.handoffDir));
|
|
44
|
+
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.roleCommandsDir));
|
|
45
|
+
await fs.ensureDir(resolveRepoPath(input.repoRoot, paths.logsDir));
|
|
46
|
+
return paths;
|
|
47
|
+
},
|
|
48
|
+
async createArtifactTemplates(input) {
|
|
49
|
+
const paths = await this.ensureHandoffStructure(input);
|
|
50
|
+
const files = [
|
|
51
|
+
[paths.roleCommandPaths.architect, renderRoleCommandTemplate(input.taskSlug, "architect")],
|
|
52
|
+
[paths.roleCommandPaths.coder, renderRoleCommandTemplate(input.taskSlug, "coder")],
|
|
53
|
+
[paths.roleCommandPaths.reviewer, renderRoleCommandTemplate(input.taskSlug, "reviewer")],
|
|
54
|
+
[paths.architecturePlanPath, renderArchitecturePlanTemplate(input.taskSlug)],
|
|
55
|
+
[paths.implementationLogPath, renderImplementationLogTemplate(input.taskSlug)],
|
|
56
|
+
[paths.validationLogPath, renderValidationLogTemplate(input.taskSlug)],
|
|
57
|
+
[paths.reviewReportPath, renderReviewReportTemplate(input.taskSlug)]
|
|
58
|
+
];
|
|
59
|
+
const created = [];
|
|
60
|
+
for (const [artifactPath, content] of files) {
|
|
61
|
+
const didCreate = await fs.ensureFile(resolveRepoPath(input.repoRoot, artifactPath), content, {
|
|
62
|
+
overwrite: input.overwrite
|
|
63
|
+
});
|
|
64
|
+
if (didCreate) {
|
|
65
|
+
created.push(artifactPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return created;
|
|
69
|
+
},
|
|
70
|
+
async listArtifacts(input) {
|
|
71
|
+
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
72
|
+
const checks = [];
|
|
73
|
+
for (const [kind, pathKey] of ARTIFACT_PATH_KEYS) {
|
|
74
|
+
const artifactPath = paths[pathKey];
|
|
75
|
+
if (typeof artifactPath !== "string") {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const content = await readTextOrNull(fs, resolveRepoPath(input.repoRoot, artifactPath));
|
|
79
|
+
checks.push(checkMarkdownArtifact(kind, artifactPath, content));
|
|
80
|
+
}
|
|
81
|
+
return { paths, checks };
|
|
82
|
+
},
|
|
83
|
+
async readArtifact(input) {
|
|
84
|
+
const absolutePath = resolveRepoPath(input.repoRoot, input.artifactPath);
|
|
85
|
+
if (!(await fs.pathExists(absolutePath))) {
|
|
86
|
+
throw new VcmError({
|
|
87
|
+
code: "ARTIFACT_MISSING",
|
|
88
|
+
message: `Artifact does not exist: ${input.artifactPath}`,
|
|
89
|
+
statusCode: 404
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return fs.readText(absolutePath);
|
|
93
|
+
},
|
|
94
|
+
async resolveRoleCommandPath(input) {
|
|
95
|
+
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
96
|
+
if (!DISPATCHABLE_ROLES.includes(input.role)) {
|
|
97
|
+
throw new VcmError({
|
|
98
|
+
code: "ROLE_NOT_DISPATCHABLE",
|
|
99
|
+
message: `${input.role} cannot receive role commands.`,
|
|
100
|
+
statusCode: 400
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const commandPath = paths.roleCommandPaths[input.role];
|
|
104
|
+
if (await fs.pathExists(resolveRepoPath(input.repoRoot, commandPath))) {
|
|
105
|
+
return commandPath;
|
|
106
|
+
}
|
|
107
|
+
const legacyCommandPath = getLegacyRoleCommandPath(paths.roleCommandsDir, input.role);
|
|
108
|
+
if (await fs.pathExists(resolveRepoPath(input.repoRoot, legacyCommandPath))) {
|
|
109
|
+
return legacyCommandPath;
|
|
110
|
+
}
|
|
111
|
+
return commandPath;
|
|
112
|
+
},
|
|
113
|
+
async readRoleCommand(input) {
|
|
114
|
+
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
115
|
+
const primaryCommandPath = paths.roleCommandPaths[input.role];
|
|
116
|
+
const commandPath = await this.resolveRoleCommandPath(input);
|
|
117
|
+
const absolutePath = resolveRepoPath(input.repoRoot, commandPath);
|
|
118
|
+
if (!(await fs.pathExists(absolutePath))) {
|
|
119
|
+
throw new VcmError({
|
|
120
|
+
code: "ROLE_COMMAND_MISSING",
|
|
121
|
+
message: `Missing role command: ${commandPath}`,
|
|
122
|
+
statusCode: 404,
|
|
123
|
+
hint: "Ask project-manager to produce the role command first."
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const content = await fs.readText(absolutePath);
|
|
127
|
+
if (!content.trim()) {
|
|
128
|
+
throw new VcmError({
|
|
129
|
+
code: "ROLE_COMMAND_EMPTY",
|
|
130
|
+
message: `Role command is empty: ${commandPath}`,
|
|
131
|
+
statusCode: 400,
|
|
132
|
+
hint: `Ask project-manager to write the real instruction in ${primaryCommandPath}. Keep all files under ${input.handoffDir}.`
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (ROLE_COMMAND_PLACEHOLDER_PATTERN.test(content)) {
|
|
136
|
+
throw new VcmError({
|
|
137
|
+
code: "ROLE_COMMAND_NOT_READY",
|
|
138
|
+
message: `Role command is not ready: ${commandPath}`,
|
|
139
|
+
statusCode: 409,
|
|
140
|
+
hint: `Ask project-manager to write the real instruction in ${primaryCommandPath}. Keep all files under ${input.handoffDir}.`
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return content;
|
|
144
|
+
},
|
|
145
|
+
async saveRoleCommand(input) {
|
|
146
|
+
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
147
|
+
await fs.writeText(resolveRepoPath(input.repoRoot, paths.roleCommandPaths[input.role]), input.content);
|
|
148
|
+
},
|
|
149
|
+
async appendRoleLog(input) {
|
|
150
|
+
if (!ROLE_NAMES.includes(input.role)) {
|
|
151
|
+
throw new VcmError({
|
|
152
|
+
code: "UNKNOWN_ROLE",
|
|
153
|
+
message: `Unknown role: ${input.role}`,
|
|
154
|
+
statusCode: 400
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const paths = this.getHandoffPaths(input.repoRoot, input.handoffDir);
|
|
158
|
+
await fs.appendText(resolveRepoPath(input.repoRoot, paths.roleLogPaths[input.role]), input.content);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function getLegacyRoleCommandPath(roleCommandsDir, role) {
|
|
163
|
+
return path.posix.join(roleCommandsDir, `${role}-command.md`);
|
|
164
|
+
}
|
|
165
|
+
async function readTextOrNull(fs, absolutePath) {
|
|
166
|
+
if (!(await fs.pathExists(absolutePath))) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return fs.readText(absolutePath);
|
|
170
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { VcmError } from "../errors.js";
|
|
2
|
+
export function createCommandDispatcher(deps) {
|
|
3
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
4
|
+
return {
|
|
5
|
+
async dispatchRoleCommand(input) {
|
|
6
|
+
const task = await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
|
|
7
|
+
await deps.artifactService.readRoleCommand({
|
|
8
|
+
repoRoot: input.repoRoot,
|
|
9
|
+
handoffDir: task.handoffDir,
|
|
10
|
+
role: input.role
|
|
11
|
+
});
|
|
12
|
+
const commandPath = await deps.artifactService.resolveRoleCommandPath({
|
|
13
|
+
repoRoot: input.repoRoot,
|
|
14
|
+
handoffDir: task.handoffDir,
|
|
15
|
+
role: input.role
|
|
16
|
+
});
|
|
17
|
+
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.role);
|
|
18
|
+
if (!session || session.status !== "running") {
|
|
19
|
+
throw new VcmError({
|
|
20
|
+
code: "SESSION_NOT_RUNNING",
|
|
21
|
+
message: `${input.role} session is not running.`,
|
|
22
|
+
statusCode: 409,
|
|
23
|
+
hint: `Start the ${input.role} session before sending a role command.`
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const instruction = `Please read and execute the role command at: ${commandPath}`;
|
|
27
|
+
deps.runtime.write(session.id, `${instruction}\r`);
|
|
28
|
+
return {
|
|
29
|
+
taskSlug: input.taskSlug,
|
|
30
|
+
role: input.role,
|
|
31
|
+
commandPath,
|
|
32
|
+
instruction,
|
|
33
|
+
dispatchedAt: now()
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { ROLE_NAMES } from "../../shared/constants.js";
|
|
4
|
+
import { VcmError } from "../errors.js";
|
|
5
|
+
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
|
+
import { renderManualStagePrompt, renderMessageEnvelope } from "../templates/message-envelope.js";
|
|
7
|
+
const PM_ROLE = "project-manager";
|
|
8
|
+
const PM_TO_ROLE_TYPES = new Set(["task", "question", "review-request", "revise", "cancel"]);
|
|
9
|
+
const ROLE_TO_PM_TYPES = new Set(["result", "question", "blocked", "finding"]);
|
|
10
|
+
export function createMessageService(deps) {
|
|
11
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
12
|
+
const id = deps.id ?? (() => `msg_${randomUUID()}`);
|
|
13
|
+
return {
|
|
14
|
+
listMessages(input) {
|
|
15
|
+
return readLatestMessages(deps.fs, getMessagesPath(input.repoRoot, input.stateRoot, input.taskSlug));
|
|
16
|
+
},
|
|
17
|
+
async sendMessage(input) {
|
|
18
|
+
await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
|
|
19
|
+
validateMessagePolicy(input.fromRole, input.toRole, input.type);
|
|
20
|
+
const timestamp = now();
|
|
21
|
+
const message = {
|
|
22
|
+
id: id(),
|
|
23
|
+
taskSlug: input.taskSlug,
|
|
24
|
+
fromRole: input.fromRole,
|
|
25
|
+
toRole: input.toRole,
|
|
26
|
+
type: input.type,
|
|
27
|
+
body: input.body,
|
|
28
|
+
artifactRefs: input.artifactRefs ?? [],
|
|
29
|
+
parentMessageId: input.parentMessageId,
|
|
30
|
+
status: "queued",
|
|
31
|
+
createdAt: timestamp
|
|
32
|
+
};
|
|
33
|
+
message.bodyPath = await writeMessageBody(deps.fs, input.repoRoot, input.handoffDir, message);
|
|
34
|
+
const state = await this.getOrchestrationState(input);
|
|
35
|
+
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.toRole);
|
|
36
|
+
if (!session || session.status !== "running") {
|
|
37
|
+
message.status = "queued";
|
|
38
|
+
message.failureReason = `${input.toRole} session is not running.`;
|
|
39
|
+
await appendMessageSnapshot(deps.fs, input, message);
|
|
40
|
+
return { message, delivered: false, requiresUserApproval: false };
|
|
41
|
+
}
|
|
42
|
+
if (state.mode === "manual") {
|
|
43
|
+
message.status = "pending_approval";
|
|
44
|
+
await appendMessageSnapshot(deps.fs, input, message);
|
|
45
|
+
return { message, delivered: false, requiresUserApproval: true };
|
|
46
|
+
}
|
|
47
|
+
if (state.paused) {
|
|
48
|
+
message.status = "queued";
|
|
49
|
+
message.failureReason = "Auto orchestration is paused.";
|
|
50
|
+
await appendMessageSnapshot(deps.fs, input, message);
|
|
51
|
+
return { message, delivered: false, requiresUserApproval: false };
|
|
52
|
+
}
|
|
53
|
+
const delivered = {
|
|
54
|
+
...message,
|
|
55
|
+
status: "delivered",
|
|
56
|
+
deliveredAt: timestamp,
|
|
57
|
+
failureReason: undefined
|
|
58
|
+
};
|
|
59
|
+
deps.runtime.write(session.id, `${renderMessageEnvelope(delivered)}\r`);
|
|
60
|
+
await appendMessageSnapshot(deps.fs, input, delivered);
|
|
61
|
+
return { message: delivered, delivered: true, requiresUserApproval: false };
|
|
62
|
+
},
|
|
63
|
+
async stageMessage(input) {
|
|
64
|
+
const message = await getMessageOrThrow(deps.fs, input);
|
|
65
|
+
const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, message.toRole);
|
|
66
|
+
if (!session || session.status !== "running") {
|
|
67
|
+
throw new VcmError({
|
|
68
|
+
code: "MESSAGE_TARGET_NOT_RUNNING",
|
|
69
|
+
message: `${message.toRole} session is not running.`,
|
|
70
|
+
statusCode: 409,
|
|
71
|
+
hint: `Start the ${message.toRole} session before staging this message.`
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const staged = {
|
|
75
|
+
...message,
|
|
76
|
+
status: "staged",
|
|
77
|
+
stagedAt: now(),
|
|
78
|
+
failureReason: undefined
|
|
79
|
+
};
|
|
80
|
+
deps.runtime.write(session.id, renderManualStagePrompt(staged));
|
|
81
|
+
await appendMessageSnapshot(deps.fs, input, staged);
|
|
82
|
+
return staged;
|
|
83
|
+
},
|
|
84
|
+
approveMessage(input) {
|
|
85
|
+
return this.stageMessage(input);
|
|
86
|
+
},
|
|
87
|
+
async rejectMessage(input) {
|
|
88
|
+
const message = await getMessageOrThrow(deps.fs, input);
|
|
89
|
+
const rejected = {
|
|
90
|
+
...message,
|
|
91
|
+
status: "rejected",
|
|
92
|
+
failureReason: undefined
|
|
93
|
+
};
|
|
94
|
+
await appendMessageSnapshot(deps.fs, input, rejected);
|
|
95
|
+
return rejected;
|
|
96
|
+
},
|
|
97
|
+
async getOrchestrationState(input) {
|
|
98
|
+
const statePath = getOrchestrationStatePath(input.repoRoot, input.stateRoot, input.taskSlug);
|
|
99
|
+
if (!(await deps.fs.pathExists(statePath))) {
|
|
100
|
+
return {
|
|
101
|
+
taskSlug: input.taskSlug,
|
|
102
|
+
mode: "manual",
|
|
103
|
+
paused: false,
|
|
104
|
+
updatedAt: now()
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return deps.fs.readJson(statePath);
|
|
108
|
+
},
|
|
109
|
+
async updateOrchestrationState(input) {
|
|
110
|
+
const current = await this.getOrchestrationState(input);
|
|
111
|
+
const next = {
|
|
112
|
+
...current,
|
|
113
|
+
mode: input.mode ?? current.mode,
|
|
114
|
+
paused: input.paused ?? current.paused,
|
|
115
|
+
updatedAt: now()
|
|
116
|
+
};
|
|
117
|
+
await deps.fs.writeJsonAtomic(getOrchestrationStatePath(input.repoRoot, input.stateRoot, input.taskSlug), next);
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function validateMessagePolicy(fromRole, toRole, type) {
|
|
123
|
+
if (!ROLE_NAMES.includes(toRole)) {
|
|
124
|
+
throw new VcmError({
|
|
125
|
+
code: "MESSAGE_TARGET_UNKNOWN",
|
|
126
|
+
message: `Unknown target role: ${toRole}`,
|
|
127
|
+
statusCode: 400
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (fromRole === "user") {
|
|
131
|
+
if (toRole === PM_ROLE && type === "user-request") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
throw new VcmError({
|
|
135
|
+
code: "MESSAGE_POLICY_DENIED",
|
|
136
|
+
message: "User messages can only target project-manager as user-request.",
|
|
137
|
+
statusCode: 403
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (!ROLE_NAMES.includes(fromRole)) {
|
|
141
|
+
throw new VcmError({
|
|
142
|
+
code: "MESSAGE_SENDER_UNKNOWN",
|
|
143
|
+
message: `Unknown sender role: ${fromRole}`,
|
|
144
|
+
statusCode: 400
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (fromRole === PM_ROLE && toRole !== PM_ROLE && PM_TO_ROLE_TYPES.has(type)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (fromRole !== PM_ROLE && toRole === PM_ROLE && ROLE_TO_PM_TYPES.has(type)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
throw new VcmError({
|
|
154
|
+
code: "MESSAGE_POLICY_DENIED",
|
|
155
|
+
message: `${fromRole} cannot send ${type} messages to ${toRole}.`,
|
|
156
|
+
statusCode: 403,
|
|
157
|
+
hint: "Use project-manager as the orchestration hub."
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function writeMessageBody(fs, repoRoot, handoffDir, message) {
|
|
161
|
+
const bodyPath = path.posix.join(handoffDir, "messages", `${message.id}.md`);
|
|
162
|
+
await fs.writeText(resolveRepoPath(repoRoot, bodyPath), renderMessageBodyFile(message));
|
|
163
|
+
return bodyPath;
|
|
164
|
+
}
|
|
165
|
+
function renderMessageBodyFile(message) {
|
|
166
|
+
const artifactRefs = message.artifactRefs.length > 0
|
|
167
|
+
? message.artifactRefs.map((artifact) => `- ${artifact}`).join("\n")
|
|
168
|
+
: "- none";
|
|
169
|
+
return `# VCM Message ${message.id}
|
|
170
|
+
|
|
171
|
+
- Task: ${message.taskSlug}
|
|
172
|
+
- From: ${message.fromRole}
|
|
173
|
+
- To: ${message.toRole}
|
|
174
|
+
- Type: ${message.type}
|
|
175
|
+
|
|
176
|
+
## Body
|
|
177
|
+
|
|
178
|
+
${message.body}
|
|
179
|
+
|
|
180
|
+
## Artifact Refs
|
|
181
|
+
|
|
182
|
+
${artifactRefs}
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
async function getMessageOrThrow(fs, input) {
|
|
186
|
+
const messages = await readLatestMessages(fs, getMessagesPath(input.repoRoot, input.stateRoot, input.taskSlug));
|
|
187
|
+
const message = messages.find((candidate) => candidate.id === input.messageId);
|
|
188
|
+
if (!message) {
|
|
189
|
+
throw new VcmError({
|
|
190
|
+
code: "MESSAGE_MISSING",
|
|
191
|
+
message: `Message does not exist: ${input.messageId}`,
|
|
192
|
+
statusCode: 404
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return message;
|
|
196
|
+
}
|
|
197
|
+
async function readLatestMessages(fs, messagesPath) {
|
|
198
|
+
if (!(await fs.pathExists(messagesPath))) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const latest = new Map();
|
|
202
|
+
const lines = (await fs.readText(messagesPath)).split(/\r?\n/).filter(Boolean);
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
const message = JSON.parse(line);
|
|
205
|
+
latest.set(message.id, message);
|
|
206
|
+
}
|
|
207
|
+
return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
208
|
+
}
|
|
209
|
+
async function appendMessageSnapshot(fs, input, message) {
|
|
210
|
+
await fs.appendText(getMessagesPath(input.repoRoot, input.stateRoot, input.taskSlug), `${JSON.stringify(message)}\n`);
|
|
211
|
+
}
|
|
212
|
+
function getMessagesPath(repoRoot, stateRoot, taskSlug) {
|
|
213
|
+
return path.join(repoRoot, stateRoot, "messages", `${taskSlug}.jsonl`);
|
|
214
|
+
}
|
|
215
|
+
function getOrchestrationStatePath(repoRoot, stateRoot, taskSlug) {
|
|
216
|
+
return path.join(repoRoot, stateRoot, "orchestration", `${taskSlug}.json`);
|
|
217
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ROLE_NAMES } from "../../shared/constants.js";
|
|
3
|
+
import { VcmError } from "../errors.js";
|
|
4
|
+
export function createProjectService(deps) {
|
|
5
|
+
let currentProject = null;
|
|
6
|
+
return {
|
|
7
|
+
async connectProject(input) {
|
|
8
|
+
const repoRoot = path.resolve(input.repoPath);
|
|
9
|
+
if (!(await deps.git.isRepo(repoRoot))) {
|
|
10
|
+
throw new VcmError({
|
|
11
|
+
code: "INVALID_REPO",
|
|
12
|
+
message: "Selected path is not a Git repository.",
|
|
13
|
+
statusCode: 400
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const config = buildDefaultProjectConfig(repoRoot);
|
|
17
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.handoffRoot));
|
|
18
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "tasks"));
|
|
19
|
+
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "sessions"));
|
|
20
|
+
await this.saveConfig(config, true);
|
|
21
|
+
const branch = await deps.git.getCurrentBranch(repoRoot);
|
|
22
|
+
const isDirty = await deps.git.isDirty(repoRoot);
|
|
23
|
+
const warnings = [];
|
|
24
|
+
if (branch === "main" || branch === "master") {
|
|
25
|
+
warnings.push(`You are on ${branch}. Consider creating a task branch before coding.`);
|
|
26
|
+
}
|
|
27
|
+
if (!(await deps.claude.isAvailable(config.claudeCommand))) {
|
|
28
|
+
warnings.push("Claude Code command is not available. You can still inspect artifacts, but sessions will not start.");
|
|
29
|
+
}
|
|
30
|
+
currentProject = {
|
|
31
|
+
repoRoot,
|
|
32
|
+
branch,
|
|
33
|
+
isDirty,
|
|
34
|
+
config,
|
|
35
|
+
warnings
|
|
36
|
+
};
|
|
37
|
+
return currentProject;
|
|
38
|
+
},
|
|
39
|
+
async getCurrentProject() {
|
|
40
|
+
return currentProject;
|
|
41
|
+
},
|
|
42
|
+
async loadConfig(repoRoot) {
|
|
43
|
+
const configPath = this.getConfigPath(repoRoot);
|
|
44
|
+
if (!(await deps.fs.pathExists(configPath))) {
|
|
45
|
+
return buildDefaultProjectConfig(repoRoot);
|
|
46
|
+
}
|
|
47
|
+
return deps.fs.readJson(configPath);
|
|
48
|
+
},
|
|
49
|
+
async saveConfig(config, force = false) {
|
|
50
|
+
const configPath = this.getConfigPath(config.repoRoot);
|
|
51
|
+
if (!force && await deps.fs.pathExists(configPath)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await deps.fs.writeJsonAtomic(configPath, config);
|
|
55
|
+
},
|
|
56
|
+
getConfigPath(repoRoot) {
|
|
57
|
+
return path.join(repoRoot, ".vcm", "config.json");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function buildDefaultProjectConfig(repoRoot) {
|
|
62
|
+
return {
|
|
63
|
+
version: 1,
|
|
64
|
+
repoRoot,
|
|
65
|
+
defaultRoles: [...ROLE_NAMES],
|
|
66
|
+
handoffRoot: ".ai/handoffs",
|
|
67
|
+
stateRoot: ".vcm",
|
|
68
|
+
terminalBackend: "node-pty",
|
|
69
|
+
claudeCommand: process.env.VCM_CLAUDE_COMMAND || "claude"
|
|
70
|
+
};
|
|
71
|
+
}
|