vibe-coding-master 0.7.50 → 0.8.0

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 (28) hide show
  1. package/dist/backend/adapters/filesystem.js +6 -0
  2. package/dist/backend/api/task-routes.js +20 -1
  3. package/dist/backend/server.js +4 -2
  4. package/dist/backend/services/auto-memory-service.js +79 -28
  5. package/dist/backend/services/claude-hook-service.js +5 -3
  6. package/dist/backend/services/gate-review-service.js +21 -2
  7. package/dist/backend/services/harness-feedback-service.js +53 -15
  8. package/dist/backend/services/message-service.js +10 -0
  9. package/dist/backend/services/runtime-recovery-service.js +9 -0
  10. package/dist/backend/services/translation-service.js +74 -3
  11. package/dist/backend/services/workflow-control-service.js +436 -75
  12. package/dist/backend/templates/handoff.js +14 -2
  13. package/dist/backend/templates/harness/architect-agent.js +6 -5
  14. package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -0
  15. package/dist/backend/templates/harness/architect-validation-worker-agent.js +1 -1
  16. package/dist/backend/templates/harness/claude-root.js +3 -3
  17. package/dist/backend/templates/harness/coder-agent.js +1 -0
  18. package/dist/backend/templates/harness/harness-engineer-agent.js +14 -11
  19. package/dist/backend/templates/harness/project-manager-agent.js +5 -1
  20. package/dist/backend/templates/harness/tester-agent.js +6 -0
  21. package/dist/backend/templates/harness/vcm-workflow-review-skill.js +17 -1
  22. package/dist/shared/validation/artifact-check.js +36 -3
  23. package/dist/shared/validation/artifact-contract.js +7 -0
  24. package/dist/shared/validation/artifact-registry.js +4 -1
  25. package/dist-frontend/assets/{index-DLsIPTvK.js → index-Dh7uVCmk.js} +1 -1
  26. package/dist-frontend/index.html +1 -1
  27. package/package.json +1 -1
  28. package/scripts/harness-tools/vcm-bash-guard +245 -21
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>VibeCodingMaster</title>
7
- <script type="module" crossorigin src="/assets/index-DLsIPTvK.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-Dh7uVCmk.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DDmygnV6.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-coding-master",
3
- "version": "0.7.50",
3
+ "version": "0.8.0",
4
4
  "description": "Local GUI session cockpit for Claude Code role sessions.",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,19 +1,24 @@
1
1
  #!/usr/bin/env python3
2
- """VCM PreToolUse guard for supervised tools inside VCM role sessions.
2
+ """VCM PreToolUse guard for supervised tools inside VCM workflow-role sessions.
3
3
 
4
4
  Reads the Claude Code PreToolUse hook payload on stdin. It enforces supervised
5
- Bash execution for every VCM role.
5
+ Bash execution and managed-artifact writes for VCM workflow roles. Tool roles
6
+ are outside the workflow and bypass this guard.
6
7
 
7
8
  Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
8
9
  shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
9
10
  only sanctioned detached worker; the command it runs must still stay in the
10
11
  supervised foreground process group.
