vibe-coding-master 0.2.10 → 0.2.11
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 +5 -4
- package/dist/backend/cli/install-vcm-harness.js +708 -0
- package/dist/backend/services/harness-service.js +1 -1
- package/dist/backend/templates/harness/architect-agent.js +32 -4
- package/dist/backend/templates/harness/claude-root.js +2 -2
- package/dist/backend/templates/harness/coder-agent.js +19 -0
- package/dist/backend/templates/harness/project-manager-agent.js +1 -1
- package/dist/backend/templates/harness/reviewer-agent.js +10 -1
- package/dist/backend/templates/harness/vcm-harness-bootstrap-skill.js +3 -1
- package/dist-frontend/assets/{index-DDrcJLOG.js → index-CT20u9Fk.js} +38 -38
- package/dist-frontend/index.html +1 -1
- package/docs/full-harness-baseline.md +5 -5
- package/docs/product-design.md +14 -7
- package/docs/vcm-cc-best-practices.md +54 -8
- package/package.json +1 -1
- package/scripts/install-vcm-harness.mjs +37 -1418
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { renderArchitectHarnessRules } from "../templates/harness/architect-agent.js";
|
|
8
|
+
import { renderCoderHarnessRules } from "../templates/harness/coder-agent.js";
|
|
9
|
+
import { renderRootClaudeHarnessRules } from "../templates/harness/claude-root.js";
|
|
10
|
+
import { renderGitignoreHarnessRules } from "../templates/harness/gitignore.js";
|
|
11
|
+
import { renderProjectManagerHarnessRules } from "../templates/harness/project-manager-agent.js";
|
|
12
|
+
import { renderPullRequestTemplateHarnessRules } from "../templates/harness/pull-request-template.js";
|
|
13
|
+
import { renderReviewerHarnessRules } from "../templates/harness/reviewer-agent.js";
|
|
14
|
+
import { renderVcmFinalAcceptanceSkillRules } from "../templates/harness/vcm-final-acceptance-skill.js";
|
|
15
|
+
import { renderVcmHarnessBootstrapSkillRules } from "../templates/harness/vcm-harness-bootstrap-skill.js";
|
|
16
|
+
import { renderVcmLongRunningValidationSkillRules } from "../templates/harness/vcm-long-running-validation-skill.js";
|
|
17
|
+
import { renderVcmRouteMessageSkillRules } from "../templates/harness/vcm-route-message-skill.js";
|
|
18
|
+
const HARNESS_VERSION = "0.3.0-fixed";
|
|
19
|
+
const CLI_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const APP_ROOT = path.resolve(CLI_DIR, "../../..");
|
|
21
|
+
const MANIFEST_PATH = ".ai/vcm-harness-manifest.json";
|
|
22
|
+
const HTML_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=\d+)? -->[\s\S]*?<!-- VCM:END -->/m;
|
|
23
|
+
const HASH_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=\d+)?\n[\s\S]*?# VCM:END/m;
|
|
24
|
+
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'`;
|
|
25
|
+
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'`;
|
|
26
|
+
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'`;
|
|
27
|
+
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"'`;
|
|
28
|
+
const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
|
|
29
|
+
const VCM_HOOK_DEFINITIONS = [
|
|
30
|
+
{ eventName: "PreToolUse", matcher: "Bash", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
|
|
31
|
+
{ eventName: "UserPromptSubmit", command: VCM_HOOK_COMMAND, timeout: 5 },
|
|
32
|
+
{ eventName: "Stop", command: VCM_STOP_HOOK_COMMAND, timeout: 10 },
|
|
33
|
+
{ eventName: "PermissionRequest", command: VCM_PERMISSION_REQUEST_HOOK_COMMAND, timeout: 5 }
|
|
34
|
+
];
|
|
35
|
+
const AGENT_FRONTMATTER = {
|
|
36
|
+
"project-manager": {
|
|
37
|
+
description: "User-facing VCM orchestration role for task clarification, role routing, handoffs, acceptance, and PR preparation."
|
|
38
|
+
},
|
|
39
|
+
architect: {
|
|
40
|
+
description: "VCM architecture role for plans, module boundaries, public contracts, verifiable behavior, and docs sync."
|
|
41
|
+
},
|
|
42
|
+
coder: {
|
|
43
|
+
description: "VCM implementation role for scoped code changes and focused tests."
|
|
44
|
+
},
|
|
45
|
+
reviewer: {
|
|
46
|
+
description: "VCM independent review role for acceptance, test adequacy, scope checks, and risk findings."
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const MANAGED_FILES = [
|
|
50
|
+
{
|
|
51
|
+
path: "CLAUDE.md",
|
|
52
|
+
title: "CLAUDE.md",
|
|
53
|
+
commentStyle: "html",
|
|
54
|
+
category: "root-rules",
|
|
55
|
+
blankLineBeforeEnd: true,
|
|
56
|
+
content: renderRootClaudeHarnessRules()
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: ".gitignore",
|
|
60
|
+
title: ".gitignore",
|
|
61
|
+
commentStyle: "hash",
|
|
62
|
+
category: "ignore-rules",
|
|
63
|
+
content: renderGitignoreHarnessRules()
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: ".claude/agents/project-manager.md",
|
|
67
|
+
title: "Project Manager Agent",
|
|
68
|
+
agentName: "project-manager",
|
|
69
|
+
commentStyle: "html",
|
|
70
|
+
category: "core-agent",
|
|
71
|
+
content: renderProjectManagerHarnessRules()
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: ".claude/agents/architect.md",
|
|
75
|
+
title: "Architect Agent",
|
|
76
|
+
agentName: "architect",
|
|
77
|
+
commentStyle: "html",
|
|
78
|
+
category: "core-agent",
|
|
79
|
+
blankLineBeforeEnd: true,
|
|
80
|
+
content: renderArchitectHarnessRules()
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
path: ".claude/agents/coder.md",
|
|
84
|
+
title: "Coder Agent",
|
|
85
|
+
agentName: "coder",
|
|
86
|
+
commentStyle: "html",
|
|
87
|
+
category: "core-agent",
|
|
88
|
+
content: renderCoderHarnessRules()
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
path: ".claude/agents/reviewer.md",
|
|
92
|
+
title: "Reviewer Agent",
|
|
93
|
+
agentName: "reviewer",
|
|
94
|
+
commentStyle: "html",
|
|
95
|
+
category: "core-agent",
|
|
96
|
+
content: renderReviewerHarnessRules()
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
path: ".github/pull_request_template.md",
|
|
100
|
+
title: "Pull Request Template",
|
|
101
|
+
commentStyle: "html",
|
|
102
|
+
category: "pull-request-template",
|
|
103
|
+
content: renderPullRequestTemplateHarnessRules()
|
|
104
|
+
}
|
|
105
|
+
];
|
|
106
|
+
const DURABLE_DOC_TEMPLATES = [
|
|
107
|
+
{
|
|
108
|
+
path: "docs/ARCHITECTURE.md",
|
|
109
|
+
content: "# Architecture\n"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
path: "docs/TESTING.md",
|
|
113
|
+
content: "# Testing\n"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
path: "docs/known-issues.md",
|
|
117
|
+
content: "# Known Issues\n"
|
|
118
|
+
}
|
|
119
|
+
];
|
|
120
|
+
const WHOLE_FILES = [
|
|
121
|
+
{
|
|
122
|
+
path: ".ai/tools/generate-module-index",
|
|
123
|
+
category: "generated-context-tool",
|
|
124
|
+
mode: 0o755,
|
|
125
|
+
templatePath: "scripts/harness-tools/generate-module-index"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
path: ".ai/tools/generate-public-surface",
|
|
129
|
+
category: "generated-context-tool",
|
|
130
|
+
mode: 0o755,
|
|
131
|
+
templatePath: "scripts/harness-tools/generate-public-surface"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
path: ".claude/skills/vcm-final-acceptance/SKILL.md",
|
|
135
|
+
category: "skill",
|
|
136
|
+
mode: 0o644,
|
|
137
|
+
content: renderSkillFile("VCM Final Acceptance Skill", "vcm-final-acceptance", "Use when project-manager is ready to decide whether a VCM-managed task can be accepted, returned for follow-up, or blocked for a decision.", renderVcmFinalAcceptanceSkillRules())
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
path: ".claude/skills/vcm-harness-bootstrap/SKILL.md",
|
|
141
|
+
category: "skill",
|
|
142
|
+
mode: 0o644,
|
|
143
|
+
content: renderSkillFile("VCM Harness Bootstrap Skill", "vcm-harness-bootstrap", "Use when VCM needs AI-assisted project understanding to finish or refresh project-specific harness content.", renderVcmHarnessBootstrapSkillRules())
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
path: ".claude/skills/vcm-long-running-validation/SKILL.md",
|
|
147
|
+
category: "skill",
|
|
148
|
+
mode: 0o644,
|
|
149
|
+
content: renderSkillFile("VCM Long-Running Validation Skill", "vcm-long-running-validation", "Use for builds, browser checks, E2E tests, release suites, or any validation command that may take long enough for shell-completion callbacks to become unreliable.", renderVcmLongRunningValidationSkillRules())
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
path: ".claude/skills/vcm-route-message/SKILL.md",
|
|
153
|
+
category: "skill",
|
|
154
|
+
mode: 0o644,
|
|
155
|
+
content: renderSkillFile("VCM Route Message Skill", "vcm-route-message", "Use when a VCM role needs to hand off work, ask a question, report a result, report a blocker, or raise a finding to another VCM role.", renderVcmRouteMessageSkillRules())
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
path: ".ai/tools/run-long-check",
|
|
159
|
+
category: "runtime-tool",
|
|
160
|
+
mode: 0o755,
|
|
161
|
+
templatePath: "scripts/harness-tools/run-long-check"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
path: ".ai/tools/watch-job",
|
|
165
|
+
category: "runtime-tool",
|
|
166
|
+
mode: 0o755,
|
|
167
|
+
templatePath: "scripts/harness-tools/watch-job"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
path: ".ai/tools/vcm-bash-guard",
|
|
171
|
+
category: "runtime-tool",
|
|
172
|
+
mode: 0o755,
|
|
173
|
+
templatePath: "scripts/harness-tools/vcm-bash-guard"
|
|
174
|
+
}
|
|
175
|
+
];
|
|
176
|
+
const LEGACY_FLAT_SKILL_FILES = [
|
|
177
|
+
{
|
|
178
|
+
path: ".claude/skills/vcm-final-acceptance.md",
|
|
179
|
+
replacementPath: ".claude/skills/vcm-final-acceptance/SKILL.md"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
path: ".claude/skills/vcm-harness-bootstrap.md",
|
|
183
|
+
replacementPath: ".claude/skills/vcm-harness-bootstrap/SKILL.md"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
path: ".claude/skills/vcm-long-running-validation.md",
|
|
187
|
+
replacementPath: ".claude/skills/vcm-long-running-validation/SKILL.md"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
path: ".claude/skills/vcm-route-message.md",
|
|
191
|
+
replacementPath: ".claude/skills/vcm-route-message/SKILL.md"
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
async function main() {
|
|
195
|
+
const args = parseArgs(process.argv.slice(2));
|
|
196
|
+
if (args.help) {
|
|
197
|
+
printUsage();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!args.projectRoot) {
|
|
201
|
+
fail("Missing project root.");
|
|
202
|
+
}
|
|
203
|
+
const projectRoot = path.resolve(args.projectRoot);
|
|
204
|
+
const dryRun = args.dryRun;
|
|
205
|
+
const operations = [];
|
|
206
|
+
await assertDirectory(projectRoot, "Project root");
|
|
207
|
+
const manifest = await buildManifest(projectRoot);
|
|
208
|
+
await installManifest({ projectRoot, manifest, dryRun, operations });
|
|
209
|
+
for (const definition of MANAGED_FILES) {
|
|
210
|
+
await installManagedFile({ projectRoot, definition, dryRun, operations });
|
|
211
|
+
}
|
|
212
|
+
for (const template of DURABLE_DOC_TEMPLATES) {
|
|
213
|
+
await installDurableDocTemplate({ projectRoot, template, dryRun, operations });
|
|
214
|
+
}
|
|
215
|
+
await installClaudeSettings({ projectRoot, dryRun, operations });
|
|
216
|
+
for (const directory of fixedDirectories()) {
|
|
217
|
+
await ensureDirectory({ projectRoot, relativePath: directory, dryRun, operations });
|
|
218
|
+
}
|
|
219
|
+
for (const file of WHOLE_FILES) {
|
|
220
|
+
await installWholeFile({ projectRoot, file, dryRun, operations });
|
|
221
|
+
}
|
|
222
|
+
await removeLegacyFlatSkillFiles({ projectRoot, dryRun, operations });
|
|
223
|
+
printReport({ projectRoot, dryRun, operations });
|
|
224
|
+
}
|
|
225
|
+
function parseArgs(argv) {
|
|
226
|
+
const args = {
|
|
227
|
+
dryRun: false,
|
|
228
|
+
help: false,
|
|
229
|
+
projectRoot: undefined
|
|
230
|
+
};
|
|
231
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
232
|
+
const arg = argv[index];
|
|
233
|
+
if (arg === "--help" || arg === "-h") {
|
|
234
|
+
args.help = true;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (arg === "--dry-run") {
|
|
238
|
+
args.dryRun = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (arg.startsWith("--")) {
|
|
242
|
+
fail(`Unknown option: ${arg}`);
|
|
243
|
+
}
|
|
244
|
+
if (args.projectRoot) {
|
|
245
|
+
fail(`Unexpected argument: ${arg}`);
|
|
246
|
+
}
|
|
247
|
+
args.projectRoot = arg;
|
|
248
|
+
}
|
|
249
|
+
return args;
|
|
250
|
+
}
|
|
251
|
+
function printUsage() {
|
|
252
|
+
console.log(`Usage:
|
|
253
|
+
node scripts/install-vcm-harness.mjs <project-root>
|
|
254
|
+
node scripts/install-vcm-harness.mjs <project-root> --dry-run
|
|
255
|
+
|
|
256
|
+
Installs only fixed VCM harness content.
|
|
257
|
+
|
|
258
|
+
This deterministic installer handles VCM-owned managed blocks, VCM-owned whole
|
|
259
|
+
files, VCM Claude settings hooks, generic long-running helper tools, and the
|
|
260
|
+
harness manifest. It also creates blank durable project doc templates when
|
|
261
|
+
missing and installs Rust generated-context generator tools. It does not copy
|
|
262
|
+
example project docs, generated context artifacts, module-level architecture
|
|
263
|
+
docs, or task runtime handoff artifacts.`);
|
|
264
|
+
}
|
|
265
|
+
async function buildManifest(projectRoot) {
|
|
266
|
+
const current = await readOptionalJson(path.join(projectRoot, MANIFEST_PATH));
|
|
267
|
+
const now = new Date().toISOString();
|
|
268
|
+
return {
|
|
269
|
+
schemaVersion: 1,
|
|
270
|
+
manager: "vcm",
|
|
271
|
+
harnessVersion: HARNESS_VERSION,
|
|
272
|
+
installMode: "fixed",
|
|
273
|
+
installedAt: typeof current?.installedAt === "string" ? current.installedAt : now,
|
|
274
|
+
updatedAt: now,
|
|
275
|
+
runtimeRoots: [
|
|
276
|
+
".ai/vcm/",
|
|
277
|
+
".claude/worktrees/"
|
|
278
|
+
],
|
|
279
|
+
entries: [
|
|
280
|
+
manifestEntry(MANIFEST_PATH, "file", "harness-manifest", "whole-file"),
|
|
281
|
+
...MANAGED_FILES.map((file) => managedEntry(file)),
|
|
282
|
+
{
|
|
283
|
+
path: ".claude/settings.json",
|
|
284
|
+
entryType: "file",
|
|
285
|
+
category: "claude-settings",
|
|
286
|
+
ownership: "json-merge",
|
|
287
|
+
source: "vcm-template",
|
|
288
|
+
lifecycle: "long-term",
|
|
289
|
+
jsonOwnership: {
|
|
290
|
+
topLevelKeys: ["hooks", "env"],
|
|
291
|
+
hookMatchers: ["VCM"],
|
|
292
|
+
envKeys: ["BASH_DEFAULT_TIMEOUT_MS"]
|
|
293
|
+
},
|
|
294
|
+
uninstall: {
|
|
295
|
+
action: "remove-owned-json-keys",
|
|
296
|
+
requiresConfirmation: false
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
...fixedDirectories().map((directory) => manifestEntry(directory, "directory", directoryCategory(directory), "vcm-created")),
|
|
300
|
+
...WHOLE_FILES.map((file) => ({
|
|
301
|
+
path: file.path,
|
|
302
|
+
entryType: "file",
|
|
303
|
+
category: file.category,
|
|
304
|
+
ownership: "whole-file",
|
|
305
|
+
source: "vcm-template",
|
|
306
|
+
lifecycle: file.category === "runtime-tool" ? "conditional-long-term" : "long-term",
|
|
307
|
+
uninstall: {
|
|
308
|
+
action: "delete-file-if-unchanged",
|
|
309
|
+
requiresConfirmation: false
|
|
310
|
+
}
|
|
311
|
+
})),
|
|
312
|
+
derivedGeneratedContextEntry(".ai/generated/module-index.json", ".ai/tools/generate-module-index"),
|
|
313
|
+
derivedGeneratedContextEntry(".ai/generated/public-surface.json", ".ai/tools/generate-public-surface")
|
|
314
|
+
]
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function derivedGeneratedContextEntry(pathName, source) {
|
|
318
|
+
return {
|
|
319
|
+
path: pathName,
|
|
320
|
+
entryType: "file",
|
|
321
|
+
category: "generated-context",
|
|
322
|
+
ownership: "derived-artifact",
|
|
323
|
+
source,
|
|
324
|
+
lifecycle: "derived",
|
|
325
|
+
uninstall: {
|
|
326
|
+
action: "delete-derived-artifact",
|
|
327
|
+
requiresConfirmation: false
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function manifestEntry(pathName, entryType, category, ownership) {
|
|
332
|
+
return {
|
|
333
|
+
path: pathName,
|
|
334
|
+
entryType,
|
|
335
|
+
category,
|
|
336
|
+
ownership,
|
|
337
|
+
source: "vcm-template",
|
|
338
|
+
lifecycle: "long-term"
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function managedEntry(file) {
|
|
342
|
+
return {
|
|
343
|
+
path: file.path,
|
|
344
|
+
entryType: "file",
|
|
345
|
+
category: file.category,
|
|
346
|
+
ownership: "managed-block",
|
|
347
|
+
source: "vcm-template",
|
|
348
|
+
lifecycle: "long-term",
|
|
349
|
+
marker: {
|
|
350
|
+
type: file.commentStyle === "hash" ? "hash-comment" : "html",
|
|
351
|
+
begin: file.commentStyle === "hash" ? "# VCM:BEGIN version=1" : "<!-- VCM:BEGIN version=1 -->",
|
|
352
|
+
end: file.commentStyle === "hash" ? "# VCM:END" : "<!-- VCM:END -->"
|
|
353
|
+
},
|
|
354
|
+
uninstall: {
|
|
355
|
+
action: "remove-managed-block",
|
|
356
|
+
requiresConfirmation: false
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function fixedDirectories() {
|
|
361
|
+
return [
|
|
362
|
+
".claude/agents/",
|
|
363
|
+
".claude/skills/",
|
|
364
|
+
".claude/skills/vcm-final-acceptance/",
|
|
365
|
+
".claude/skills/vcm-harness-bootstrap/",
|
|
366
|
+
".claude/skills/vcm-long-running-validation/",
|
|
367
|
+
".claude/skills/vcm-route-message/",
|
|
368
|
+
".ai/tools/",
|
|
369
|
+
".ai/generated/"
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
function directoryCategory(directory) {
|
|
373
|
+
if (directory === ".claude/agents/") {
|
|
374
|
+
return "agent-directory";
|
|
375
|
+
}
|
|
376
|
+
if (directory === ".claude/skills/" || directory.startsWith(".claude/skills/")) {
|
|
377
|
+
return "skill-directory";
|
|
378
|
+
}
|
|
379
|
+
if (directory === ".ai/generated/") {
|
|
380
|
+
return "generated-context-directory";
|
|
381
|
+
}
|
|
382
|
+
return "harness-tool-directory";
|
|
383
|
+
}
|
|
384
|
+
async function installManifest({ projectRoot, manifest, dryRun, operations }) {
|
|
385
|
+
const targetPath = path.join(projectRoot, MANIFEST_PATH);
|
|
386
|
+
const currentManifest = await readOptionalJson(targetPath);
|
|
387
|
+
if (currentManifest && manifestBodyEqual(currentManifest, manifest)) {
|
|
388
|
+
operations.push(skip(MANIFEST_PATH, "unchanged"));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
await writeIfChanged({
|
|
392
|
+
targetPath,
|
|
393
|
+
relativePath: MANIFEST_PATH,
|
|
394
|
+
content: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
395
|
+
mode: 0o644,
|
|
396
|
+
dryRun,
|
|
397
|
+
operations,
|
|
398
|
+
action: "write fixed harness manifest"
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async function installManagedFile({ projectRoot, definition, dryRun, operations }) {
|
|
402
|
+
const targetPath = resolveInside(projectRoot, definition.path);
|
|
403
|
+
const block = renderManagedBlock(definition);
|
|
404
|
+
const currentContent = await readOptionalText(targetPath);
|
|
405
|
+
let nextContent;
|
|
406
|
+
if (currentContent === undefined || currentContent.trim() === "") {
|
|
407
|
+
nextContent = renderNewManagedFile(definition, block);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
const pattern = definition.commentStyle === "hash" ? HASH_BLOCK_PATTERN : HTML_BLOCK_PATTERN;
|
|
411
|
+
nextContent = pattern.test(currentContent)
|
|
412
|
+
? currentContent.replace(pattern, block)
|
|
413
|
+
: `${currentContent.trimEnd()}\n\n${block}\n`;
|
|
414
|
+
}
|
|
415
|
+
await writeIfChanged({
|
|
416
|
+
targetPath,
|
|
417
|
+
relativePath: definition.path,
|
|
418
|
+
content: ensureTrailingNewline(nextContent),
|
|
419
|
+
mode: 0o644,
|
|
420
|
+
dryRun,
|
|
421
|
+
operations,
|
|
422
|
+
action: "install fixed managed block"
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
async function installDurableDocTemplate({ projectRoot, template, dryRun, operations }) {
|
|
426
|
+
const targetPath = resolveInside(projectRoot, template.path);
|
|
427
|
+
const currentContent = await readOptionalText(targetPath);
|
|
428
|
+
if (currentContent !== undefined) {
|
|
429
|
+
operations.push(skip(template.path, "exists"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
await writeIfChanged({
|
|
433
|
+
targetPath,
|
|
434
|
+
relativePath: template.path,
|
|
435
|
+
content: ensureTrailingNewline(template.content),
|
|
436
|
+
mode: 0o644,
|
|
437
|
+
dryRun,
|
|
438
|
+
operations,
|
|
439
|
+
action: "create durable doc template"
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
function renderManagedBlock(definition) {
|
|
443
|
+
const body = definition.content.trimEnd();
|
|
444
|
+
const endSpacing = definition.blankLineBeforeEnd ? "\n\n" : "\n";
|
|
445
|
+
if (definition.commentStyle === "hash") {
|
|
446
|
+
return `# VCM:BEGIN version=1\n${body}${endSpacing}# VCM:END`;
|
|
447
|
+
}
|
|
448
|
+
return `<!-- VCM:BEGIN version=1 -->\n${body}${endSpacing}<!-- VCM:END -->`;
|
|
449
|
+
}
|
|
450
|
+
function renderNewManagedFile(definition, block) {
|
|
451
|
+
if (definition.agentName) {
|
|
452
|
+
const frontmatter = AGENT_FRONTMATTER[definition.agentName];
|
|
453
|
+
return `---\nname: ${definition.agentName}\ndescription: ${frontmatter.description}\ntools: Read, Grep, Glob, Bash, Edit, Write\n---\n\n# ${definition.title}\n\n${block}\n`;
|
|
454
|
+
}
|
|
455
|
+
return `# ${definition.title}\n\n${block}\n`;
|
|
456
|
+
}
|
|
457
|
+
function renderSkillFile(title, name, description, body) {
|
|
458
|
+
return `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${title}\n\n${body.trimEnd()}\n`;
|
|
459
|
+
}
|
|
460
|
+
async function installClaudeSettings({ projectRoot, dryRun, operations }) {
|
|
461
|
+
const targetPath = path.join(projectRoot, ".claude/settings.json");
|
|
462
|
+
const current = await readOptionalJson(targetPath) ?? {};
|
|
463
|
+
if (!isPlainObject(current)) {
|
|
464
|
+
fail(`Target JSON is not an object: ${targetPath}`);
|
|
465
|
+
}
|
|
466
|
+
const next = mergeVcmHooks(current);
|
|
467
|
+
await writeIfChanged({
|
|
468
|
+
targetPath,
|
|
469
|
+
relativePath: ".claude/settings.json",
|
|
470
|
+
content: `${JSON.stringify(next, null, 2)}\n`,
|
|
471
|
+
mode: 0o644,
|
|
472
|
+
dryRun,
|
|
473
|
+
operations,
|
|
474
|
+
action: "merge VCM Claude hooks"
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function mergeVcmHooks(settings) {
|
|
478
|
+
const next = structuredClone(settings);
|
|
479
|
+
const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
|
|
480
|
+
for (const [eventName, eventMatchers] of Object.entries(hooks)) {
|
|
481
|
+
if (!Array.isArray(eventMatchers)) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const remaining = eventMatchers.filter((matcher) => !isOwnedHookMatcher(matcher));
|
|
485
|
+
if (remaining.length > 0) {
|
|
486
|
+
hooks[eventName] = remaining;
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
delete hooks[eventName];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const definition of VCM_HOOK_DEFINITIONS) {
|
|
493
|
+
hooks[definition.eventName] = [
|
|
494
|
+
...(Array.isArray(hooks[definition.eventName]) ? hooks[definition.eventName] : []),
|
|
495
|
+
{
|
|
496
|
+
...(definition.matcher ? { matcher: definition.matcher } : {}),
|
|
497
|
+
hooks: [
|
|
498
|
+
{
|
|
499
|
+
type: "command",
|
|
500
|
+
command: definition.command,
|
|
501
|
+
timeout: definition.timeout
|
|
502
|
+
}
|
|
503
|
+
]
|
|
504
|
+
}
|
|
505
|
+
];
|
|
506
|
+
}
|
|
507
|
+
next.hooks = hooks;
|
|
508
|
+
const env = isPlainObject(next.env) ? { ...next.env } : {};
|
|
509
|
+
env.BASH_DEFAULT_TIMEOUT_MS = VCM_BASH_DEFAULT_TIMEOUT_MS;
|
|
510
|
+
next.env = env;
|
|
511
|
+
return next;
|
|
512
|
+
}
|
|
513
|
+
function isOwnedHookMatcher(matcher) {
|
|
514
|
+
if (!isPlainObject(matcher) || !Array.isArray(matcher.hooks)) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
return matcher.hooks.some((hook) => {
|
|
518
|
+
if (!isPlainObject(hook)) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
const command = typeof hook.command === "string" ? hook.command : "";
|
|
522
|
+
return command.includes("VCM") ||
|
|
523
|
+
command.includes("/api/hooks/claude-code") ||
|
|
524
|
+
command.includes("hook-event");
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
async function ensureDirectory({ projectRoot, relativePath, dryRun, operations }) {
|
|
528
|
+
const targetPath = resolveInside(projectRoot, relativePath);
|
|
529
|
+
if (await pathExists(targetPath)) {
|
|
530
|
+
operations.push(skip(relativePath, "exists"));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (dryRun) {
|
|
534
|
+
operations.push(plan(relativePath, "create fixed directory"));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
538
|
+
operations.push(done(relativePath, "created fixed directory"));
|
|
539
|
+
}
|
|
540
|
+
async function installWholeFile({ projectRoot, file, dryRun, operations }) {
|
|
541
|
+
const content = await wholeFileContent(file);
|
|
542
|
+
await writeIfChanged({
|
|
543
|
+
targetPath: resolveInside(projectRoot, file.path),
|
|
544
|
+
relativePath: file.path,
|
|
545
|
+
content: ensureTrailingNewline(content),
|
|
546
|
+
mode: file.mode,
|
|
547
|
+
dryRun,
|
|
548
|
+
operations,
|
|
549
|
+
action: "write fixed VCM file"
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
async function removeLegacyFlatSkillFiles({ projectRoot, dryRun, operations }) {
|
|
553
|
+
const wholeFilesByPath = new Map(WHOLE_FILES.map((file) => [file.path, file]));
|
|
554
|
+
for (const legacy of LEGACY_FLAT_SKILL_FILES) {
|
|
555
|
+
const targetPath = resolveInside(projectRoot, legacy.path);
|
|
556
|
+
const currentContent = await readOptionalText(targetPath);
|
|
557
|
+
if (currentContent === undefined) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const replacement = wholeFilesByPath.get(legacy.replacementPath);
|
|
561
|
+
if (!replacement) {
|
|
562
|
+
operations.push(skip(legacy.path, "missing replacement skill definition"));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const replacementContent = ensureTrailingNewline(await wholeFileContent(replacement));
|
|
566
|
+
const legacyExpectedContent = ensureTrailingNewline(stripSkillFrontmatter(replacementContent));
|
|
567
|
+
if (currentContent !== replacementContent && currentContent !== legacyExpectedContent) {
|
|
568
|
+
operations.push(skip(legacy.path, "legacy flat skill file differs; left in place"));
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (dryRun) {
|
|
572
|
+
operations.push(plan(legacy.path, "delete legacy flat skill file"));
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
await fs.rm(targetPath, { force: true });
|
|
576
|
+
operations.push(done(legacy.path, "deleted legacy flat skill file"));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function stripSkillFrontmatter(content) {
|
|
580
|
+
return content.replace(/^---\n[\s\S]*?\n---\n\n/, "");
|
|
581
|
+
}
|
|
582
|
+
async function wholeFileContent(file) {
|
|
583
|
+
if (typeof file.content === "string") {
|
|
584
|
+
return file.content;
|
|
585
|
+
}
|
|
586
|
+
if (typeof file.templatePath === "string") {
|
|
587
|
+
const templateAbsolutePath = path.join(APP_ROOT, file.templatePath);
|
|
588
|
+
const content = await readOptionalText(templateAbsolutePath);
|
|
589
|
+
if (content === undefined) {
|
|
590
|
+
fail(`Missing bundled harness template: ${file.templatePath}`);
|
|
591
|
+
}
|
|
592
|
+
return content;
|
|
593
|
+
}
|
|
594
|
+
fail(`Whole file entry has no content: ${file.path}`);
|
|
595
|
+
}
|
|
596
|
+
async function writeIfChanged({ targetPath, relativePath, content, mode, dryRun, operations, action }) {
|
|
597
|
+
const currentContent = await readOptionalText(targetPath);
|
|
598
|
+
if (currentContent === content) {
|
|
599
|
+
operations.push(skip(relativePath, "unchanged"));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (dryRun) {
|
|
603
|
+
operations.push(plan(relativePath, action));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
607
|
+
await fs.writeFile(targetPath, content, "utf8");
|
|
608
|
+
if (mode !== undefined) {
|
|
609
|
+
await fs.chmod(targetPath, mode);
|
|
610
|
+
}
|
|
611
|
+
operations.push(done(relativePath, action));
|
|
612
|
+
}
|
|
613
|
+
async function readOptionalJson(absolutePath) {
|
|
614
|
+
const content = await readOptionalText(absolutePath);
|
|
615
|
+
if (content === undefined || content.trim() === "") {
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
return JSON.parse(content);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
fail(`Invalid JSON file: ${absolutePath}\n${error.message}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async function readOptionalText(absolutePath) {
|
|
626
|
+
return fs.readFile(absolutePath, "utf8").catch((error) => {
|
|
627
|
+
if (error.code === "ENOENT") {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
throw error;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
async function assertDirectory(absolutePath, label) {
|
|
634
|
+
const stat = await fs.stat(absolutePath).catch((error) => {
|
|
635
|
+
if (error.code === "ENOENT") {
|
|
636
|
+
fail(`${label} not found: ${absolutePath}`);
|
|
637
|
+
}
|
|
638
|
+
throw error;
|
|
639
|
+
});
|
|
640
|
+
if (!stat.isDirectory()) {
|
|
641
|
+
fail(`${label} is not a directory: ${absolutePath}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function pathExists(absolutePath) {
|
|
645
|
+
return fs.stat(absolutePath).then(() => true, (error) => {
|
|
646
|
+
if (error.code === "ENOENT") {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
throw error;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
function manifestBodyEqual(left, right) {
|
|
653
|
+
const normalizedLeft = { ...left };
|
|
654
|
+
const normalizedRight = { ...right };
|
|
655
|
+
delete normalizedLeft.installedAt;
|
|
656
|
+
delete normalizedLeft.updatedAt;
|
|
657
|
+
delete normalizedRight.installedAt;
|
|
658
|
+
delete normalizedRight.updatedAt;
|
|
659
|
+
return JSON.stringify(normalizedLeft) === JSON.stringify(normalizedRight);
|
|
660
|
+
}
|
|
661
|
+
function resolveInside(root, relativePath) {
|
|
662
|
+
if (path.isAbsolute(relativePath)) {
|
|
663
|
+
fail(`Path must be relative: ${relativePath}`);
|
|
664
|
+
}
|
|
665
|
+
const normalized = path.normalize(relativePath);
|
|
666
|
+
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
667
|
+
fail(`Path escapes root: ${relativePath}`);
|
|
668
|
+
}
|
|
669
|
+
const resolved = path.resolve(root, normalized);
|
|
670
|
+
if (!isInside(root, resolved) && resolved !== root) {
|
|
671
|
+
fail(`Path escapes root: ${relativePath}`);
|
|
672
|
+
}
|
|
673
|
+
return resolved;
|
|
674
|
+
}
|
|
675
|
+
function isInside(root, candidate) {
|
|
676
|
+
const relative = path.relative(root, candidate);
|
|
677
|
+
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
678
|
+
}
|
|
679
|
+
function ensureTrailingNewline(value) {
|
|
680
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
681
|
+
}
|
|
682
|
+
function plan(pathName, action) {
|
|
683
|
+
return { status: "plan", path: pathName, action };
|
|
684
|
+
}
|
|
685
|
+
function done(pathName, action) {
|
|
686
|
+
return { status: "done", path: pathName, action };
|
|
687
|
+
}
|
|
688
|
+
function skip(pathName, reason) {
|
|
689
|
+
return { status: "skip", path: pathName, action: reason };
|
|
690
|
+
}
|
|
691
|
+
function printReport({ projectRoot, dryRun, operations }) {
|
|
692
|
+
console.log(`${dryRun ? "Dry-run" : "Applied"} VCM fixed harness install`);
|
|
693
|
+
console.log(`Project: ${projectRoot}`);
|
|
694
|
+
for (const operation of operations) {
|
|
695
|
+
console.log(`${operation.status.toUpperCase()} ${operation.path} - ${operation.action}`);
|
|
696
|
+
}
|
|
697
|
+
if (dryRun) {
|
|
698
|
+
console.log("No files changed. Re-run without --dry-run to apply.");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function isPlainObject(value) {
|
|
702
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
703
|
+
}
|
|
704
|
+
function fail(message) {
|
|
705
|
+
console.error(`VCM fixed harness install failed: ${message}`);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
await main();
|