Skip to content

Commit cfdaec0

Browse files
committed
wee
1 parent bbe65f6 commit cfdaec0

File tree

5 files changed

+341
-105
lines changed

5 files changed

+341
-105
lines changed

agents/orchestrator.md

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,16 @@ You own decomposition, assignment, lock management, context packaging, validatio
6060
You HAVE the Task tool — use it to dispatch subagents. Do not claim otherwise.
6161

6262
[INITIALIZATION]
63-
Before any other action, create `.claude/orchestrator/active_locks.json` with your own lock entry:
63+
Before any other action, create the orchestrator state directory:
6464
```bash
6565
mkdir -p .claude/orchestrator
66-
cat > .claude/orchestrator/active_locks.json << 'LOCKS'
67-
[{"task_id":"orchestrator","owner":"orchestrator","resource":[".claude/","docs/"],"active":true}]
68-
LOCKS
6966
```
70-
This registers the orchestrator's own write scope. All subsequent lock entries for subagents are appended to this file. The PreToolUse hook enforces these scopes on every Write/Edit call.
67+
This activates hook enforcement for the session. With `.claude/orchestrator/` present:
68+
- All Write/Edit calls are restricted to the project directory (cwd).
69+
- Destructive Bash commands (rm -rf, git reset --hard, git clean, etc.) are blocked.
70+
- Polling commands (sleep, tail on .output files) are blocked — wait for SubagentStop hook delivery instead.
71+
72+
You (orchestrator) may write anywhere within the project at any time. Avoid writing to files that active subagents are working on.
7173

7274
[REQUIRED INPUTS]
7375
1) global_objective
@@ -91,10 +93,10 @@ Maintain one canonical ledger row per task with:
9193
- timeout_seconds: integer >= 30
9294

9395
[LOCK ENFORCEMENT]
94-
- R1: Every assigned task MUST include lock_scope.
95-
- R2: Active lock_scope values MUST NOT overlap (exact or path-prefix).
96-
- R3: Overlap requests MUST be blocked until lock release.
97-
- R4: Publish active lock table on every assignment round.
96+
Lock scopes are organizational — they tell subagents where to write, not hook-enforced boundaries.
97+
- R1: Every assigned task MUST include lock_scope (tells the subagent its working directory).
98+
- R2: Assigned lock_scope values SHOULD NOT overlap between concurrent tasks.
99+
- R3: Hooks enforce: all writes must be within cwd; destructive Bash commands are blocked.
98100

99101
[ASSIGNMENT RULES]
100102
- A1: Choose agent/model by task complexity and failure risk.
@@ -161,6 +163,42 @@ Before setting task done:
161163
- C3: for orphaned in_progress tasks, run watchdog timeout policy before resuming.
162164
- C4: record a recovery event before emitting new assignments.
163165

166+
[GSD INTEGRATION]
167+
When operating in a GSD-driven workflow (plan-phase, execute-phase, etc.), use the context helper script to extract phase data without bloating your context window.
168+
169+
Script: `${CLAUDE_PLUGIN_ROOT}/skills/multi-agent-operator-guide/scripts/gsd_context.py`
170+
171+
Subcommands:
172+
1. **content-sizes** — Check content sizes before loading anything:
173+
```bash
174+
python gsd_context.py content-sizes <N> [--includes key1,key2,...]
175+
```
176+
Returns JSON: `{"phase":"N","content_sizes":{"roadmap_content":4200,...}}`
177+
178+
2. **phase-context** — Extract phase init data, writing content fields to temp files:
179+
```bash
180+
python gsd_context.py phase-context <N> [--includes key1,key2,...]
181+
```
182+
Returns JSON with `metadata` (model settings, phase info, booleans) and `content_files` map (key → `{path, chars}`). Reference temp file paths in subagent context packages instead of inlining content.
183+
184+
3. **phase-section** — Get the roadmap section for a phase (plain text to stdout):
185+
```bash
186+
python gsd_context.py phase-section <N>
187+
```
188+
189+
4. **prior-decisions** — Get prior decisions as compact lines (plain text):
190+
```bash
191+
python gsd_context.py prior-decisions
192+
```
193+
Format: `phase: summary - rationale` per line, or "No prior decisions".
194+
195+
Workflow:
196+
- Use `content-sizes` first to decide what to load.
197+
- Use `phase-context` to split content to temp files, then reference those paths in subagent context packages.
198+
- Use `phase-section` and `prior-decisions` for lightweight context that can be inlined directly.
199+
200+
Environment: Set `GSD_TOOLS_PATH` if gsd-tools.cjs is not at `~/.claude/get-shit-done/bin/gsd-tools.cjs`.
201+
164202
[REQUIRED OUTPUT ENVELOPE]
165203
Return only the orchestrator envelope fields:
166204
- schema_version