11
12
  """
13
+ import ast
12
14
  import json
15
+ import os
13
16
  import re
14
17
  import shlex
15
18
  import sys
16
19
 
20
+ WORKFLOW_ROLES = {"project-manager", "architect", "coder", "tester", "reviewer"}
21
+
17
22
  SKILL_HINT = (
18
23
  "Use the vcm-long-running-validation skill instead: "
19
24
  "`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
@@ -39,8 +44,9 @@ HEREDOC_START = re.compile(
39
44
  EXECUTABLE_HEREDOC_RECEIVER = re.compile(
40
45
  r"(?:^|[;&|()])\s*"
41
46
  r"(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+\s+)*)"
42
- r"(?:[^\s;&|()]*/)?(?:sh|bash|zsh|dash|ksh|python\d*(?:\.\d+)?|node)(?=\s|$)"
47
+ r"(?:[^\s;&|()]*/)?(?P<receiver>sh|bash|zsh|dash|ksh|python\d*(?:\.\d+)?|node)(?=\s|$)"
43
48
  )
49
+ SHELL_HEREDOC_RECEIVERS = {"sh", "bash", "zsh", "dash", "ksh"}
44
50
  SHELL_CONTROL_OPERATOR = re.compile(r"(?:\|&?|&&|\|\||;|\n|\(|\)|`)")
45
51
  RUN_LONG_CHECK_SHELL_STRING = re.compile(
46
52
  r"(?:^|[;&|()\n])\s*"
@@ -106,34 +112,70 @@ def strip_escaped_characters(command: str) -> str:
106
112
  return "".join(cleaned)
107
113
 
108
114
 
109
- def strip_heredoc_bodies(command: str) -> str:
110
- """Remove heredoc data while preserving executable lines and separators."""
111
- pending: list[tuple[str, bool, bool]] = []
112
- executable = []
115
+ def heredoc_receiver_language(line: str):
116
+ match = EXECUTABLE_HEREDOC_RECEIVER.search(line)
117
+ if not match:
118
+ return None
119
+ receiver = match.group("receiver")
120
+ if receiver in SHELL_HEREDOC_RECEIVERS:
121
+ return "shell"
122
+ if receiver.startswith("python"):
123
+ return "python"
124
+ return "node"
125
+
126
+
127
+ def parse_heredoc_content(command: str) -> tuple[str, str, list[tuple[str, str]]]:
128
+ """Return shell-only text, all executable text, and typed interpreter bodies."""
129
+ pending = []
130
+ shell_executable = []
131
+ all_executable = []
132
+ interpreter_sections: list[tuple[str, list[str]]] = []
113
133
  for line in command.splitlines(keepends=True):
114
134
  if pending:
115
- delimiter, strip_tabs, preserve_body = pending[0]
135
+ delimiter, strip_tabs, language, body = pending[0]
116
136
  candidate = line.rstrip("\r\n")
117
137
  if strip_tabs:
118
138
  candidate = candidate.lstrip("\t")
119
139
  if candidate == delimiter:
120
140
  pending.pop(0)
121
- elif preserve_body:
122
- executable.append(line)
141
+ elif language:
142
+ content_line = line.lstrip("\t") if strip_tabs else line
143
+ all_executable.append(content_line)
144
+ if language == "shell":
145
+ shell_executable.append(content_line)
146
+ else:
147
+ body.append(content_line)
148
+ if content_line.endswith(("\n", "\r")):
149
+ shell_executable.append("\n")
123
150
  elif line.endswith(("\n", "\r")):
124
- executable.append("\n")
151
+ shell_executable.append("\n")
152
+ all_executable.append("\n")
125
153
  continue
126
154
 
127
- executable.append(line)
128
- preserve_body = bool(EXECUTABLE_HEREDOC_RECEIVER.search(line))
155
+ shell_executable.append(line)
156
+ all_executable.append(line)
157
+ language = heredoc_receiver_language(line)
129
158
  for match in HEREDOC_START.finditer(line):
130
159
  delimiter = match.group(3) or match.group(4)
131
- pending.append((delimiter, bool(match.group(1)), preserve_body))
132
- return "".join(executable)
160
+ body: list[str] = []
161
+ pending.append((delimiter, bool(match.group(1)), language, body))
162
+ if language in ("python", "node"):
163
+ interpreter_sections.append((language, body))
164
+ return (
165
+ "".join(shell_executable),
166
+ "".join(all_executable),
167
+ [(language, "".join(body)) for language, body in interpreter_sections],
168
+ )
169
+
170
+
171
+ def strip_heredoc_bodies(command: str) -> str:
172
+ """Remove data heredocs while preserving all executable interpreter bodies."""
173
+ return parse_heredoc_content(command)[1]
133
174
 
134
175
 
135
176
  def shell_segments(command: str) -> list[list[str]]:
136
- normalized = strip_heredoc_bodies(command).replace("\\\n", " ").replace("\n", " ; ")
177
+ shell_command, _, _ = parse_heredoc_content(command)
178
+ normalized = shell_command.replace("\\\n", " ").replace("\n", " ; ")
137
179
  try:
138
180
  lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|()<>")
139
181
  lexer.whitespace_split = True
@@ -290,16 +332,190 @@ def protected_tool_invoked(command_without_quotes: str) -> bool:
290
332
  return bool(PROTECTED_TOOL_INVOCATION.search(command_without_quotes))
291
333
 
292
334
 
293
- def scan_shell_command(command: str, depth: int = 0) -> list[str]:
335
+ def shell_background_reasons(command: str) -> list[str]:
336
+ stripped = strip_escaped_characters(strip_quoted(command))
294
337
  reasons = []
295
- executable_command = strip_heredoc_bodies(command)
296
- stripped = strip_escaped_characters(strip_quoted(executable_command))
297
338
  if re.search(r"(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)", stripped):
298
339
  reasons.append("nohup/setsid detach is forbidden")
299
340
  if re.search(r"(?:^|[\s;&|(])disown(?:\s|$)", stripped):
300
341
  reasons.append("disown is forbidden")
301
342
  if unquoted_ampersand(stripped):
302
343
  reasons.append("'&' background execution is forbidden")
344
+ return reasons
345
+
346
+
347
+ def python_dotted_name(node, aliases: dict[str, str]):
348
+ parts = []
349
+ current = node
350
+ while isinstance(current, ast.Attribute):
351
+ parts.append(current.attr)
352
+ current = current.value
353
+ if not isinstance(current, ast.Name):
354
+ return None
355
+ parts.append(current.id)
356
+ parts.reverse()
357
+ if parts[0] in aliases:
358
+ parts = aliases[parts[0]].split(".") + parts[1:]
359
+ return ".".join(parts)
360
+
361
+
362
+ def python_import_aliases(tree) -> dict[str, str]:
363
+ aliases = {}
364
+ for node in ast.walk(tree):
365
+ if isinstance(node, ast.Import):
366
+ for imported in node.names:
367
+ aliases[imported.asname or imported.name.split(".")[0]] = imported.name
368
+ elif isinstance(node, ast.ImportFrom) and node.module:
369
+ for imported in node.names:
370
+ aliases[imported.asname or imported.name] = f"{node.module}.{imported.name}"
371
+ return aliases
372
+
373
+
374
+ def literal_string(node):
375
+ return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
376
+
377
+
378
+ def scan_python_backgrounding(source: str) -> list[str]:
379
+ try:
380
+ tree = ast.parse(source)
381
+ except SyntaxError:
382
+ return []
383
+ aliases = python_import_aliases(tree)
384
+ reasons = []
385
+ asynchronous_calls = {
386
+ "subprocess.Popen",
387
+ "os.fork",
388
+ "os.popen",
389
+ "asyncio.create_subprocess_exec",
390
+ "asyncio.create_subprocess_shell",
391
+ }
392
+ shell_calls = {
393
+ "os.system",
394
+ "subprocess.getoutput",
395
+ "subprocess.getstatusoutput",
396
+ }
397
+ for node in ast.walk(tree):
398
+ if not isinstance(node, ast.Call):
399
+ continue
400
+ name = python_dotted_name(node.func, aliases)
401
+ if not name:
402
+ continue
403
+ if name in asynchronous_calls or name.startswith("os.spawn"):
404
+ reasons.append("Python asynchronous process creation is forbidden")
405
+ continue
406
+ command = literal_string(node.args[0]) if node.args else None
407
+ if name in shell_calls and command and shell_background_reasons(command):
408
+ reasons.append("Python shell background execution is forbidden")
409
+ continue
410
+ if name in {"subprocess.run", "subprocess.call", "subprocess.check_call", "subprocess.check_output"}:
411
+ uses_shell = any(
412
+ keyword.arg == "shell" and isinstance(keyword.value, ast.Constant) and keyword.value.value is True
413
+ for keyword in node.keywords
414
+ )
415
+ if uses_shell and command and shell_background_reasons(command):
416
+ reasons.append("Python shell background execution is forbidden")
417
+ return reasons
418
+
419
+
420
+ def mask_javascript(source: str, mask_strings: bool) -> str:
421
+ output = []
422
+ state = "code"
423
+ quote = ""
424
+ index = 0
425
+ while index < len(source):
426
+ character = source[index]
427
+ following = source[index + 1] if index + 1 < len(source) else ""
428
+ if state == "code":
429
+ if character == "/" and following == "/":
430
+ output.extend((" ", " "))
431
+ state = "line-comment"
432
+ index += 2
433
+ continue
434
+ if character == "/" and following == "*":
435
+ output.extend((" ", " "))
436
+ state = "block-comment"
437
+ index += 2
438
+ continue
439
+ if character in ("'", '"', "`"):
440
+ quote = character
441
+ state = "string"
442
+ output.append(" " if mask_strings else character)
443
+ index += 1
444
+ continue
445
+ output.append(character)
446
+ index += 1
447
+ continue
448
+ if state == "line-comment":
449
+ output.append("\n" if character == "\n" else " ")
450
+ if character == "\n":
451
+ state = "code"
452
+ index += 1
453
+ continue
454
+ if state == "block-comment":
455
+ if character == "*" and following == "/":
456
+ output.extend((" ", " "))
457
+ state = "code"
458
+ index += 2
459
+ else:
460
+ output.append("\n" if character == "\n" else " ")
461
+ index += 1
462
+ continue
463
+ if character == "\\" and following:
464
+ output.extend((" ", " ") if mask_strings else (character, following))
465
+ index += 2
466
+ continue
467
+ output.append(" " if mask_strings and character != "\n" else character)
468
+ if character == quote:
469
+ state = "code"
470
+ index += 1
471
+ return "".join(output)
472
+
473
+
474
+ def scan_node_backgrounding(source: str) -> list[str]:
475
+ without_comments = mask_javascript(source, False)
476
+ executable = mask_javascript(source, True)
477
+ module_aliases = {"child_process"}
478
+ function_aliases = set()
479
+ module_pattern = r"(?:node:)?child_process"
480
+ for match in re.finditer(
481
+ rf"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['\"]{module_pattern}['\"]\s*\)",
482
+ without_comments,
483
+ ):
484
+ module_aliases.add(match.group(1))
485
+ for match in re.finditer(
486
+ rf"\bimport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['\"]{module_pattern}['\"]",
487
+ without_comments,
488
+ ):
489
+ module_aliases.add(match.group(1))
490
+ destructured_patterns = [
491
+ rf"\b(?:const|let|var)\s*\{{([^}}]+)\}}\s*=\s*require\(\s*['\"]{module_pattern}['\"]\s*\)",
492
+ rf"\bimport\s*\{{([^}}]+)\}}\s*from\s*['\"]{module_pattern}['\"]",
493
+ ]
494
+ asynchronous = {"spawn", "exec", "execFile", "fork"}
495
+ for pattern in destructured_patterns:
496
+ for match in re.finditer(pattern, without_comments):
497
+ for item in match.group(1).split(","):
498
+ parts = re.split(r"\s+(?:as)\s+|\s*:\s*", item.strip())
499
+ if parts and parts[0] in asynchronous:
500
+ function_aliases.add(parts[-1])
501
+ for alias in module_aliases:
502
+ if re.search(rf"\b{re.escape(alias)}\s*\.\s*(?:spawn|exec|execFile|fork)\s*\(", executable):
503
+ return ["Node asynchronous process creation is forbidden"]
504
+ for alias in function_aliases:
505
+ if re.search(rf"\b{re.escape(alias)}\s*\(", executable):
506
+ return ["Node asynchronous process creation is forbidden"]
507
+ if re.search(
508
+ rf"\brequire\(\s*['\"]{module_pattern}['\"]\s*\)\s*\.\s*(?:spawn|exec|execFile|fork)\s*\(",
509
+ without_comments,
510
+ ):
511
+ return ["Node asynchronous process creation is forbidden"]
512
+ return []
513
+
514
+
515
+ def scan_shell_command(command: str, depth: int = 0) -> list[str]:
516
+ shell_command, _, interpreter_sections = parse_heredoc_content(command)
517
+ reasons = shell_background_reasons(shell_command)
518
+ stripped = strip_escaped_characters(strip_quoted(shell_command))
303
519
  if protected_tool_invoked(stripped):
304
520
  if SHELL_CONTROL_OPERATOR.search(stripped):
305
521
  reasons.append("run-long-check and watch-job must be standalone Bash commands")
@@ -308,16 +524,21 @@ def scan_shell_command(command: str, depth: int = 0) -> list[str]:
308
524
 
309
525
  # `sh -c '...'` quoted payloads are shell code, not plain strings.
310
526
  if depth < MAX_NESTED_SHELL_DEPTH and SHELL_DASH_C.search(stripped):
311
- for segment in quoted_segments(executable_command):
527
+ for segment in quoted_segments(shell_command):
312
528
  if protected_tool_invoked(strip_escaped_characters(strip_quoted(segment))):
313
529
  reasons.append("run-long-check and watch-job must not be invoked through a shell command string")
314
530
  reasons.extend(scan_shell_command(segment, depth + 1))
315
531
  if depth < MAX_NESTED_SHELL_DEPTH:
316
- for substitution in double_quoted_command_substitutions(executable_command):
532
+ for substitution in double_quoted_command_substitutions(shell_command):
317
533
  nested_stripped = strip_escaped_characters(strip_quoted(substitution))
318
534
  if protected_tool_invoked(nested_stripped):
319
535
  reasons.append("run-long-check and watch-job must not be invoked through command substitution")
320
536
  reasons.extend(scan_shell_command(substitution, depth + 1))
537
+ for language, source in interpreter_sections:
538
+ if language == "python":
539
+ reasons.extend(scan_python_backgrounding(source))
540
+ elif language == "node":
541
+ reasons.extend(scan_node_backgrounding(source))
321
542
  return reasons
322
543
 
323
544
 
@@ -337,6 +558,9 @@ def guard_reasons(tool_input: dict) -> list[str]:
337
558
 
338
559
  def main() -> int:
339
560
  raw = sys.stdin.read()
561
+ if os.environ.get("VCM_ROLE", "").strip() not in WORKFLOW_ROLES:
562
+ return 0
563
+
340
564
  try:
341
565
  payload = json.loads(raw) if raw.strip() else {}
342
566
  except ValueError: