vibe-coding-master 0.2.7 → 0.2.9
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 +47 -13
- package/dist/backend/api/claude-hook-routes.js +7 -1
- package/dist/backend/gateway/gateway-command-parser.js +4 -0
- package/dist/backend/gateway/gateway-service.js +263 -32
- package/dist/backend/gateway/gateway-settings-service.js +34 -0
- package/dist/backend/server.js +3 -1
- package/dist/backend/services/claude-hook-service.js +33 -3
- package/dist/backend/services/harness-service.js +37 -32
- package/dist/backend/services/job-guard-service.js +126 -0
- package/dist/backend/services/message-service.js +1 -1
- package/dist/backend/templates/harness/architect-agent.js +19 -8
- package/dist/backend/templates/harness/claude-root.js +7 -11
- package/dist/backend/templates/harness/coder-agent.js +45 -17
- package/dist/backend/templates/harness/gitignore.js +3 -2
- package/dist/backend/templates/harness/project-manager-agent.js +16 -11
- package/dist/backend/templates/harness/reviewer-agent.js +25 -28
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +42 -31
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +37 -18
- package/dist-frontend/assets/index-DC9-SB7F.css +32 -0
- package/dist-frontend/assets/{index-DHuS-DYr.js → index-DDrcJLOG.js} +37 -37
- package/dist-frontend/index.html +2 -2
- package/docs/full-harness-baseline.md +7 -5
- package/docs/gateway-design.md +40 -6
- package/docs/product-design.md +11 -2
- package/docs/vcm-cc-best-practices.md +11 -4
- package/package.json +1 -1
- package/scripts/harness-tools/run-long-check +401 -0
- package/scripts/harness-tools/vcm-bash-guard +107 -0
- package/scripts/harness-tools/watch-job +204 -0
- package/scripts/install-vcm-harness.mjs +93 -387
- package/scripts/uninstall-vcm-harness.mjs +18 -1
- package/scripts/verify-package.mjs +3 -0
- package/dist/backend/templates/harness/known-issues-doc.js +0 -22
- package/dist-frontend/assets/index-7lq6YPCq.css +0 -32
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""VCM PreToolUse guard: deny background Bash inside VCM role sessions.
|
|
3
|
+
|
|
4
|
+
Reads the Claude Code PreToolUse hook payload on stdin. When the Bash tool
|
|
5
|
+
call would start background work (run_in_background, nohup, setsid, disown,
|
|
6
|
+
or a lone '&'), it prints a deny decision that redirects the role to the
|
|
7
|
+
vcm-long-running-validation skill. Anything else is allowed by staying
|
|
8
|
+
silent.
|
|
9
|
+
|
|
10
|
+
Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
|
|
11
|
+
shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
|
|
12
|
+
only sanctioned detached worker; the command it runs must still stay in the
|
|
13
|
+
supervised foreground process group.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
SKILL_HINT = (
|
|
20
|
+
"Use the vcm-long-running-validation skill instead: "
|
|
21
|
+
"`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
|
|
22
|
+
"`.ai/tools/watch-job <job-id>` in the same turn, repeating watch-job "
|
|
23
|
+
"until it reports a terminal result."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MAX_NESTED_SHELL_DEPTH = 3
|
|
27
|
+
QUOTED = re.compile(r"'([^']*)'|\"([^\"]*)\"")
|
|
28
|
+
SHELL_DASH_C = re.compile(r"(?:^|[\s;&|(])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\w*(?:\s|$)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def strip_quoted(command: str) -> str:
|
|
32
|
+
return QUOTED.sub(" ", command)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def quoted_segments(command: str) -> list[str]:
|
|
36
|
+
return [single or double for single, double in QUOTED.findall(command)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unquoted_ampersand(command_without_quotes: str) -> bool:
|
|
40
|
+
cleaned = re.sub(r"\d*>&\d*", " ", command_without_quotes)
|
|
41
|
+
cleaned = re.sub(r"<&\d*", " ", cleaned)
|
|
42
|
+
cleaned = cleaned.replace("&&", " ")
|
|
43
|
+
return bool(re.search(r"&\s*(?:$|[;\n)])", cleaned) or re.search(r"\s&\s", cleaned))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan_shell_command(command: str, depth: int = 0) -> list[str]:
|
|
47
|
+
reasons = []
|
|
48
|
+
stripped = strip_quoted(command)
|
|
49
|
+
if re.search(r"(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)", stripped):
|
|
50
|
+
reasons.append("nohup/setsid detach is forbidden")
|
|
51
|
+
if re.search(r"(?:^|[\s;&|(])disown(?:\s|$)", stripped):
|
|
52
|
+
reasons.append("disown is forbidden")
|
|
53
|
+
if unquoted_ampersand(stripped):
|
|
54
|
+
reasons.append("'&' background execution is forbidden")
|
|
55
|
+
|
|
56
|
+
# `sh -c '...'` quoted payloads are shell code, not plain strings.
|
|
57
|
+
if depth < MAX_NESTED_SHELL_DEPTH and SHELL_DASH_C.search(stripped):
|
|
58
|
+
for segment in quoted_segments(command):
|
|
59
|
+
reasons.extend(scan_shell_command(segment, depth + 1))
|
|
60
|
+
return reasons
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def background_reasons(tool_input: dict) -> list[str]:
|
|
64
|
+
reasons = []
|
|
65
|
+
if tool_input.get("run_in_background"):
|
|
66
|
+
reasons.append("Bash run_in_background is forbidden")
|
|
67
|
+
|
|
68
|
+
command = tool_input.get("command")
|
|
69
|
+
command = command if isinstance(command, str) else ""
|
|
70
|
+
|
|
71
|
+
reasons.extend(scan_shell_command(command))
|
|
72
|
+
return list(dict.fromkeys(reasons))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
raw = sys.stdin.read()
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
79
|
+
except ValueError:
|
|
80
|
+
return 0
|
|
81
|
+
if payload.get("tool_name") != "Bash":
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
tool_input = payload.get("tool_input")
|
|
85
|
+
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
86
|
+
reasons = background_reasons(tool_input)
|
|
87
|
+
if not reasons:
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
print(
|
|
91
|
+
json.dumps(
|
|
92
|
+
{
|
|
93
|
+
"hookSpecificOutput": {
|
|
94
|
+
"hookEventName": "PreToolUse",
|
|
95
|
+
"permissionDecision": "deny",
|
|
96
|
+
"permissionDecisionReason": (
|
|
97
|
+
"VCM forbids background Bash (" + "; ".join(reasons) + "). " + SKILL_HINT
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Watch one VCM long-running validation job in the foreground.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
.ai/tools/watch-job <job-id> [--window <duration>] [--interval <duration>]
|
|
6
|
+
|
|
7
|
+
watch-job renews the job supervision lease while it runs. When the watch
|
|
8
|
+
window elapses and the job is still running, it exits 125 WITHOUT stopping
|
|
9
|
+
the job; call watch-job again immediately in the same turn. The job ceiling
|
|
10
|
+
itself is enforced by the run-long-check worker, not by watch-job.
|
|
11
|
+
|
|
12
|
+
Exit codes:
|
|
13
|
+
0 success
|
|
14
|
+
1 failed
|
|
15
|
+
124 timeout (job hit its ceiling and was killed by the worker)
|
|
16
|
+
125 still running; call watch-job again now
|
|
17
|
+
4 orphaned or stale (job lost supervision and was killed, or its worker died)
|
|
18
|
+
2 usage error or unknown job id
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
MAX_WINDOW_SECONDS = 8 * 60
|
|
27
|
+
DEFAULT_WINDOW = "8m"
|
|
28
|
+
STATUS_WAIT_SECONDS = 10
|
|
29
|
+
TERMINAL_EXIT_CODES = {
|
|
30
|
+
"success": 0,
|
|
31
|
+
"failed": 1,
|
|
32
|
+
"timeout": 124,
|
|
33
|
+
"orphaned": 4,
|
|
34
|
+
"stale": 4,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def root_dir() -> Path:
|
|
39
|
+
return Path(__file__).resolve().parents[2]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_duration(value: str) -> float:
|
|
43
|
+
value = value.strip().lower()
|
|
44
|
+
if value.endswith("ms"):
|
|
45
|
+
return float(value[:-2]) / 1000
|
|
46
|
+
if value.endswith("s"):
|
|
47
|
+
return float(value[:-1])
|
|
48
|
+
if value.endswith("m"):
|
|
49
|
+
return float(value[:-1]) * 60
|
|
50
|
+
if value.endswith("h"):
|
|
51
|
+
return float(value[:-1]) * 3600
|
|
52
|
+
return float(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_optional_json(path: Path) -> dict | None:
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(path.read_text())
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def renew_lease(lease_path: Path) -> None:
|
|
63
|
+
try:
|
|
64
|
+
lease_path.touch()
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tail(path: Path, lines: int = 40, max_bytes: int = 65536) -> str:
|
|
70
|
+
try:
|
|
71
|
+
size = path.stat().st_size
|
|
72
|
+
except OSError:
|
|
73
|
+
return ""
|
|
74
|
+
try:
|
|
75
|
+
with path.open("rb") as handle:
|
|
76
|
+
handle.seek(max(0, size - max_bytes))
|
|
77
|
+
data = handle.read(max_bytes)
|
|
78
|
+
except OSError:
|
|
79
|
+
return ""
|
|
80
|
+
text = data.decode("utf-8", errors="replace")
|
|
81
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def elapsed_seconds(status: dict) -> float | None:
|
|
85
|
+
started_at = status.get("startedAt")
|
|
86
|
+
if not started_at:
|
|
87
|
+
return None
|
|
88
|
+
from datetime import datetime, timezone
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
started = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
return round((datetime.now(timezone.utc) - started).total_seconds(), 3)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def print_summary(job_id: str, status: dict, directory: Path) -> None:
|
|
98
|
+
state = status.get("status")
|
|
99
|
+
print(f"job: {job_id}")
|
|
100
|
+
print(f"status: {state}")
|
|
101
|
+
print(f"exitCode: {status.get('exitCode')}")
|
|
102
|
+
print(f"durationSeconds: {status.get('durationSeconds')}")
|
|
103
|
+
if state == "timeout":
|
|
104
|
+
print(f"timeoutSeconds: {status.get('timeoutSeconds')}")
|
|
105
|
+
print(f"processStopResult: {status.get('processStopResult')}")
|
|
106
|
+
if state == "orphaned":
|
|
107
|
+
print(f"orphanReason: {status.get('orphanReason')}")
|
|
108
|
+
print(f"processStopResult: {status.get('processStopResult')}")
|
|
109
|
+
if state == "stale":
|
|
110
|
+
print(f"staleReason: {status.get('staleReason')}")
|
|
111
|
+
|
|
112
|
+
if state in {"failed", "timeout", "orphaned", "stale"}:
|
|
113
|
+
stdout_tail = tail(directory / "stdout.log")
|
|
114
|
+
stderr_tail = tail(directory / "stderr.log")
|
|
115
|
+
if stdout_tail:
|
|
116
|
+
print("\nstdout tail:")
|
|
117
|
+
print(stdout_tail)
|
|
118
|
+
if stderr_tail:
|
|
119
|
+
print("\nstderr tail:")
|
|
120
|
+
print(stderr_tail)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_progress(job_id: str, status: dict, directory: Path, window: float) -> None:
|
|
124
|
+
print(f"job: {job_id}")
|
|
125
|
+
print("status: still-running")
|
|
126
|
+
print(f"jobStatus: {status.get('status')}")
|
|
127
|
+
elapsed = elapsed_seconds(status)
|
|
128
|
+
if elapsed is not None:
|
|
129
|
+
print(f"elapsedSeconds: {elapsed}")
|
|
130
|
+
ceiling = status.get("timeoutSeconds")
|
|
131
|
+
if isinstance(ceiling, (int, float)):
|
|
132
|
+
print(f"ceilingRemainingSeconds: {max(0, round(ceiling - elapsed))}")
|
|
133
|
+
print(f"watchWindowSeconds: {int(window)}")
|
|
134
|
+
stdout_tail = tail(directory / "stdout.log", lines=5)
|
|
135
|
+
if stdout_tail:
|
|
136
|
+
print("\nstdout tail:")
|
|
137
|
+
print(stdout_tail)
|
|
138
|
+
print("\njob still running - call .ai/tools/watch-job again now; do not end the turn.")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> int:
|
|
142
|
+
parser = argparse.ArgumentParser(
|
|
143
|
+
description="Watch a file-backed long-running validation job."
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument("job_id")
|
|
146
|
+
parser.add_argument("--window", default=DEFAULT_WINDOW)
|
|
147
|
+
parser.add_argument("--interval", default="1s")
|
|
148
|
+
parser.add_argument("--timeout", dest="legacy_timeout", default=None, help=argparse.SUPPRESS)
|
|
149
|
+
args = parser.parse_args()
|
|
150
|
+
|
|
151
|
+
if args.legacy_timeout is not None:
|
|
152
|
+
print(
|
|
153
|
+
"error: watch-job no longer takes --timeout; the job ceiling is set by"
|
|
154
|
+
" run-long-check --timeout. Use --window (max 8m) and call watch-job"
|
|
155
|
+
" repeatedly until it reports a terminal result.",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return 2
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
window = parse_duration(args.window)
|
|
162
|
+
interval = parse_duration(args.interval)
|
|
163
|
+
except ValueError as exc:
|
|
164
|
+
print(f"error: invalid duration: {exc}", file=sys.stderr)
|
|
165
|
+
return 2
|
|
166
|
+
if window <= 0 or interval <= 0:
|
|
167
|
+
print("error: window and interval must be positive", file=sys.stderr)
|
|
168
|
+
return 2
|
|
169
|
+
if window > MAX_WINDOW_SECONDS:
|
|
170
|
+
print("error: window exceeds maximum 8m; call watch-job repeatedly instead", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
|
|
173
|
+
directory = root_dir() / ".ai/vcm/jobs" / args.job_id
|
|
174
|
+
status_path = directory / "status.json"
|
|
175
|
+
lease_path = directory / "lease"
|
|
176
|
+
|
|
177
|
+
found_deadline = time.time() + STATUS_WAIT_SECONDS
|
|
178
|
+
while not status_path.is_file():
|
|
179
|
+
if time.time() >= found_deadline:
|
|
180
|
+
print(f"error: unknown job id: {args.job_id} (no {status_path})", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
time.sleep(0.2)
|
|
183
|
+
|
|
184
|
+
deadline = time.time() + window
|
|
185
|
+
last_status: dict = {}
|
|
186
|
+
while True:
|
|
187
|
+
renew_lease(lease_path)
|
|
188
|
+
status = read_optional_json(status_path)
|
|
189
|
+
if status:
|
|
190
|
+
last_status = status
|
|
191
|
+
state = status.get("status")
|
|
192
|
+
if state in TERMINAL_EXIT_CODES:
|
|
193
|
+
print_summary(args.job_id, status, directory)
|
|
194
|
+
return TERMINAL_EXIT_CODES[state]
|
|
195
|
+
|
|
196
|
+
if time.time() >= deadline:
|
|
197
|
+
print_progress(args.job_id, last_status, directory, window)
|
|
198
|
+
return 125
|
|
199
|
+
|
|
200
|
+
time.sleep(interval)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
raise SystemExit(main())
|