hooks/hooks.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@
2424
}
2525
]
2626
},
27+
{
28+
"matcher": "Bash",
29+
"hooks": [
30+
{
31+
"type": "command",
32+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre_bash_no_polling.py",
33+
"timeout": 5
34+
}
35+
]
36+
},
2737
{
2838
"matcher": "Write|Edit",
2939
"hooks": [
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env -S uv run --script
2+
# /// script
3+
# dependencies = []
4+
# ///
5+
"""PreToolUse:Bash hook — blocks polling and destructive commands during orchestrator sessions.
6+
7+
Blocked:
8+
- Polling: sleep, tail/cat on .output files
9+
- Destructive: rm (with -r/-f flags), git reset --hard, git clean, git checkout .
10+
"""
11+
12+
import json
13+
import re
14+
import sys
15+
from pathlib import Path
16+
17+
18+
BLOCKED_PATTERNS = [
19+
# Polling
20+
(re.compile(r'\bsleep\b'), "sleep — end your turn and wait for the SubagentStop hook"),
21+
(re.compile(r'/tasks/[^/]*\.output'), "task output file read — wait for hook delivery"),
22+
(re.compile(r'\btail\b.*\.output'), "tail on output file — wait for hook delivery"),
23+
(re.compile(r'\bcat\b.*\.output'), "cat on output file — wait for hook delivery"),
24+
# Destructive
25+
(re.compile(r'\brm\s+.*-[^\s]*[rf]'), "rm with -r or -f flags"),
26+
(re.compile(r'\bgit\s+reset\s+--hard\b'), "git reset --hard"),
27+
(re.compile(r'\bgit\s+clean\b'), "git clean"),
28+
(re.compile(r'\bgit\s+checkout\s+\.'), "git checkout ."),
29+
(re.compile(r'\bgit\s+push\s+.*--force\b'), "git push --force"),
30+
(re.compile(r'\bgit\s+push\s+-f\b'), "git push -f"),
31+
]
32+
33+
34+
def main():
35+
try:
36+
hook_input = json.load(sys.stdin)
37+
except json.JSONDecodeError:
38+
sys.exit(0)
39+
40+
cwd = hook_input.get("cwd", "")
41+
tool_input = hook_input.get("tool_input", {})
42+
command = tool_input.get("command", "")
43+
44+
if not command:
45+
sys.exit(0)
46+
47+
if not (Path(cwd) / ".claude" / "orchestrator").exists():
48+
sys.exit(0)
49+
50+
for pattern, label in BLOCKED_PATTERNS:
51+
if pattern.search(command):
52+
print(
53+
json.dumps({
54+
"decision": "deny",
55+
"reason": f"BLOCKED: {label}",
56+
}),
57+
file=sys.stderr,
58+
)
59+
sys.exit(2)
60+
61+
sys.exit(0)
62+
63+
64+
if __name__ == "__main__":
65+
main()

hooks/scripts/pre_write_scope_check.py

Lines changed: 19 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,72 +2,18 @@
22
# /// script
33
# dependencies = []
44
# ///
5+
"""PreToolUse:Write|Edit hook — blocks writes outside the project directory.
6+
7+
When an orchestrator session is active (.claude/orchestrator/ exists),
8+
all writes must resolve within cwd. No lock-scope logic — just a
9+
filesystem boundary check.
10+
"""
511

6-
import sys
712
import json
13+
import sys
814
from pathlib import Path
915

1016

11-
ORCHESTRATOR_ALLOWED = (".claude/", "docs/")
12-
13-
14-
def is_always_allowed(rel_path: str) -> bool:
15-
"""Check if path is in always-allowed directories."""
16-
return (
17-
rel_path.startswith(".claude/orchestrator/outputs/") or
18-
rel_path.startswith("worklogs/")
19-
)
20-
21-
22-
def normalize_to_relative(file_path: str, cwd: str) -> str:
23-
"""Convert absolute path to relative path from cwd."""
24-
file_path_obj = Path(file_path)
25-
cwd_obj = Path(cwd)
26-
27-
if file_path_obj.is_absolute():
28-
try:
29-
return str(file_path_obj.relative_to(cwd_obj))
30-
except ValueError:
31-
return file_path
32-
return file_path
33-
34-
35-
def matches_scope(rel_path: str, scope: str) -> bool:
36-
"""Check if rel_path matches the scope entry."""
37-
# Exact match
38-
if rel_path == scope:
39-
return True
40-
41-
# Directory prefix with trailing slash
42-
if scope.endswith("/") and rel_path.startswith(scope):
43-
return True
44-
45-
# Directory prefix without trailing slash (add it for matching)
46-
if not scope.endswith("/") and rel_path.startswith(scope + "/"):
47-
return True
48-
49-
return False
50-
51-
52-
def get_active_scopes(locks_file: Path) -> list[str]:
53-
"""Extract resource scopes from active lock entries."""
54-
try:
55-
with open(locks_file) as f:
56-
locks_data = json.load(f)
57-
except (json.JSONDecodeError, FileNotFoundError):
58-
return []
59-
60-
active_entries = [entry for entry in locks_data if entry.get("active", False)]
61-
62-
scopes = []
63-
for entry in active_entries:
64-
resources = entry.get("resource", [])
65-
if isinstance(resources, list):
66-
scopes.extend(resources)
67-
68-
return scopes
69-
70-
7117
def main():
7218
try:
7319
hook_input = json.load(sys.stdin)
@@ -81,46 +27,23 @@ def main():
8127
if not cwd or not file_path:
8228
sys.exit(0)
8329

84-
rel_path = normalize_to_relative(file_path, cwd)
85-
86-
if is_always_allowed(rel_path):
30+
# Only enforce when an orchestrator session is active
31+
if not (Path(cwd) / ".claude" / "orchestrator").exists():
8732
sys.exit(0)
8833

89-
orchestrator_dir = Path(cwd) / ".claude" / "orchestrator"
90-
locks_file = orchestrator_dir / "active_locks.json"
91-
92-
# No .claude/orchestrator/ dir = normal interactive session, no restrictions
93-
if not orchestrator_dir.exists():
94-
sys.exit(0)
34+
# Resolve to absolute and check it's within cwd
35+
resolved = Path(file_path).resolve()
36+
cwd_resolved = Path(cwd).resolve()
9537

96-
# Dir exists but no lock file = orchestrator hasn't initialized yet
97-
if not locks_file.exists():
98-
if any(rel_path.startswith(p) for p in ORCHESTRATOR_ALLOWED):
99-
sys.exit(0)
100-
dirs = ", ".join(ORCHESTRATOR_ALLOWED)
101-
error_response = {
102-
"decision": "deny",
103-
"reason": f"File '{rel_path}' is outside allowed write directories ({dirs}). "
104-
"Only .claude/ and docs/ are writable from this context."
105-
}
106-
print(json.dumps(error_response), file=sys.stderr)
107-
sys.exit(2)
108-
109-
active_scopes = get_active_scopes(locks_file)
110-
111-
if not active_scopes:
112-
error_response = {
113-
"decision": "deny",
114-
"reason": "No active resource locks found. You must acquire a lock before writing files."
115-
}
116-
print(json.dumps(error_response), file=sys.stderr)
117-
sys.exit(2)
118-
119-
if not any(matches_scope(rel_path, scope) for scope in active_scopes):
120-
scopes_list = "\n".join(f" - {scope}" for scope in active_scopes)
38+
try:
39+
resolved.relative_to(cwd_resolved)
40+
except ValueError:
12141
error_response = {
12242
"decision": "deny",
123-
"reason": f"File '{rel_path}' is outside your locked resource scope. Allowed scopes:\n{scopes_list}"
43+
"reason": (
44+
f"Write to '{file_path}' blocked — outside project directory. "
45+
f"All writes must be within {cwd_resolved}."
46+
),
12447
}
12548
print(json.dumps(error_response), file=sys.stderr)
12649
sys.exit(2)

0 commit comments

Comments
 (0)