vibe-coding-master 0.2.7 → 0.2.8
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 +17 -4
- package/dist/backend/api/claude-hook-routes.js +7 -1
- 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/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/docs/full-harness-baseline.md +7 -5
- package/docs/product-design.md +1 -1
- 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
|
@@ -5,13 +5,22 @@ import path from "node:path";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
-
const HARNESS_VERSION = "0.
|
|
8
|
+
const HARNESS_VERSION = "0.3.0-fixed";
|
|
9
9
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const MANIFEST_PATH = ".ai/vcm-harness-manifest.json";
|
|
11
11
|
const HTML_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=\d+)? -->[\s\S]*?<!-- VCM:END -->/m;
|
|
12
12
|
const HASH_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=\d+)?\n[\s\S]*?# VCM:END/m;
|
|
13
13
|
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'`;
|
|
14
|
+
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'`;
|
|
14
15
|
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'`;
|
|
16
|
+
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"'`;
|
|
17
|
+
const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
|
|
18
|
+
const VCM_HOOK_DEFINITIONS = [
|
|
19
|
+
{ eventName: "PreToolUse", matcher: "Bash", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
|
|
20
|
+
{ eventName: "UserPromptSubmit", command: VCM_HOOK_COMMAND, timeout: 5 },
|
|
21
|
+
{ eventName: "Stop", command: VCM_STOP_HOOK_COMMAND, timeout: 10 },
|
|
22
|
+
{ eventName: "PermissionRequest", command: VCM_PERMISSION_REQUEST_HOOK_COMMAND, timeout: 5 }
|
|
23
|
+
];
|
|
15
24
|
|
|
16
25
|
const AGENT_FRONTMATTER = {
|
|
17
26
|
"project-manager": {
|
|
@@ -44,12 +53,11 @@ const MANAGED_FILES = [
|
|
|
44
53
|
|
|
45
54
|
## VCM Background Jobs
|
|
46
55
|
|
|
47
|
-
-
|
|
48
|
-
- The only
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
- Do not end the current turn only to wait for a long-running shell callback.
|
|
56
|
+
- Never run the Bash tool with \`run_in_background: true\`. Never detach a process with \`nohup\`, \`setsid\`, \`disown\`, or a trailing \`&\`. VCM denies these calls.
|
|
57
|
+
- The only sanctioned long-running mechanism is the \`vcm-long-running-validation\` skill: \`.ai/tools/run-long-check\` plus \`.ai/tools/watch-job\`.
|
|
58
|
+
- The moment a command might run longer than 2 minutes, switch to that skill instead of running the command directly.
|
|
59
|
+
- While a job is running, stay in the current turn and keep calling \`.ai/tools/watch-job\` until it reports a terminal result; VCM blocks turn-end while a job is running, and a job without a live watcher is killed automatically.
|
|
60
|
+
- Hard ceiling: 60 minutes per job, enforced by the job worker. Do not run or suggest operations expected to exceed 60 minutes without user approval; split larger work first.
|
|
53
61
|
|
|
54
62
|
## VCM Durable Project Docs
|
|
55
63
|
|
|
@@ -94,6 +102,7 @@ const MANAGED_FILES = [
|
|
|
94
102
|
content: `# VCM runtime task metadata, handoffs, session records, logs, and task worktrees.
|
|
95
103
|
.ai/vcm/
|
|
96
104
|
.claude/worktrees/
|
|
105
|
+
.ai/tools/__pycache__/
|
|
97
106
|
`
|
|
98
107
|
},
|
|
99
108
|
{
|
|
@@ -171,6 +180,11 @@ const MANAGED_FILES = [
|
|
|
171
180
|
- Fill the PR body from final acceptance, review report, docs-sync report, known-issues disposition, and commits.
|
|
172
181
|
- Do not perform technical review or validation during PR preparation; route missing evidence to the responsible role.
|
|
173
182
|
- Create a draft PR by default unless the user requests a ready PR.
|
|
183
|
+
|
|
184
|
+
### Background Jobs
|
|
185
|
+
|
|
186
|
+
- Never background a Bash command: no \`run_in_background\`, \`nohup\`, \`setsid\`, \`disown\`, or trailing \`&\`.
|
|
187
|
+
- For any command that may exceed 2 minutes, use the \`vcm-long-running-validation\` skill and stay in the turn, re-running \`.ai/tools/watch-job\` until it reports a terminal result.
|
|
174
188
|
`
|
|
175
189
|
},
|
|
176
190
|
{
|
|
@@ -231,6 +245,11 @@ const MANAGED_FILES = [
|
|
|
231
245
|
- When crate-external public APIs change, require \`.ai/tools/generate-public-surface --check\` or regeneration.
|
|
232
246
|
- Read \`.ai/vcm/handoffs/known-issues.md\` and promote confirmed unresolved issues to \`docs/known-issues.md\`.
|
|
233
247
|
- Write \`.ai/vcm/handoffs/docs-sync-report.md\` with decision, evidence reviewed, architecture drift check, docs updated, docs left unchanged, remaining documentation risks, and handoff notes.
|
|
248
|
+
|
|
249
|
+
### Background Jobs
|
|
250
|
+
|
|
251
|
+
- Never background a Bash command: no \`run_in_background\`, \`nohup\`, \`setsid\`, \`disown\`, or trailing \`&\`.
|
|
252
|
+
- For any command that may exceed 2 minutes, use the \`vcm-long-running-validation\` skill and stay in the turn, re-running \`.ai/tools/watch-job\` until it reports a terminal result.
|
|
234
253
|
`
|
|
235
254
|
},
|
|
236
255
|
{
|
|
@@ -280,6 +299,11 @@ const MANAGED_FILES = [
|
|
|
280
299
|
- Do not request Replan because of workload, session length, or context size.
|
|
281
300
|
- If the plan remains valid but the assigned work cannot be finished in this turn, include completed work, remaining work, validation state, and next continuation step in the route message, then ask project-manager for continuation.
|
|
282
301
|
- If implementation exposes a broad testing gap beyond baseline unit tests, report it to project-manager for reviewer follow-up.
|
|
302
|
+
|
|
303
|
+
### Background Jobs
|
|
304
|
+
|
|
305
|
+
- Never background a Bash command: no \`run_in_background\`, \`nohup\`, \`setsid\`, \`disown\`, or trailing \`&\`.
|
|
306
|
+
- For any command that may exceed 2 minutes, use the \`vcm-long-running-validation\` skill and stay in the turn, re-running \`.ai/tools/watch-job\` until it reports a terminal result.
|
|
283
307
|
`
|
|
284
308
|
},
|
|
285
309
|
{
|
|
@@ -326,6 +350,11 @@ const MANAGED_FILES = [
|
|
|
326
350
|
|
|
327
351
|
- Write \`.ai/vcm/handoffs/review-report.md\` with decision, evidence reviewed, tests added or updated, commands run or checked, validation results, failed expectations, reproduction steps, skipped checks with reasons, coverage gaps, and required follow-ups.
|
|
328
352
|
- Record confirmed unresolved issues in \`.ai/vcm/handoffs/known-issues.md\` only when they should survive current-task cleanup.
|
|
353
|
+
|
|
354
|
+
### Background Jobs
|
|
355
|
+
|
|
356
|
+
- Never background a Bash command: no \`run_in_background\`, \`nohup\`, \`setsid\`, \`disown\`, or trailing \`&\`.
|
|
357
|
+
- For any command that may exceed 2 minutes, use the \`vcm-long-running-validation\` skill and stay in the turn, re-running \`.ai/tools/watch-job\` until it reports a terminal result.
|
|
329
358
|
`
|
|
330
359
|
},
|
|
331
360
|
{
|
|
@@ -619,32 +648,53 @@ description: Use for builds, browser checks, E2E tests, release suites, or any v
|
|
|
619
648
|
|
|
620
649
|
# VCM Long-Running Validation Skill
|
|
621
650
|
|
|
622
|
-
Use this skill for builds, browser checks, E2E tests, release suites, or any command that may
|
|
651
|
+
Use this skill for builds, browser checks, E2E tests, release suites, or any command that may run longer than 2 minutes.
|
|
623
652
|
|
|
624
653
|
## Rule
|
|
625
654
|
|
|
626
|
-
|
|
655
|
+
Never run the Bash tool with \`run_in_background: true\`, and never detach a process with \`nohup\`, \`setsid\`, \`disown\`, or a trailing \`&\`. VCM denies these calls.
|
|
627
656
|
|
|
628
|
-
The only
|
|
657
|
+
The only sanctioned long-running mechanism is \`.ai/tools/run-long-check\` plus \`.ai/tools/watch-job\` through this skill.
|
|
629
658
|
|
|
630
|
-
|
|
659
|
+
The hard ceiling is 60 minutes per job, enforced by the job worker itself. Do not run or suggest operations expected to exceed 60 minutes without user approval; split larger work first.
|
|
631
660
|
|
|
632
661
|
## Protocol
|
|
633
662
|
|
|
634
|
-
1. Start the command
|
|
635
|
-
2.
|
|
636
|
-
3.
|
|
637
|
-
4.
|
|
638
|
-
5. Read the final status and relevant log tail.
|
|
663
|
+
1. Start the command with an explicit ceiling: \`.ai/tools/run-long-check --timeout <duration> -- <command>\`. Pick the ceiling from \`docs/TESTING.md\` guidance or a realistic estimate, never above 60m. The tool prints the job id and creates job state under \`.ai/vcm/jobs/<job-id>/\`.
|
|
664
|
+
2. In the same turn, run \`.ai/tools/watch-job <job-id>\`. The default watch window is 8 minutes.
|
|
665
|
+
3. If watch-job exits 125, the job is still running: run \`.ai/tools/watch-job <job-id>\` again immediately. Do not end the turn between windows.
|
|
666
|
+
4. Repeat until watch-job reports a terminal result.
|
|
667
|
+
5. Read the final status and the relevant log tail.
|
|
639
668
|
6. Record command, result, duration, and required follow-up wherever the caller normally records command evidence.
|
|
640
669
|
|
|
641
670
|
Example:
|
|
642
671
|
|
|
643
672
|
\`\`\`bash
|
|
644
|
-
.ai/tools/run-long-check -- cargo test --workspace
|
|
645
|
-
.ai/tools/watch-job <job-id>
|
|
673
|
+
.ai/tools/run-long-check --timeout 30m -- cargo test --workspace
|
|
674
|
+
.ai/tools/watch-job <job-id>
|
|
675
|
+
.ai/tools/watch-job <job-id> # repeat while the exit code is 125
|
|
646
676
|
\`\`\`
|
|
647
677
|
|
|
678
|
+
## Exit Codes
|
|
679
|
+
|
|
680
|
+
Treat watch-job exit codes as explicit results:
|
|
681
|
+
|
|
682
|
+
- \`0\`: success.
|
|
683
|
+
- \`1\`: failed; read the log tails.
|
|
684
|
+
- \`124\`: timeout; the job hit its ceiling and was killed; not passed.
|
|
685
|
+
- \`125\`: still running; call watch-job again now.
|
|
686
|
+
- \`4\`: orphaned or stale; the job lost foreground supervision and was killed, or its worker died; not passed.
|
|
687
|
+
- \`2\`: usage error or unknown job id.
|
|
688
|
+
|
|
689
|
+
## Supervision
|
|
690
|
+
|
|
691
|
+
A running job requires a live foreground watcher:
|
|
692
|
+
|
|
693
|
+
- watch-job renews the job supervision lease while it runs.
|
|
694
|
+
- If no watcher renews the lease for about 2 minutes, the worker kills the command process group and records \`orphaned\`. A job cannot keep running unsupervised.
|
|
695
|
+
- VCM also blocks ending the turn while a job is running. Stay in the turn and keep watching.
|
|
696
|
+
- Only one validation job may run at a time; run-long-check refuses to start a second one.
|
|
697
|
+
|
|
648
698
|
## Job Files
|
|
649
699
|
|
|
650
700
|
\`\`\`text
|
|
@@ -652,23 +702,19 @@ Example:
|
|
|
652
702
|
.ai/vcm/jobs/<job-id>/status.json
|
|
653
703
|
.ai/vcm/jobs/<job-id>/stdout.log
|
|
654
704
|
.ai/vcm/jobs/<job-id>/stderr.log
|
|
705
|
+
.ai/vcm/jobs/<job-id>/lease
|
|
655
706
|
\`\`\`
|
|
656
707
|
|
|
657
708
|
## Timeout
|
|
658
709
|
|
|
659
710
|
Timeout is not "unknown". It is a command result.
|
|
660
711
|
|
|
661
|
-
\`watch-job
|
|
662
|
-
|
|
663
|
-
On timeout:
|
|
712
|
+
On timeout the worker stops the command process group and records \`timeout\` in \`status.json\`; watch-job reports it with exit code 124:
|
|
664
713
|
|
|
665
714
|
- summarize the latest log tail
|
|
666
|
-
- record the timeout in \`status.json\`
|
|
667
715
|
- report whether the timed-out process was stopped
|
|
668
716
|
- do not mark the command as passed
|
|
669
|
-
- do not
|
|
670
|
-
|
|
671
|
-
\`watch-job\` should attempt to stop the timed-out command process group. If termination cannot be confirmed, say so in the summary.
|
|
717
|
+
- do not retry in the background
|
|
672
718
|
|
|
673
719
|
## Cleanup
|
|
674
720
|
|
|
@@ -775,366 +821,19 @@ If delivery is manual, blocked, or the target role is busy, leave the route file
|
|
|
775
821
|
path: ".ai/tools/run-long-check",
|
|
776
822
|
category: "runtime-tool",
|
|
777
823
|
mode: 0o755,
|
|
778
|
-
|
|
779
|
-
import json
|
|
780
|
-
import os
|
|
781
|
-
import subprocess
|
|
782
|
-
import sys
|
|
783
|
-
import time
|
|
784
|
-
import uuid
|
|
785
|
-
from datetime import datetime, timezone
|
|
786
|
-
from pathlib import Path
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def root_dir() -> Path:
|
|
790
|
-
return Path(__file__).resolve().parents[2]
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
def now_iso() -> str:
|
|
794
|
-
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
def write_json(path: Path, data: dict) -> None:
|
|
798
|
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
799
|
-
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\\n")
|
|
800
|
-
tmp.replace(path)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
def job_id() -> str:
|
|
804
|
-
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
805
|
-
return f"{timestamp}-{uuid.uuid4().hex[:8]}"
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
def start_job(command: list[str]) -> int:
|
|
809
|
-
root = root_dir()
|
|
810
|
-
job = job_id()
|
|
811
|
-
directory = root / ".ai/vcm/jobs" / job
|
|
812
|
-
directory.mkdir(parents=True, exist_ok=False)
|
|
813
|
-
|
|
814
|
-
(directory / "command.json").write_text(
|
|
815
|
-
json.dumps({"command": command, "cwd": "."}, indent=2, sort_keys=True) + "\\n"
|
|
816
|
-
)
|
|
817
|
-
write_json(
|
|
818
|
-
directory / "status.json",
|
|
819
|
-
{
|
|
820
|
-
"jobId": job,
|
|
821
|
-
"status": "queued",
|
|
822
|
-
"command": command,
|
|
823
|
-
"cwd": ".",
|
|
824
|
-
"startedAt": None,
|
|
825
|
-
"finishedAt": None,
|
|
826
|
-
"exitCode": None,
|
|
827
|
-
"durationSeconds": None,
|
|
828
|
-
"workerPid": None,
|
|
829
|
-
"processId": None,
|
|
830
|
-
},
|
|
831
|
-
)
|
|
832
|
-
|
|
833
|
-
subprocess.Popen(
|
|
834
|
-
[sys.executable, str(Path(__file__).resolve()), "--worker", job],
|
|
835
|
-
cwd=root,
|
|
836
|
-
stdin=subprocess.DEVNULL,
|
|
837
|
-
stdout=subprocess.DEVNULL,
|
|
838
|
-
stderr=subprocess.DEVNULL,
|
|
839
|
-
start_new_session=True,
|
|
840
|
-
)
|
|
841
|
-
|
|
842
|
-
print(f"job: {job}")
|
|
843
|
-
print(f"watch: .ai/tools/watch-job {job}")
|
|
844
|
-
return 0
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
def run_worker(job: str) -> int:
|
|
848
|
-
root = root_dir()
|
|
849
|
-
directory = root / ".ai/vcm/jobs" / job
|
|
850
|
-
command_path = directory / "command.json"
|
|
851
|
-
status_path = directory / "status.json"
|
|
852
|
-
|
|
853
|
-
payload = json.loads(command_path.read_text())
|
|
854
|
-
command = payload["command"]
|
|
855
|
-
started = time.time()
|
|
856
|
-
started_at = now_iso()
|
|
857
|
-
|
|
858
|
-
with (directory / "stdout.log").open("wb") as stdout, (directory / "stderr.log").open("wb") as stderr:
|
|
859
|
-
process = subprocess.Popen(
|
|
860
|
-
command,
|
|
861
|
-
cwd=root,
|
|
862
|
-
stdout=stdout,
|
|
863
|
-
stderr=stderr,
|
|
864
|
-
start_new_session=True,
|
|
865
|
-
)
|
|
866
|
-
write_json(
|
|
867
|
-
status_path,
|
|
868
|
-
{
|
|
869
|
-
"jobId": job,
|
|
870
|
-
"status": "running",
|
|
871
|
-
"command": command,
|
|
872
|
-
"cwd": ".",
|
|
873
|
-
"startedAt": started_at,
|
|
874
|
-
"finishedAt": None,
|
|
875
|
-
"exitCode": None,
|
|
876
|
-
"durationSeconds": None,
|
|
877
|
-
"workerPid": os.getpid(),
|
|
878
|
-
"processId": process.pid,
|
|
879
|
-
},
|
|
880
|
-
)
|
|
881
|
-
exit_code = process.wait()
|
|
882
|
-
|
|
883
|
-
duration = round(time.time() - started, 3)
|
|
884
|
-
current = json.loads(status_path.read_text())
|
|
885
|
-
if current.get("status") == "timeout":
|
|
886
|
-
current["processExitCode"] = exit_code
|
|
887
|
-
current["processFinishedAt"] = now_iso()
|
|
888
|
-
current["processDurationSeconds"] = duration
|
|
889
|
-
write_json(status_path, current)
|
|
890
|
-
return 0
|
|
891
|
-
|
|
892
|
-
write_json(
|
|
893
|
-
status_path,
|
|
894
|
-
{
|
|
895
|
-
"jobId": job,
|
|
896
|
-
"status": "success" if exit_code == 0 else "failed",
|
|
897
|
-
"command": command,
|
|
898
|
-
"cwd": ".",
|
|
899
|
-
"startedAt": started_at,
|
|
900
|
-
"finishedAt": now_iso(),
|
|
901
|
-
"exitCode": exit_code,
|
|
902
|
-
"durationSeconds": duration,
|
|
903
|
-
"workerPid": os.getpid(),
|
|
904
|
-
"processId": process.pid,
|
|
905
|
-
},
|
|
906
|
-
)
|
|
907
|
-
return 0
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
def main() -> int:
|
|
911
|
-
if len(sys.argv) >= 3 and sys.argv[1] == "--worker":
|
|
912
|
-
return run_worker(sys.argv[2])
|
|
913
|
-
|
|
914
|
-
if len(sys.argv) < 3 or sys.argv[1] != "--":
|
|
915
|
-
print("Usage: .ai/tools/run-long-check -- <command> [args...]", file=sys.stderr)
|
|
916
|
-
return 2
|
|
917
|
-
|
|
918
|
-
return start_job(sys.argv[2:])
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
if __name__ == "__main__":
|
|
922
|
-
raise SystemExit(main())
|
|
923
|
-
`
|
|
824
|
+
templatePath: "scripts/harness-tools/run-long-check"
|
|
924
825
|
},
|
|
925
826
|
{
|
|
926
827
|
path: ".ai/tools/watch-job",
|
|
927
828
|
category: "runtime-tool",
|
|
928
829
|
mode: 0o755,
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
from datetime import datetime, timezone
|
|
937
|
-
from pathlib import Path
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
MAX_TIMEOUT_SECONDS = 60 * 60
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
def root_dir() -> Path:
|
|
944
|
-
return Path(__file__).resolve().parents[2]
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
def parse_duration(value: str) -> float:
|
|
948
|
-
value = value.strip().lower()
|
|
949
|
-
if value.endswith("ms"):
|
|
950
|
-
return float(value[:-2]) / 1000
|
|
951
|
-
if value.endswith("s"):
|
|
952
|
-
return float(value[:-1])
|
|
953
|
-
if value.endswith("m"):
|
|
954
|
-
return float(value[:-1]) * 60
|
|
955
|
-
if value.endswith("h"):
|
|
956
|
-
return float(value[:-1]) * 3600
|
|
957
|
-
return float(value)
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
def validate_duration(name: str, value: float, maximum: float | None = None) -> bool:
|
|
961
|
-
if value <= 0:
|
|
962
|
-
print(f"error: {name} must be positive", file=sys.stderr)
|
|
963
|
-
return False
|
|
964
|
-
if maximum is not None and value > maximum:
|
|
965
|
-
print(f"error: {name} exceeds maximum 60m", file=sys.stderr)
|
|
966
|
-
return False
|
|
967
|
-
return True
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
def read_status(path: Path) -> dict:
|
|
971
|
-
return json.loads(path.read_text())
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
def now_iso() -> str:
|
|
975
|
-
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
def write_json(path: Path, data: dict) -> None:
|
|
979
|
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
980
|
-
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\\n")
|
|
981
|
-
tmp.replace(path)
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
def started_duration(status: dict) -> float | None:
|
|
985
|
-
started_at = status.get("startedAt")
|
|
986
|
-
if not started_at:
|
|
987
|
-
return None
|
|
988
|
-
try:
|
|
989
|
-
started = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
990
|
-
except ValueError:
|
|
991
|
-
return None
|
|
992
|
-
return round((datetime.now(timezone.utc) - started).total_seconds(), 3)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
def process_group_exists(pid: int) -> bool:
|
|
996
|
-
try:
|
|
997
|
-
os.killpg(pid, 0)
|
|
998
|
-
return True
|
|
999
|
-
except ProcessLookupError:
|
|
1000
|
-
return False
|
|
1001
|
-
except PermissionError:
|
|
1002
|
-
return True
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
def process_exists(pid: int) -> bool:
|
|
1006
|
-
try:
|
|
1007
|
-
os.kill(pid, 0)
|
|
1008
|
-
return True
|
|
1009
|
-
except ProcessLookupError:
|
|
1010
|
-
return False
|
|
1011
|
-
except PermissionError:
|
|
1012
|
-
return True
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
def stop_process(pid: int, signal_value: signal.Signals) -> str:
|
|
1016
|
-
try:
|
|
1017
|
-
os.killpg(pid, signal_value)
|
|
1018
|
-
return "process-group"
|
|
1019
|
-
except ProcessLookupError:
|
|
1020
|
-
return "not-running"
|
|
1021
|
-
except PermissionError:
|
|
1022
|
-
try:
|
|
1023
|
-
os.kill(pid, signal_value)
|
|
1024
|
-
return "process"
|
|
1025
|
-
except ProcessLookupError:
|
|
1026
|
-
return "not-running"
|
|
1027
|
-
except PermissionError:
|
|
1028
|
-
return "permission-denied"
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
def stop_process_group(pid: int) -> str:
|
|
1032
|
-
if not process_group_exists(pid) and not process_exists(pid):
|
|
1033
|
-
return "not-running"
|
|
1034
|
-
|
|
1035
|
-
mode = stop_process(pid, signal.SIGTERM)
|
|
1036
|
-
if mode in {"not-running", "permission-denied"}:
|
|
1037
|
-
return mode
|
|
1038
|
-
|
|
1039
|
-
deadline = time.time() + 2
|
|
1040
|
-
while time.time() < deadline:
|
|
1041
|
-
still_running = process_group_exists(pid) if mode == "process-group" else process_exists(pid)
|
|
1042
|
-
if not still_running:
|
|
1043
|
-
return f"terminated-{mode}"
|
|
1044
|
-
time.sleep(0.1)
|
|
1045
|
-
|
|
1046
|
-
kill_mode = stop_process(pid, signal.SIGKILL)
|
|
1047
|
-
if kill_mode == "not-running":
|
|
1048
|
-
return f"terminated-{mode}"
|
|
1049
|
-
if kill_mode == "permission-denied":
|
|
1050
|
-
return "permission-denied"
|
|
1051
|
-
return f"killed-{kill_mode}"
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
def tail(path: Path, lines: int = 40) -> str:
|
|
1055
|
-
if not path.is_file():
|
|
1056
|
-
return ""
|
|
1057
|
-
content = path.read_text(errors="replace").splitlines()
|
|
1058
|
-
return "\\n".join(content[-lines:])
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
def print_summary(job_id: str, status: dict, directory: Path) -> None:
|
|
1062
|
-
print(f"job: {job_id}")
|
|
1063
|
-
print(f"status: {status.get('status')}")
|
|
1064
|
-
print(f"exitCode: {status.get('exitCode')}")
|
|
1065
|
-
print(f"durationSeconds: {status.get('durationSeconds')}")
|
|
1066
|
-
if status.get("status") == "timeout":
|
|
1067
|
-
print(f"timeoutSeconds: {status.get('timeoutSeconds')}")
|
|
1068
|
-
print(f"processStopResult: {status.get('processStopResult')}")
|
|
1069
|
-
|
|
1070
|
-
if status.get("status") in {"failed", "timeout"}:
|
|
1071
|
-
stdout_tail = tail(directory / "stdout.log")
|
|
1072
|
-
stderr_tail = tail(directory / "stderr.log")
|
|
1073
|
-
if stdout_tail:
|
|
1074
|
-
print("\\nstdout tail:")
|
|
1075
|
-
print(stdout_tail)
|
|
1076
|
-
if stderr_tail:
|
|
1077
|
-
print("\\nstderr tail:")
|
|
1078
|
-
print(stderr_tail)
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
def main() -> int:
|
|
1082
|
-
parser = argparse.ArgumentParser(description="Watch a file-backed long-running validation job.")
|
|
1083
|
-
parser.add_argument("job_id")
|
|
1084
|
-
parser.add_argument("--timeout", default="10m")
|
|
1085
|
-
parser.add_argument("--interval", default="1s")
|
|
1086
|
-
args = parser.parse_args()
|
|
1087
|
-
|
|
1088
|
-
try:
|
|
1089
|
-
timeout = parse_duration(args.timeout)
|
|
1090
|
-
interval = parse_duration(args.interval)
|
|
1091
|
-
except ValueError as exc:
|
|
1092
|
-
print(f"error: invalid duration: {exc}", file=sys.stderr)
|
|
1093
|
-
return 2
|
|
1094
|
-
if not validate_duration("timeout", timeout, MAX_TIMEOUT_SECONDS):
|
|
1095
|
-
return 2
|
|
1096
|
-
if not validate_duration("interval", interval):
|
|
1097
|
-
return 2
|
|
1098
|
-
directory = root_dir() / ".ai/vcm/jobs" / args.job_id
|
|
1099
|
-
status_path = directory / "status.json"
|
|
1100
|
-
|
|
1101
|
-
deadline = time.time() + timeout
|
|
1102
|
-
last_status: dict | None = None
|
|
1103
|
-
|
|
1104
|
-
while time.time() <= deadline:
|
|
1105
|
-
if status_path.is_file():
|
|
1106
|
-
last_status = read_status(status_path)
|
|
1107
|
-
if last_status.get("status") in {"success", "failed"}:
|
|
1108
|
-
print_summary(args.job_id, last_status, directory)
|
|
1109
|
-
return 0 if last_status.get("status") == "success" else 1
|
|
1110
|
-
time.sleep(interval)
|
|
1111
|
-
|
|
1112
|
-
timeout_status = dict(last_status or {})
|
|
1113
|
-
process_id = timeout_status.get("processId")
|
|
1114
|
-
stop_result = "no-process-id"
|
|
1115
|
-
if isinstance(process_id, int):
|
|
1116
|
-
stop_result = stop_process_group(process_id)
|
|
1117
|
-
|
|
1118
|
-
timeout_status.update(
|
|
1119
|
-
{
|
|
1120
|
-
"jobId": args.job_id,
|
|
1121
|
-
"status": "timeout",
|
|
1122
|
-
"finishedAt": now_iso(),
|
|
1123
|
-
"exitCode": None,
|
|
1124
|
-
"durationSeconds": started_duration(timeout_status),
|
|
1125
|
-
"timeoutSeconds": timeout,
|
|
1126
|
-
"processStopResult": stop_result,
|
|
1127
|
-
}
|
|
1128
|
-
)
|
|
1129
|
-
if directory.is_dir():
|
|
1130
|
-
write_json(status_path, timeout_status)
|
|
1131
|
-
print_summary(args.job_id, timeout_status, directory)
|
|
1132
|
-
return 124
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
if __name__ == "__main__":
|
|
1136
|
-
raise SystemExit(main())
|
|
1137
|
-
`
|
|
830
|
+
templatePath: "scripts/harness-tools/watch-job"
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
path: ".ai/tools/vcm-bash-guard",
|
|
834
|
+
category: "runtime-tool",
|
|
835
|
+
mode: 0o755,
|
|
836
|
+
templatePath: "scripts/harness-tools/vcm-bash-guard"
|
|
1138
837
|
}
|
|
1139
838
|
];
|
|
1140
839
|
|
|
@@ -1262,8 +961,9 @@ async function buildManifest(projectRoot) {
|
|
|
1262
961
|
source: "vcm-template",
|
|
1263
962
|
lifecycle: "long-term",
|
|
1264
963
|
jsonOwnership: {
|
|
1265
|
-
topLevelKeys: ["hooks"],
|
|
1266
|
-
hookMatchers: ["VCM"]
|
|
964
|
+
topLevelKeys: ["hooks", "env"],
|
|
965
|
+
hookMatchers: ["VCM"],
|
|
966
|
+
envKeys: ["BASH_DEFAULT_TIMEOUT_MS"]
|
|
1267
967
|
},
|
|
1268
968
|
uninstall: {
|
|
1269
969
|
action: "remove-owned-json-keys",
|
|
@@ -1481,15 +1181,16 @@ function mergeVcmHooks(settings) {
|
|
|
1481
1181
|
}
|
|
1482
1182
|
}
|
|
1483
1183
|
|
|
1484
|
-
for (const
|
|
1485
|
-
hooks[eventName] = [
|
|
1486
|
-
...(Array.isArray(hooks[eventName]) ? hooks[eventName] : []),
|
|
1184
|
+
for (const definition of VCM_HOOK_DEFINITIONS) {
|
|
1185
|
+
hooks[definition.eventName] = [
|
|
1186
|
+
...(Array.isArray(hooks[definition.eventName]) ? hooks[definition.eventName] : []),
|
|
1487
1187
|
{
|
|
1188
|
+
...(definition.matcher ? { matcher: definition.matcher } : {}),
|
|
1488
1189
|
hooks: [
|
|
1489
1190
|
{
|
|
1490
1191
|
type: "command",
|
|
1491
|
-
command:
|
|
1492
|
-
timeout:
|
|
1192
|
+
command: definition.command,
|
|
1193
|
+
timeout: definition.timeout
|
|
1493
1194
|
}
|
|
1494
1195
|
]
|
|
1495
1196
|
}
|
|
@@ -1497,6 +1198,11 @@ function mergeVcmHooks(settings) {
|
|
|
1497
1198
|
}
|
|
1498
1199
|
|
|
1499
1200
|
next.hooks = hooks;
|
|
1201
|
+
|
|
1202
|
+
const env = isPlainObject(next.env) ? { ...next.env } : {};
|
|
1203
|
+
env.BASH_DEFAULT_TIMEOUT_MS = VCM_BASH_DEFAULT_TIMEOUT_MS;
|
|
1204
|
+
next.env = env;
|
|
1205
|
+
|
|
1500
1206
|
return next;
|
|
1501
1207
|
}
|
|
1502
1208
|
|
|
@@ -232,7 +232,7 @@ async function removeOwnedJson({ projectRoot, entry, dryRun, operations, warning
|
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
const nextValue =
|
|
235
|
+
const nextValue = removeVcmOwnedSettings(value, entry.jsonOwnership ?? {});
|
|
236
236
|
if (deepEqual(value, nextValue)) {
|
|
237
237
|
operations.push(skip(entry.path, "no VCM-owned JSON values found"));
|
|
238
238
|
return;
|
|
@@ -247,6 +247,23 @@ async function removeOwnedJson({ projectRoot, entry, dryRun, operations, warning
|
|
|
247
247
|
operations.push(done(entry.path, "removed VCM-owned JSON values"));
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
function removeVcmOwnedSettings(settings, jsonOwnership) {
|
|
251
|
+
const nextSettings = removeVcmHookMatchers(settings, jsonOwnership.hookMatchers ?? ["VCM"]);
|
|
252
|
+
const envKeys = Array.isArray(jsonOwnership.envKeys) ? jsonOwnership.envKeys : [];
|
|
253
|
+
if (envKeys.length > 0 && isPlainObject(nextSettings.env)) {
|
|
254
|
+
const env = { ...nextSettings.env };
|
|
255
|
+
for (const key of envKeys) {
|
|
256
|
+
delete env[key];
|
|
257
|
+
}
|
|
258
|
+
if (Object.keys(env).length > 0) {
|
|
259
|
+
nextSettings.env = env;
|
|
260
|
+
} else {
|
|
261
|
+
delete nextSettings.env;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return nextSettings;
|
|
265
|
+
}
|
|
266
|
+
|
|
250
267
|
function removeVcmHookMatchers(settings, hookMatchers) {
|
|
251
268
|
const nextSettings = structuredClone(settings);
|
|
252
269
|
if (!isPlainObject(nextSettings.hooks)) {
|
|
@@ -8,6 +8,9 @@ const requiredFiles = [
|
|
|
8
8
|
"scripts/fix-node-pty-spawn-helper.mjs",
|
|
9
9
|
"scripts/harness-tools/generate-module-index",
|
|
10
10
|
"scripts/harness-tools/generate-public-surface",
|
|
11
|
+
"scripts/harness-tools/run-long-check",
|
|
12
|
+
"scripts/harness-tools/watch-job",
|
|
13
|
+
"scripts/harness-tools/vcm-bash-guard",
|
|
11
14
|
"scripts/install-vcm-harness.mjs",
|
|
12
15
|
"scripts/uninstall-vcm-harness.mjs",
|
|
13
16
|
"dist/main.js",
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export function renderKnownIssuesDocHarnessRules() {
|
|
2
|
-
return `## VCM Known Issues Policy
|
|
3
|
-
|
|
4
|
-
- Use this file only for confirmed unresolved issues that must survive across tasks.
|
|
5
|
-
- Do not record current-task scratch notes, guesses, resolved issues, or ordinary TODOs here.
|
|
6
|
-
- During a task, record unresolved findings in \`.ai/vcm/handoffs/known-issues.md\` first.
|
|
7
|
-
- At task close, promote only still-relevant confirmed issues from the task-local file into this document.
|
|
8
|
-
- Remove entries when they are fixed, rejected, obsolete, or moved into a concrete plan.
|
|
9
|
-
|
|
10
|
-
## Entry Format
|
|
11
|
-
|
|
12
|
-
\`\`\`md
|
|
13
|
-
## YYYY-MM-DD <short issue title>
|
|
14
|
-
|
|
15
|
-
- discovered in: <task or PR>
|
|
16
|
-
- type: bug | limitation | technical-debt | validation-gap | docs-gap | decision-needed
|
|
17
|
-
- impact: low | medium | high
|
|
18
|
-
- status: open | planned | accepted
|
|
19
|
-
- proposed action: <next useful step>
|
|
20
|
-
\`\`\`
|
|
21
|
-
`;
|
|
22
|
-
}
|