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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -0
  3. package/dist/backend/adapters/claude-adapter.js +38 -0
  4. package/dist/backend/adapters/command-runner.js +33 -0
  5. package/dist/backend/adapters/filesystem.js +60 -0
  6. package/dist/backend/adapters/git-adapter.js +33 -0
  7. package/dist/backend/api/artifact-routes.js +109 -0
  8. package/dist/backend/api/message-routes.js +90 -0
  9. package/dist/backend/api/project-routes.js +17 -0
  10. package/dist/backend/api/session-routes.js +64 -0
  11. package/dist/backend/api/task-routes.js +30 -0
  12. package/dist/backend/errors.js +29 -0
  13. package/dist/backend/runtime/node-pty-runtime.js +162 -0
  14. package/dist/backend/runtime/session-registry.js +36 -0
  15. package/dist/backend/runtime/terminal-runtime.js +1 -0
  16. package/dist/backend/server.js +159 -0
  17. package/dist/backend/services/artifact-service.js +170 -0
  18. package/dist/backend/services/command-dispatcher.js +37 -0
  19. package/dist/backend/services/message-service.js +217 -0
  20. package/dist/backend/services/project-service.js +71 -0
  21. package/dist/backend/services/session-service.js +221 -0
  22. package/dist/backend/services/status-service.js +21 -0
  23. package/dist/backend/services/task-service.js +88 -0
  24. package/dist/backend/templates/handoff.js +76 -0
  25. package/dist/backend/templates/message-envelope.js +27 -0
  26. package/dist/backend/templates/role-command.js +21 -0
  27. package/dist/backend/templates/role-messaging-context.js +44 -0
  28. package/dist/backend/ws/terminal-ws.js +60 -0
  29. package/dist/cli/vcmctl.js +141 -0
  30. package/dist/main.js +63 -0
  31. package/dist/shared/constants.js +45 -0
  32. package/dist/shared/types/api.js +1 -0
  33. package/dist/shared/types/artifact.js +1 -0
  34. package/dist/shared/types/message.js +1 -0
  35. package/dist/shared/types/project.js +1 -0
  36. package/dist/shared/types/role.js +1 -0
  37. package/dist/shared/types/session.js +1 -0
  38. package/dist/shared/types/task.js +1 -0
  39. package/dist/shared/types/terminal.js +1 -0
  40. package/dist/shared/validation/artifact-check.js +64 -0
  41. package/dist/shared/validation/slug-check.js +22 -0
  42. package/dist-frontend/assets/index-Bah6k-Ix.css +32 -0
  43. package/dist-frontend/assets/index-EMaQuIB6.js +58 -0
  44. package/dist-frontend/index.html +13 -0
  45. package/docs/cc-best-practices.md +2142 -0
  46. package/docs/product-design.md +1597 -0
  47. package/docs/v1-architecture-design.md +1431 -0
  48. package/docs/v1-implementation-plan.md +1949 -0
  49. package/docs/v1-message-bus-orchestration-design.md +534 -0
  50. package/package.json +60 -0
  51. package/scripts/clean-build.mjs +12 -0
  52. 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
+ }