@aipper/aiws-spec 0.0.28 → 0.0.29
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/docs/cli-interface.md +10 -12
- package/docs/opencode-autonomous-swarm.md +178 -0
- package/docs/opencode-omo-adapter.md +123 -4
- package/docs/opencode-omo-validation-checklist.md +47 -0
- package/docs/opencode-subagent-first.md +187 -0
- package/docs/workflow-delegation-context-injection.md +217 -0
- package/docs/workflow-delegation-contracts.json +68 -1
- package/docs/workflow-delegation-contracts.md +3 -0
- package/docs/workflow-delegation-contracts.schema.json +95 -0
- package/docs/workflow-governance-rules.json +47 -6
- package/docs/workflow-governance-rules.md +7 -6
- package/docs/workflow-governance-rules.schema.json +39 -1
- package/docs/workflow-router-rules.json +36 -4
- package/docs/workflow-router-rules.md +8 -4
- package/docs/workflow-stage-contracts.json +2 -3
- package/docs/workflow-stage-contracts.md +2 -3
- package/package.json +1 -1
- package/templates/workspace/.agents/skills/using-aiws/SKILL.md +8 -6
- package/templates/workspace/.agents/skills/ws-commit/SKILL.md +6 -118
- package/templates/workspace/.agents/skills/ws-deliver/SKILL.md +6 -218
- package/templates/workspace/.agents/skills/ws-dev/SKILL.md +52 -141
- package/templates/workspace/.agents/skills/ws-finish/SKILL.md +6 -205
- package/templates/workspace/.agents/skills/ws-handoff/SKILL.md +10 -44
- package/templates/workspace/.agents/skills/ws-plan-verify/SKILL.md +6 -58
- package/templates/workspace/.agents/skills/ws-review/SKILL.md +6 -1
- package/templates/workspace/.agents/skills/ws-verify-before-complete/SKILL.md +12 -53
- package/templates/workspace/.claude/commands/ws-review.md +5 -1
- package/templates/workspace/.claude/settings.json.example +26 -0
- package/templates/workspace/.claude/skills/ws-commit/SKILL.md +6 -118
- package/templates/workspace/.claude/skills/ws-deliver/SKILL.md +6 -218
- package/templates/workspace/.claude/skills/ws-dev/SKILL.md +52 -141
- package/templates/workspace/.claude/skills/ws-finish/SKILL.md +6 -205
- package/templates/workspace/.claude/skills/ws-handoff/SKILL.md +10 -44
- package/templates/workspace/.claude/skills/ws-plan-verify/SKILL.md +6 -49
- package/templates/workspace/.claude/skills/ws-review/SKILL.md +6 -1
- package/templates/workspace/.claude/skills/ws-verify-before-complete/SKILL.md +12 -53
- package/templates/workspace/.opencode/command/ws-auto.md +33 -0
- package/templates/workspace/.opencode/command/ws-autonomy.md +25 -0
- package/templates/workspace/.opencode/command/ws-review.md +5 -1
- package/templates/workspace/.opencode/commands/ws-auto.md +33 -0
- package/templates/workspace/.opencode/commands/ws-autonomy.md +25 -0
- package/templates/workspace/.opencode/commands/ws-commit.md +4 -56
- package/templates/workspace/.opencode/commands/ws-deliver.md +10 -50
- package/templates/workspace/.opencode/commands/ws-finish.md +8 -65
- package/templates/workspace/.opencode/commands/ws-handoff.md +9 -17
- package/templates/workspace/.opencode/commands/ws-migrate.md +10 -17
- package/templates/workspace/.opencode/commands/ws-plan-verify.md +5 -15
- package/templates/workspace/.opencode/commands/ws-pull.md +6 -75
- package/templates/workspace/.opencode/commands/ws-push.md +7 -82
- package/templates/workspace/.opencode/commands/ws-review.md +5 -1
- package/templates/workspace/.opencode/commands/ws-submodule-setup.md +8 -47
- package/templates/workspace/.opencode/commands/ws-verify-before-complete.md +10 -19
- package/templates/workspace/.opencode/helpers/approval-whitelist-check.sh +148 -0
- package/templates/workspace/.opencode/helpers/approval-whitelist-run.sh +82 -0
- package/templates/workspace/.opencode/helpers/approval-whitelist-watchdog.sh +144 -0
- package/templates/workspace/.opencode/helpers/tmux-swarm-rescue.sh +56 -0
- package/templates/workspace/.opencode/helpers/tmux-swarm-scan.sh +46 -0
- package/templates/workspace/.opencode/oh-my-opencode.json.example +64 -4
- package/templates/workspace/.opencode/skills/using-aiws/SKILL.md +93 -77
- package/templates/workspace/.opencode/skills/ws-analyze/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-auto/SKILL.md +46 -0
- package/templates/workspace/.opencode/skills/ws-autonomy/SKILL.md +62 -0
- package/templates/workspace/.opencode/skills/ws-bugfix/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-commit/SKILL.md +6 -118
- package/templates/workspace/.opencode/skills/ws-delegate/SKILL.md +93 -40
- package/templates/workspace/.opencode/skills/ws-deliver/SKILL.md +6 -218
- package/templates/workspace/.opencode/skills/ws-dev/SKILL.md +53 -142
- package/templates/workspace/.opencode/skills/ws-dev-lite/SKILL.md +19 -6
- package/templates/workspace/.opencode/skills/ws-finish/SKILL.md +6 -205
- package/templates/workspace/.opencode/skills/ws-frontend-design/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-handoff/SKILL.md +10 -44
- package/templates/workspace/.opencode/skills/ws-intake/SKILL.md +11 -2
- package/templates/workspace/.opencode/skills/ws-migrate/SKILL.md +6 -42
- package/templates/workspace/.opencode/skills/ws-plan/SKILL.md +4 -2
- package/templates/workspace/.opencode/skills/ws-plan-verify/SKILL.md +6 -49
- package/templates/workspace/.opencode/skills/ws-preflight/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-pull/SKILL.md +8 -109
- package/templates/workspace/.opencode/skills/ws-push/SKILL.md +8 -100
- package/templates/workspace/.opencode/skills/ws-quality-review/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-change/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-contract-sync/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-contract-validate/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-flow-sync/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-review/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-review/SKILL.md +14 -3
- package/templates/workspace/.opencode/skills/ws-rule/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-spec-review/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-submodule-setup/SKILL.md +10 -57
- package/templates/workspace/.opencode/skills/ws-verify-before-complete/SKILL.md +12 -53
- package/templates/workspace/AGENTS.md +5 -2
- package/templates/workspace/AI_PROJECT.md +1 -1
- package/templates/workspace/changes/README.md +9 -12
- package/templates/workspace/manifest.json +265 -207
- package/templates/workspace/.agents/skills/ws-migrate/SKILL.md +0 -54
- package/templates/workspace/.agents/skills/ws-pull/SKILL.md +0 -119
- package/templates/workspace/.agents/skills/ws-push/SKILL.md +0 -110
- package/templates/workspace/.agents/skills/ws-submodule-setup/SKILL.md +0 -65
- package/templates/workspace/.claude/skills/ws-migrate/SKILL.md +0 -54
- package/templates/workspace/.claude/skills/ws-pull/SKILL.md +0 -119
- package/templates/workspace/.claude/skills/ws-push/SKILL.md +0 -110
- package/templates/workspace/.claude/skills/ws-submodule-setup/SKILL.md +0 -65
|
@@ -4,90 +4,15 @@ description: 推送:submodule 感知(先 submodules 后 superproject;fast-
|
|
|
4
4
|
<!-- AIWS_MANAGED_BEGIN:opencode:ws-push -->
|
|
5
5
|
# ws push
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Thin CLI wrapper. Delegates to `aiws push`.
|
|
8
8
|
|
|
9
|
-
目标:安全 push 当前仓库;若仓库包含 submodules,则先 push submodules,再 push superproject(默认 fast-forward;不 force)。
|
|
10
|
-
|
|
11
|
-
强制约束:
|
|
12
|
-
- 不自动提交、不自动 `git add -A`
|
|
13
|
-
- 不使用 `--force` / `--force-with-lease`
|
|
14
|
-
- 若工作区不干净:停止并要求先 commit 或 stash
|
|
15
|
-
|
|
16
|
-
步骤(建议):
|
|
17
|
-
1) 输出上下文并检查工作区干净:
|
|
18
|
-
```bash
|
|
19
|
-
git branch --show-current
|
|
20
|
-
git status --porcelain
|
|
21
|
-
git status -sb
|
|
22
|
-
```
|
|
23
|
-
若 `git status --porcelain` 非空:停止。
|
|
24
|
-
|
|
25
|
-
2) 判断是否存在 submodules:
|
|
26
|
-
```bash
|
|
27
|
-
if [[ -f .gitmodules ]]; then
|
|
28
|
-
git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$' || true
|
|
29
|
-
fi
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
3) 若没有 submodules:正常 push(仍需用户确认远端/分支):
|
|
33
|
-
```bash
|
|
34
|
-
git remote -v
|
|
35
|
-
git push
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
4) 若有 submodules:先检查 `.gitmodules` 的 `submodule.<name>.branch` 是否齐全(缺失则停止并提示 `/ws-submodule-setup`):
|
|
39
9
|
```bash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
missing=1
|
|
47
|
-
fi
|
|
48
|
-
done < <(git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$' 2>/dev/null || true)
|
|
49
|
-
if [[ "$missing" -ne 0 ]]; then
|
|
50
|
-
echo "hint: run /ws-submodule-setup (and commit .gitmodules), then retry"
|
|
51
|
-
exit 2
|
|
10
|
+
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
11
|
+
./node_modules/.bin/aiws push
|
|
12
|
+
elif command -v aiws >/dev/null 2>&1; then
|
|
13
|
+
aiws push
|
|
14
|
+
else
|
|
15
|
+
npx @aipper/aiws push
|
|
52
16
|
fi
|
|
53
17
|
```
|
|
54
|
-
|
|
55
|
-
5) 逐个 push submodules(fast-forward only),再 push superproject:
|
|
56
|
-
```bash
|
|
57
|
-
base_branch="$(git branch --show-current)"
|
|
58
|
-
|
|
59
|
-
git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$' 2>/dev/null \
|
|
60
|
-
| while read -r key sub_path; do
|
|
61
|
-
name="${key#submodule.}"; name="${name%.path}"
|
|
62
|
-
echo "== submodule: ${sub_path} (${name}) =="
|
|
63
|
-
|
|
64
|
-
if [[ -n "$(git -C "${sub_path}" status --porcelain 2>/dev/null || true)" ]]; then
|
|
65
|
-
echo "error: submodule dirty: ${sub_path}"
|
|
66
|
-
exit 2
|
|
67
|
-
fi
|
|
68
|
-
|
|
69
|
-
cfg_branch="$(git config --file .gitmodules --get "submodule.${name}.branch" 2>/dev/null || true)"
|
|
70
|
-
if [[ "${cfg_branch:-}" == "." ]]; then cfg_branch="$base_branch"; fi
|
|
71
|
-
target_branch="${cfg_branch}"
|
|
72
|
-
|
|
73
|
-
git -C "${sub_path}" fetch origin --prune
|
|
74
|
-
if ! git -C "${sub_path}" show-ref --verify --quiet "refs/remotes/origin/${target_branch}"; then
|
|
75
|
-
echo "error: missing origin/${target_branch} for ${sub_path}"
|
|
76
|
-
exit 2
|
|
77
|
-
fi
|
|
78
|
-
|
|
79
|
-
if ! git -C "${sub_path}" merge-base --is-ancestor "origin/${target_branch}" HEAD; then
|
|
80
|
-
echo "error: non-fast-forward (submodule=${sub_path}, branch=${target_branch})"
|
|
81
|
-
exit 2
|
|
82
|
-
fi
|
|
83
|
-
|
|
84
|
-
git -C "${sub_path}" push origin "HEAD:refs/heads/${target_branch}"
|
|
85
|
-
done
|
|
86
|
-
|
|
87
|
-
git remote -v
|
|
88
|
-
git push
|
|
89
|
-
```
|
|
90
18
|
<!-- AIWS_MANAGED_END:opencode:ws-push -->
|
|
91
|
-
|
|
92
|
-
可在下方追加本项目对 OpenCode 的额外说明(托管块外内容会被保留)。
|
|
93
|
-
|
|
@@ -19,7 +19,11 @@ description: 评审:提交前审计改动并落盘证据
|
|
|
19
19
|
3) 将审计落盘到(目录不存在则创建):
|
|
20
20
|
- 默认:`changes/<change-id>/review/codex-review.md`
|
|
21
21
|
- 回退:`.agentdocs/tmp/review/codex-review.md`(仅在无法确定 `change-id` 时使用)
|
|
22
|
-
4)
|
|
22
|
+
4) 若当前任务已进入“准备提交/交付/finish”的语境,继续补齐 dual review gate:
|
|
23
|
+
- 运行/收敛 `/ws-spec-review`,落盘 `changes/<change-id>/review/spec-review.md`
|
|
24
|
+
- 运行/收敛 `/ws-quality-review`,落盘 `changes/<change-id>/review/quality-review.md`
|
|
25
|
+
- 不要把单个 `codex-review.md` 误当成 finish gate 已完成
|
|
26
|
+
5) 回复中输出:
|
|
23
27
|
- `证据(Evidence):` 证据文件路径
|
|
24
28
|
- `主要风险(Top risks):` 3–8 条(高→低)
|
|
25
29
|
- `下一步(Next):` 最小修复清单 + 最小验证命令
|
|
@@ -4,54 +4,15 @@ description: 子模块分支对齐(写入 .gitmodules 的 submodule.<name>.bra
|
|
|
4
4
|
<!-- AIWS_MANAGED_BEGIN:opencode:ws-submodule-setup -->
|
|
5
5
|
# ws submodule setup
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Thin CLI wrapper. Delegates to `aiws submodule-setup`.
|
|
8
8
|
|
|
9
|
-
目标:为每个 submodule 写入 `.gitmodules` 的 `submodule.<name>.branch`(团队真值),让 `/ws-pull`、`/ws-finish` 能确定性地减少 detached 与人为差异;并使 `aiws validate` 的 submodule 分支门禁通过。
|
|
10
|
-
|
|
11
|
-
约束:
|
|
12
|
-
- 不自动提交、不自动 push(必须先输出 diff 并让用户确认)
|
|
13
|
-
- 不做破坏性命令
|
|
14
|
-
|
|
15
|
-
步骤(建议):
|
|
16
|
-
1) 确认工作区干净:
|
|
17
|
-
```bash
|
|
18
|
-
git status --porcelain
|
|
19
|
-
```
|
|
20
|
-
非空则停止。
|
|
21
|
-
|
|
22
|
-
2) 列出 submodules:
|
|
23
9
|
```bash
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
name="${key#submodule.}"; name="${name%.path}"
|
|
32
|
-
echo "== submodule: ${name} path=${sub_path} =="
|
|
33
|
-
echo "[current] branch=$(git config --file .gitmodules --get submodule.${name}.branch || true)"
|
|
34
|
-
echo "[origin] HEAD=$(git -C \"${sub_path}\" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
|
35
|
-
done < <(git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$')
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
4) 写入分支配置:
|
|
39
|
-
```bash
|
|
40
|
-
git submodule set-branch --branch <branch-or-dot> <sub_path>
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
5) 输出 diff 并让用户确认是否提交:
|
|
44
|
-
```bash
|
|
45
|
-
git diff -- .gitmodules
|
|
46
|
-
git status --porcelain
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
6) 用户确认后提交:
|
|
50
|
-
```bash
|
|
51
|
-
git add .gitmodules
|
|
52
|
-
git commit -m "chore(submodule): set tracking branches"
|
|
10
|
+
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
11
|
+
./node_modules/.bin/aiws submodule-setup
|
|
12
|
+
elif command -v aiws >/dev/null 2>&1; then
|
|
13
|
+
aiws submodule-setup
|
|
14
|
+
else
|
|
15
|
+
npx @aipper/aiws submodule-setup
|
|
16
|
+
fi
|
|
53
17
|
```
|
|
54
18
|
<!-- AIWS_MANAGED_END:opencode:ws-submodule-setup -->
|
|
55
|
-
|
|
56
|
-
可在下方追加本项目对 OpenCode 的额外说明(托管块外内容会被保留)。
|
|
57
|
-
|
|
@@ -4,24 +4,15 @@ description: 完成前验证:finish / handoff 前检查双审查与 validate/e
|
|
|
4
4
|
<!-- AIWS_MANAGED_BEGIN:opencode:ws-verify-before-complete -->
|
|
5
5
|
# ws verify before complete
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Thin CLI wrapper. Delegates to `aiws verify-bc`.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
1
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- 默认:`changes/<change-id>/evidence/verify-before-complete.md`
|
|
19
|
-
- 回退:`.agentdocs/tmp/review/verify-before-complete.md`
|
|
20
|
-
4) 输出:
|
|
21
|
-
- `证据(Evidence):`
|
|
22
|
-
- `结论(Result): pass|fail`
|
|
23
|
-
- `缺失项(Missing):`
|
|
24
|
-
- `下一步(Next):`
|
|
9
|
+
```bash
|
|
10
|
+
if [[ -x "./node_modules/.bin/aiws" ]]; then
|
|
11
|
+
./node_modules/.bin/aiws verify-bc
|
|
12
|
+
elif command -v aiws >/dev/null 2>&1; then
|
|
13
|
+
aiws verify-bc
|
|
14
|
+
else
|
|
15
|
+
npx @aipper/aiws verify-bc
|
|
16
|
+
fi
|
|
17
|
+
```
|
|
25
18
|
<!-- AIWS_MANAGED_END:opencode:ws-verify-before-complete -->
|
|
26
|
-
|
|
27
|
-
可在下方追加本项目对 OpenCode 的额外说明(托管块外内容会被保留)。
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
workspace_root="${1:-.}"
|
|
5
|
+
shift || true
|
|
6
|
+
|
|
7
|
+
command_text=""
|
|
8
|
+
decision_kind="unknown"
|
|
9
|
+
paths=()
|
|
10
|
+
|
|
11
|
+
while [[ $# -gt 0 ]]; do
|
|
12
|
+
case "$1" in
|
|
13
|
+
--command)
|
|
14
|
+
command_text="${2:-}"
|
|
15
|
+
shift 2
|
|
16
|
+
;;
|
|
17
|
+
--kind)
|
|
18
|
+
decision_kind="${2:-unknown}"
|
|
19
|
+
shift 2
|
|
20
|
+
;;
|
|
21
|
+
--path)
|
|
22
|
+
paths+=("${2:-}")
|
|
23
|
+
shift 2
|
|
24
|
+
;;
|
|
25
|
+
*)
|
|
26
|
+
echo "error: unknown argument: $1" >&2
|
|
27
|
+
exit 2
|
|
28
|
+
;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
python3 - "$workspace_root" "$command_text" "$decision_kind" "${paths[@]-}" <<'PY'
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import shlex
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from fnmatch import fnmatch
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
workspace_root = Path(sys.argv[1]).resolve()
|
|
42
|
+
command_text = sys.argv[2]
|
|
43
|
+
decision_kind = sys.argv[3]
|
|
44
|
+
paths = sys.argv[4:]
|
|
45
|
+
|
|
46
|
+
config_path = workspace_root / ".opencode" / "oh-my-opencode.json"
|
|
47
|
+
out_dir = Path(os.environ.get("AIWS_OPENCODE_AUTONOMY_DIR", workspace_root / ".agentdocs" / "tmp" / "opencode-autonomy"))
|
|
48
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
log_file = out_dir / "approval-whitelist.log"
|
|
50
|
+
result_file = out_dir / "approval-whitelist-last.json"
|
|
51
|
+
|
|
52
|
+
decision = "manual"
|
|
53
|
+
reason = "missing_policy"
|
|
54
|
+
policy = None
|
|
55
|
+
|
|
56
|
+
def normalize_signature(command: str) -> str:
|
|
57
|
+
if not command.strip():
|
|
58
|
+
return ""
|
|
59
|
+
tokens = shlex.split(command)
|
|
60
|
+
if not tokens:
|
|
61
|
+
return ""
|
|
62
|
+
if tokens[0] == "git" and len(tokens) >= 2:
|
|
63
|
+
return f"git {tokens[1]}"
|
|
64
|
+
return tokens[0]
|
|
65
|
+
|
|
66
|
+
if config_path.exists():
|
|
67
|
+
try:
|
|
68
|
+
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
payload = {
|
|
71
|
+
"workspace_root": str(workspace_root),
|
|
72
|
+
"config_path": str(config_path),
|
|
73
|
+
"command": command_text,
|
|
74
|
+
"command_signature": normalize_signature(command_text) if command_text.strip() else "",
|
|
75
|
+
"kind": decision_kind,
|
|
76
|
+
"paths": paths,
|
|
77
|
+
"decision": "manual",
|
|
78
|
+
"reason": "invalid_config_json",
|
|
79
|
+
}
|
|
80
|
+
result_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
81
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
82
|
+
with log_file.open("a", encoding="utf-8") as fh:
|
|
83
|
+
fh.write(f"[{timestamp}] decision=manual reason=invalid_config_json kind={decision_kind} signature={(payload['command_signature'] or '(empty)')} paths={paths}\n")
|
|
84
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
policy = raw.get("aiws", {}).get("autonomy", {}).get("approval_whitelist", {})
|
|
87
|
+
|
|
88
|
+
def path_matches(target: str, patterns: list[str]) -> bool:
|
|
89
|
+
normalized = target.replace("\\", "/")
|
|
90
|
+
for pattern in patterns:
|
|
91
|
+
if fnmatch(normalized, pattern):
|
|
92
|
+
return True
|
|
93
|
+
if pattern.endswith("/") and fnmatch(normalized, f"{pattern}*"):
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
command_signature = normalize_signature(command_text)
|
|
98
|
+
|
|
99
|
+
payload = {
|
|
100
|
+
"workspace_root": str(workspace_root),
|
|
101
|
+
"config_path": str(config_path),
|
|
102
|
+
"command": command_text,
|
|
103
|
+
"command_signature": command_signature,
|
|
104
|
+
"kind": decision_kind,
|
|
105
|
+
"paths": paths,
|
|
106
|
+
"decision": decision,
|
|
107
|
+
"reason": reason,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if policy and policy.get("enabled") is True:
|
|
111
|
+
deny_commands = [item for item in policy.get("deny_commands", []) if isinstance(item, str) and item.strip()]
|
|
112
|
+
deny_paths = [item for item in policy.get("deny_paths", []) if isinstance(item, str) and item.strip()]
|
|
113
|
+
read_only_commands = [item for item in policy.get("read_only_commands", []) if isinstance(item, str) and item.strip()]
|
|
114
|
+
write_allow_paths = [item for item in policy.get("write_allow_paths", []) if isinstance(item, str) and item.strip()]
|
|
115
|
+
host_permission_mode = policy.get("host_permission_mode", "")
|
|
116
|
+
|
|
117
|
+
if decision_kind == "host-permission":
|
|
118
|
+
decision = "manual"
|
|
119
|
+
reason = f"host_permission_mode={host_permission_mode or 'missing'}"
|
|
120
|
+
elif command_signature in deny_commands:
|
|
121
|
+
decision = "deny"
|
|
122
|
+
reason = "deny_command"
|
|
123
|
+
elif any(path_matches(path, deny_paths) for path in paths):
|
|
124
|
+
decision = "deny"
|
|
125
|
+
reason = "deny_path"
|
|
126
|
+
elif decision_kind == "read" and command_signature in read_only_commands:
|
|
127
|
+
decision = "allow"
|
|
128
|
+
reason = "read_only_command"
|
|
129
|
+
elif decision_kind == "write" and paths and all(path_matches(path, write_allow_paths) for path in paths):
|
|
130
|
+
decision = "allow"
|
|
131
|
+
reason = "write_allow_path"
|
|
132
|
+
else:
|
|
133
|
+
decision = "manual"
|
|
134
|
+
reason = "policy_requires_manual_review"
|
|
135
|
+
|
|
136
|
+
payload["host_permission_mode"] = host_permission_mode
|
|
137
|
+
payload["policy_mode"] = policy.get("mode", "")
|
|
138
|
+
|
|
139
|
+
payload["decision"] = decision
|
|
140
|
+
payload["reason"] = reason
|
|
141
|
+
result_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
142
|
+
|
|
143
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
144
|
+
with log_file.open("a", encoding="utf-8") as fh:
|
|
145
|
+
fh.write(f"[{timestamp}] decision={decision} reason={reason} kind={decision_kind} signature={command_signature or '(empty)'} paths={paths}\n")
|
|
146
|
+
|
|
147
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
148
|
+
PY
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
workspace_root="${1:-.}"
|
|
5
|
+
shift || true
|
|
6
|
+
|
|
7
|
+
helper_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
8
|
+
check_script="${helper_dir}/approval-whitelist-check.sh"
|
|
9
|
+
|
|
10
|
+
if [[ ! -x "${check_script}" && ! -f "${check_script}" ]]; then
|
|
11
|
+
echo "error: missing checker: ${check_script}" >&2
|
|
12
|
+
exit 2
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
decision_json="$(bash "${check_script}" "${workspace_root}" "$@")"
|
|
16
|
+
|
|
17
|
+
python3 - "${workspace_root}" "${decision_json}" <<'PY'
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
workspace_root = Path(sys.argv[1]).resolve()
|
|
27
|
+
payload = json.loads(sys.argv[2])
|
|
28
|
+
|
|
29
|
+
out_dir = Path(os.environ.get("AIWS_OPENCODE_AUTONOMY_DIR", workspace_root / ".agentdocs" / "tmp" / "opencode-autonomy"))
|
|
30
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
log_file = out_dir / "approval-whitelist-exec.log"
|
|
32
|
+
result_file = out_dir / "approval-whitelist-exec-last.json"
|
|
33
|
+
|
|
34
|
+
decision = payload.get("decision", "manual")
|
|
35
|
+
command_text = str(payload.get("command", ""))
|
|
36
|
+
|
|
37
|
+
def finish(result: dict, exit_code: int) -> None:
|
|
38
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
39
|
+
result_file.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
40
|
+
with log_file.open("a", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(
|
|
42
|
+
f"[{timestamp}] decision={result.get('decision')} executed={result.get('executed')} exit_code={result.get('exit_code')} reason={result.get('reason')} command={result.get('command_signature') or '(empty)'}\n"
|
|
43
|
+
)
|
|
44
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
45
|
+
sys.exit(exit_code)
|
|
46
|
+
|
|
47
|
+
if decision == "deny":
|
|
48
|
+
finish({**payload, "executed": False, "exit_code": None, "reason": payload.get("reason", "deny_command")}, 4)
|
|
49
|
+
|
|
50
|
+
if decision != "allow":
|
|
51
|
+
finish({**payload, "executed": False, "exit_code": None, "reason": payload.get("reason", "manual_review_required")}, 3)
|
|
52
|
+
|
|
53
|
+
if not command_text.strip():
|
|
54
|
+
finish({**payload, "decision": "manual", "executed": False, "exit_code": None, "reason": "empty_command"}, 3)
|
|
55
|
+
|
|
56
|
+
unsafe_fragments = ["&&", "||", ";", "|", ">", "<", "$(", "`"]
|
|
57
|
+
if any(fragment in command_text for fragment in unsafe_fragments):
|
|
58
|
+
finish({**payload, "decision": "manual", "executed": False, "exit_code": None, "reason": "unsupported_shell_syntax"}, 3)
|
|
59
|
+
|
|
60
|
+
command_args = shlex.split(command_text)
|
|
61
|
+
if not command_args:
|
|
62
|
+
finish({**payload, "decision": "manual", "executed": False, "exit_code": None, "reason": "empty_command"}, 3)
|
|
63
|
+
|
|
64
|
+
proc = subprocess.run(
|
|
65
|
+
command_args,
|
|
66
|
+
cwd=str(workspace_root),
|
|
67
|
+
text=True,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
check=False,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result = {
|
|
73
|
+
**payload,
|
|
74
|
+
"executed": True,
|
|
75
|
+
"exit_code": proc.returncode,
|
|
76
|
+
"stdout_line_count": len(proc.stdout.splitlines()),
|
|
77
|
+
"stderr_line_count": len(proc.stderr.splitlines()),
|
|
78
|
+
"reason": "executed",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
finish(result, proc.returncode)
|
|
82
|
+
PY
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
workspace_root="${1:-.}"
|
|
5
|
+
shift || true
|
|
6
|
+
|
|
7
|
+
out_dir="${AIWS_OPENCODE_AUTONOMY_DIR:-${workspace_root}/.agentdocs/tmp/opencode-autonomy}"
|
|
8
|
+
queue_file="${out_dir}/approval-watchdog-queue.jsonl"
|
|
9
|
+
once=false
|
|
10
|
+
poll_ms=2000
|
|
11
|
+
|
|
12
|
+
while [[ $# -gt 0 ]]; do
|
|
13
|
+
case "$1" in
|
|
14
|
+
--queue)
|
|
15
|
+
queue_file="${2:-}"
|
|
16
|
+
shift 2
|
|
17
|
+
;;
|
|
18
|
+
--once)
|
|
19
|
+
once=true
|
|
20
|
+
shift
|
|
21
|
+
;;
|
|
22
|
+
--poll-ms)
|
|
23
|
+
poll_ms="${2:-2000}"
|
|
24
|
+
shift 2
|
|
25
|
+
;;
|
|
26
|
+
*)
|
|
27
|
+
echo "error: unknown argument: $1" >&2
|
|
28
|
+
exit 2
|
|
29
|
+
;;
|
|
30
|
+
esac
|
|
31
|
+
done
|
|
32
|
+
|
|
33
|
+
helper_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
34
|
+
runner_script="${helper_dir}/approval-whitelist-run.sh"
|
|
35
|
+
|
|
36
|
+
python3 - "$workspace_root" "$queue_file" "$runner_script" "$poll_ms" "$once" <<'PY'
|
|
37
|
+
import json
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import time
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
workspace_root = Path(sys.argv[1]).resolve()
|
|
45
|
+
queue_file = Path(sys.argv[2]).resolve()
|
|
46
|
+
runner_script = Path(sys.argv[3]).resolve()
|
|
47
|
+
poll_ms = max(int(sys.argv[4]), 100)
|
|
48
|
+
once = sys.argv[5].lower() == "true"
|
|
49
|
+
|
|
50
|
+
out_dir = queue_file.parent
|
|
51
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
results_file = out_dir / "approval-watchdog-results.jsonl"
|
|
53
|
+
state_file = out_dir / "approval-watchdog-state.json"
|
|
54
|
+
log_file = out_dir / "approval-watchdog.log"
|
|
55
|
+
|
|
56
|
+
def now() -> str:
|
|
57
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
58
|
+
|
|
59
|
+
def load_state() -> dict:
|
|
60
|
+
if not state_file.exists():
|
|
61
|
+
return {"processed_ids": [], "last_run_at": None, "processed_count": 0}
|
|
62
|
+
return json.loads(state_file.read_text(encoding="utf-8"))
|
|
63
|
+
|
|
64
|
+
def save_state(state: dict) -> None:
|
|
65
|
+
state_file.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
def append_log(message: str) -> None:
|
|
68
|
+
with log_file.open("a", encoding="utf-8") as fh:
|
|
69
|
+
fh.write(f"[{now()}] {message}\n")
|
|
70
|
+
|
|
71
|
+
def normalize_entry(raw: dict, index: int) -> dict:
|
|
72
|
+
entry_id = str(raw.get("id") or f"queue-{index}")
|
|
73
|
+
kind = str(raw.get("kind") or "unknown")
|
|
74
|
+
command = str(raw.get("command") or "")
|
|
75
|
+
paths = raw.get("paths") if isinstance(raw.get("paths"), list) else []
|
|
76
|
+
paths = [str(item) for item in paths if str(item).strip()]
|
|
77
|
+
return {"id": entry_id, "kind": kind, "command": command, "paths": paths}
|
|
78
|
+
|
|
79
|
+
def run_entry(entry: dict) -> dict:
|
|
80
|
+
args = ["bash", str(runner_script), str(workspace_root), "--kind", entry["kind"], "--command", entry["command"]]
|
|
81
|
+
for path in entry["paths"]:
|
|
82
|
+
args.extend(["--path", path])
|
|
83
|
+
proc = subprocess.run(args, cwd=str(workspace_root), text=True, capture_output=True, check=False)
|
|
84
|
+
stdout = proc.stdout.strip()
|
|
85
|
+
payload = json.loads(stdout) if stdout else {}
|
|
86
|
+
result = {
|
|
87
|
+
"id": entry["id"],
|
|
88
|
+
"queued_kind": entry["kind"],
|
|
89
|
+
"queued_command": entry["command"],
|
|
90
|
+
"queued_paths": entry["paths"],
|
|
91
|
+
"runner_exit_code": proc.returncode,
|
|
92
|
+
"result": payload,
|
|
93
|
+
"processed_at": now(),
|
|
94
|
+
}
|
|
95
|
+
with results_file.open("a", encoding="utf-8") as fh:
|
|
96
|
+
fh.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
97
|
+
append_log(
|
|
98
|
+
f"id={entry['id']} exit_code={proc.returncode} decision={payload.get('decision', 'unknown')} executed={payload.get('executed', False)}"
|
|
99
|
+
)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
def process_pending() -> int:
|
|
103
|
+
state = load_state()
|
|
104
|
+
processed_ids = set(str(item) for item in state.get("processed_ids", []))
|
|
105
|
+
processed_now = 0
|
|
106
|
+
if not queue_file.exists():
|
|
107
|
+
state["last_run_at"] = now()
|
|
108
|
+
save_state(state)
|
|
109
|
+
append_log("queue_missing")
|
|
110
|
+
return processed_now
|
|
111
|
+
lines = queue_file.read_text(encoding="utf-8").splitlines()
|
|
112
|
+
for index, line in enumerate(lines, start=1):
|
|
113
|
+
stripped = line.strip()
|
|
114
|
+
if not stripped:
|
|
115
|
+
continue
|
|
116
|
+
raw = json.loads(stripped)
|
|
117
|
+
entry = normalize_entry(raw, index)
|
|
118
|
+
if entry["id"] in processed_ids:
|
|
119
|
+
continue
|
|
120
|
+
run_entry(entry)
|
|
121
|
+
processed_ids.add(entry["id"])
|
|
122
|
+
processed_now += 1
|
|
123
|
+
state["processed_ids"] = sorted(processed_ids)
|
|
124
|
+
state["processed_count"] = len(processed_ids)
|
|
125
|
+
state["last_run_at"] = now()
|
|
126
|
+
save_state(state)
|
|
127
|
+
append_log(f"cycle_complete processed_now={processed_now}")
|
|
128
|
+
return processed_now
|
|
129
|
+
|
|
130
|
+
if once:
|
|
131
|
+
process_pending()
|
|
132
|
+
print(state_file)
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
append_log(f"watchdog_start poll_ms={poll_ms}")
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
process_pending()
|
|
139
|
+
time.sleep(poll_ms / 1000.0)
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
append_log("watchdog_stop keyboard_interrupt")
|
|
142
|
+
print(state_file)
|
|
143
|
+
sys.exit(0)
|
|
144
|
+
PY
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
5
|
+
echo "error: tmux not found" >&2
|
|
6
|
+
exit 1
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
out_dir="${AIWS_OPENCODE_AUTONOMY_DIR:-.agentdocs/tmp/opencode-autonomy}"
|
|
10
|
+
scan_file="${1:-${out_dir}/tmux-scan.json}"
|
|
11
|
+
log_file="${out_dir}/tmux-rescue.log"
|
|
12
|
+
mkdir -p "${out_dir}"
|
|
13
|
+
|
|
14
|
+
if [[ ! -f "${scan_file}" ]]; then
|
|
15
|
+
echo "error: scan file missing: ${scan_file}" >&2
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
python3 - "${scan_file}" "${log_file}" <<'PY'
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
|
|
25
|
+
scan_file, log_file = sys.argv[1:3]
|
|
26
|
+
|
|
27
|
+
def tmux(*args):
|
|
28
|
+
subprocess.run(["tmux", *args], check=True)
|
|
29
|
+
|
|
30
|
+
with open(scan_file, "r", encoding="utf-8") as fh:
|
|
31
|
+
payload = json.load(fh)
|
|
32
|
+
|
|
33
|
+
actions = []
|
|
34
|
+
for pane in payload.get("panes", []):
|
|
35
|
+
target = str(pane.get("target", ""))
|
|
36
|
+
if not target:
|
|
37
|
+
continue
|
|
38
|
+
if pane.get("waiting_confirm") is True:
|
|
39
|
+
tmux("send-keys", "-t", target, "y", "Enter")
|
|
40
|
+
actions.append((target, "confirm_yes"))
|
|
41
|
+
continue
|
|
42
|
+
if pane.get("press_enter") is True:
|
|
43
|
+
tmux("send-keys", "-t", target, "Enter")
|
|
44
|
+
actions.append((target, "press_enter"))
|
|
45
|
+
continue
|
|
46
|
+
if pane.get("pane_in_mode") is True:
|
|
47
|
+
tmux("send-keys", "-t", target, "-X", "cancel")
|
|
48
|
+
actions.append((target, "cancel_copy_mode"))
|
|
49
|
+
|
|
50
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
51
|
+
with open(log_file, "a", encoding="utf-8") as fh:
|
|
52
|
+
for target, action in actions:
|
|
53
|
+
fh.write(f"[{timestamp}] {target} {action}\n")
|
|
54
|
+
|
|
55
|
+
print(log_file)
|
|
56
|
+
PY
|