feat(templates): add org-chart template (Paperclip pattern)
Session 4 step 18 — agent role/reports-to hierarchy with tree/validate/ add/remove/list commands, circular chain detection via python3. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
912689f3c5
commit
9d24dc5c41
3 changed files with 369 additions and 0 deletions
28
scripts/templates/org-chart/ORG-CHART.md
Normal file
28
scripts/templates/org-chart/ORG-CHART.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Organization: {{ORG_NAME}}
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
|
||||||
|
| Agent | Role | Reports To | Status | Budget |
|
||||||
|
|-------|------|------------|--------|--------|
|
||||||
|
| {{AGENT_1}} | {{ROLE_1}} | (board) | active | {{BUDGET_1}} |
|
||||||
|
| {{AGENT_2}} | {{ROLE_2}} | {{AGENT_1}} | active | {{BUDGET_2}} |
|
||||||
|
| {{AGENT_3}} | {{ROLE_3}} | {{AGENT_1}} | active | {{BUDGET_3}} |
|
||||||
|
|
||||||
|
## Delegation Rules
|
||||||
|
|
||||||
|
- Board (human) → top-level agents: task assignment, goal setting
|
||||||
|
- Manager agents → direct reports: task decomposition, delegation
|
||||||
|
- Cross-team requests: route through common ancestor in org chart
|
||||||
|
- Escalation: up the reporting chain to the first agent with authority
|
||||||
|
|
||||||
|
## Human Override
|
||||||
|
|
||||||
|
The human operator is the "board of directors" with override authority
|
||||||
|
on all decisions. Any agent can be paused, redirected, or terminated
|
||||||
|
by the human at any time.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Use `(board)` as the Reports To value for top-level agents that report
|
||||||
|
directly to the human operator. All other agents must reference a valid
|
||||||
|
agent name in the Reports To column.
|
||||||
62
scripts/templates/org-chart/README.md
Normal file
62
scripts/templates/org-chart/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Org Chart
|
||||||
|
|
||||||
|
File-based agent hierarchy using Paperclip's simple `reportsTo` pattern.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
- **Simple reportsTo FK, not recursive**: Each agent has a single `Reports To`
|
||||||
|
field referencing its parent by name. No recursive traversal at runtime.
|
||||||
|
- **Board = human operator**: Top-level agents use `(board)` to indicate they
|
||||||
|
report directly to the human. The human always has override authority.
|
||||||
|
- **Markdown table**: Human-editable, version-controlled, no service dependency.
|
||||||
|
|
||||||
|
## Delegation flow
|
||||||
|
|
||||||
|
```
|
||||||
|
(board) [human]
|
||||||
|
└─ orchestrator-agent (manager)
|
||||||
|
├─ research-agent (worker)
|
||||||
|
└─ writer-agent (worker)
|
||||||
|
```
|
||||||
|
|
||||||
|
Task assignment flows down the tree. Escalation flows up.
|
||||||
|
Cross-team requests are routed through the nearest common ancestor.
|
||||||
|
|
||||||
|
## Human override authority
|
||||||
|
|
||||||
|
The human operator ("board") has unconditional override authority:
|
||||||
|
- Any agent can be paused, redirected, or terminated at any time
|
||||||
|
- No agent can block a human instruction
|
||||||
|
- Governance gates apply to agents, not to the human operator
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show org tree
|
||||||
|
./org-manager.sh
|
||||||
|
|
||||||
|
# Validate chart (check agent files exist, no circular chains)
|
||||||
|
./org-manager.sh validate
|
||||||
|
|
||||||
|
# Add an agent
|
||||||
|
./org-manager.sh add writer-agent "Content Writer" orchestrator-agent
|
||||||
|
|
||||||
|
# Remove an agent (direct reports reassigned to parent)
|
||||||
|
./org-manager.sh remove writer-agent
|
||||||
|
|
||||||
|
# List all agents
|
||||||
|
./org-manager.sh list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation checks
|
||||||
|
|
||||||
|
- All agent names must have a corresponding `.claude/agents/<name>.md` file
|
||||||
|
- All `Reports To` values must be valid agent names or `(board)`
|
||||||
|
- No circular reporting chains
|
||||||
|
|
||||||
|
## Paperclip comparison
|
||||||
|
|
||||||
|
Paperclip stores the org chart in a `agents` database table with a
|
||||||
|
`reportsTo` foreign key. This implementation uses a markdown table —
|
||||||
|
equivalent structure, no database required, suitable for file-based
|
||||||
|
agent systems running on a single machine.
|
||||||
279
scripts/templates/org-chart/org-manager.sh
Normal file
279
scripts/templates/org-chart/org-manager.sh
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Org chart manager: parse, validate, and visualize ORG-CHART.md.
|
||||||
|
# Bash 3.2 compatible. Uses python3 for table parsing.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./org-manager.sh # Show org tree
|
||||||
|
# ./org-manager.sh validate # Validate chart integrity
|
||||||
|
# ./org-manager.sh add <agent> "<role>" <reports-to> # Add agent
|
||||||
|
# ./org-manager.sh remove <agent> # Remove agent (reassigns reports)
|
||||||
|
# ./org-manager.sh list # List all agents
|
||||||
|
#
|
||||||
|
# Placeholders:
|
||||||
|
# {{WORKING_DIR}} - absolute path to project directory
|
||||||
|
|
||||||
|
WORKING_DIR="{{WORKING_DIR}}"
|
||||||
|
ORG_FILE="$WORKING_DIR/ORG-CHART.md"
|
||||||
|
AGENTS_DIR="$WORKING_DIR/.claude/agents"
|
||||||
|
ACTION="${1:-tree}"
|
||||||
|
ARG1="${2:-}"
|
||||||
|
ARG2="${3:-}"
|
||||||
|
ARG3="${4:-}"
|
||||||
|
|
||||||
|
if [ ! -f "$ORG_FILE" ]; then
|
||||||
|
echo "Error: $ORG_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
parse_org_table() {
|
||||||
|
python3 -c "
|
||||||
|
import re, sys, os
|
||||||
|
|
||||||
|
org_file = '$ORG_FILE'
|
||||||
|
rows = []
|
||||||
|
in_table = False
|
||||||
|
with open(org_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.rstrip()
|
||||||
|
if re.match(r'\|\s*Agent\s*\|', line):
|
||||||
|
in_table = True
|
||||||
|
continue
|
||||||
|
if in_table and re.match(r'\|[-| ]+\|', line):
|
||||||
|
continue
|
||||||
|
if in_table and line.startswith('|'):
|
||||||
|
parts = [p.strip() for p in line.strip('|').split('|')]
|
||||||
|
if len(parts) >= 4:
|
||||||
|
rows.append({
|
||||||
|
'agent': parts[0],
|
||||||
|
'role': parts[1],
|
||||||
|
'reports_to': parts[2],
|
||||||
|
'status': parts[3],
|
||||||
|
'budget': parts[4] if len(parts) > 4 else ''
|
||||||
|
})
|
||||||
|
elif in_table and not line.startswith('|'):
|
||||||
|
in_table = False
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
print(r['agent'] + '|' + r['role'] + '|' + r['reports_to'] + '|' + r['status'] + '|' + r['budget'])
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
tree)
|
||||||
|
python3 -c "
|
||||||
|
import re, sys, os
|
||||||
|
|
||||||
|
org_file = '$ORG_FILE'
|
||||||
|
rows = []
|
||||||
|
in_table = False
|
||||||
|
with open(org_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.rstrip()
|
||||||
|
if re.match(r'\|\s*Agent\s*\|', line):
|
||||||
|
in_table = True
|
||||||
|
continue
|
||||||
|
if in_table and re.match(r'\|[-| ]+\|', line):
|
||||||
|
continue
|
||||||
|
if in_table and line.startswith('|'):
|
||||||
|
parts = [p.strip() for p in line.strip('|').split('|')]
|
||||||
|
if len(parts) >= 4:
|
||||||
|
rows.append({
|
||||||
|
'agent': parts[0],
|
||||||
|
'role': parts[1],
|
||||||
|
'reports_to': parts[2],
|
||||||
|
'status': parts[3]
|
||||||
|
})
|
||||||
|
elif in_table and not line.startswith('|'):
|
||||||
|
in_table = False
|
||||||
|
|
||||||
|
# Build parent → children map
|
||||||
|
children = {}
|
||||||
|
for r in rows:
|
||||||
|
parent = r['reports_to']
|
||||||
|
if parent not in children:
|
||||||
|
children[parent] = []
|
||||||
|
children[parent].append(r)
|
||||||
|
|
||||||
|
def print_tree(parent, indent):
|
||||||
|
kids = children.get(parent, [])
|
||||||
|
for k in kids:
|
||||||
|
status_mark = '' if k['status'] == 'active' else ' [' + k['status'] + ']'
|
||||||
|
print(indent + '- ' + k['agent'] + ' (' + k['role'] + ')' + status_mark)
|
||||||
|
print_tree(k['agent'], indent + ' ')
|
||||||
|
|
||||||
|
print('Organization Tree')
|
||||||
|
print('=' * 40)
|
||||||
|
print('(board) [human operator]')
|
||||||
|
print_tree('(board)', '')
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
|
||||||
|
validate)
|
||||||
|
python3 -c "
|
||||||
|
import re, sys, os
|
||||||
|
|
||||||
|
org_file = '$ORG_FILE'
|
||||||
|
agents_dir = '$AGENTS_DIR'
|
||||||
|
rows = []
|
||||||
|
in_table = False
|
||||||
|
with open(org_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.rstrip()
|
||||||
|
if re.match(r'\|\s*Agent\s*\|', line):
|
||||||
|
in_table = True
|
||||||
|
continue
|
||||||
|
if in_table and re.match(r'\|[-| ]+\|', line):
|
||||||
|
continue
|
||||||
|
if in_table and line.startswith('|'):
|
||||||
|
parts = [p.strip() for p in line.strip('|').split('|')]
|
||||||
|
if len(parts) >= 4:
|
||||||
|
rows.append({
|
||||||
|
'agent': parts[0],
|
||||||
|
'role': parts[1],
|
||||||
|
'reports_to': parts[2],
|
||||||
|
'status': parts[3]
|
||||||
|
})
|
||||||
|
elif in_table and not line.startswith('|'):
|
||||||
|
in_table = False
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
all_agents = set(r['agent'] for r in rows)
|
||||||
|
|
||||||
|
# Check agent files exist
|
||||||
|
for r in rows:
|
||||||
|
agent_file = os.path.join(agents_dir, r['agent'] + '.md')
|
||||||
|
if not os.path.exists(agent_file):
|
||||||
|
errors.append('Missing agent file: .claude/agents/' + r['agent'] + '.md')
|
||||||
|
|
||||||
|
# Check reports_to references are valid
|
||||||
|
for r in rows:
|
||||||
|
if r['reports_to'] not in all_agents and r['reports_to'] != '(board)':
|
||||||
|
errors.append('Unknown reports_to: ' + r['agent'] + ' → ' + r['reports_to'])
|
||||||
|
|
||||||
|
# Check for circular chains (simple: follow chain, max 50 steps)
|
||||||
|
for r in rows:
|
||||||
|
visited = set()
|
||||||
|
current = r['reports_to']
|
||||||
|
while current and current != '(board)':
|
||||||
|
if current in visited:
|
||||||
|
errors.append('Circular chain detected involving: ' + r['agent'])
|
||||||
|
break
|
||||||
|
visited.add(current)
|
||||||
|
parent_rows = [x for x in rows if x['agent'] == current]
|
||||||
|
if not parent_rows:
|
||||||
|
break
|
||||||
|
current = parent_rows[0]['reports_to']
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print('VALIDATION ERRORS:')
|
||||||
|
for e in errors:
|
||||||
|
print(' ' + e)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print('Validation OK: ' + str(len(rows)) + ' agents, no issues found.')
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
|
||||||
|
add)
|
||||||
|
if [ -z "$ARG1" ] || [ -z "$ARG2" ] || [ -z "$ARG3" ]; then
|
||||||
|
echo "Usage: $0 add <agent-name> \"<role>\" <reports-to>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
AGENT_NAME="$ARG1"
|
||||||
|
ROLE="$ARG2"
|
||||||
|
REPORTS_TO="$ARG3"
|
||||||
|
python3 -c "
|
||||||
|
import re
|
||||||
|
|
||||||
|
org_file = '$ORG_FILE'
|
||||||
|
agent = '$AGENT_NAME'
|
||||||
|
role = '$ROLE'
|
||||||
|
reports_to = '$REPORTS_TO'
|
||||||
|
|
||||||
|
with open(org_file) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
new_row = '| ' + agent + ' | ' + role + ' | ' + reports_to + ' | active | |'
|
||||||
|
|
||||||
|
# Insert before the first blank line after the table
|
||||||
|
lines = content.split('\n')
|
||||||
|
in_table = False
|
||||||
|
insert_at = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.match(r'\|\s*Agent\s*\|', line):
|
||||||
|
in_table = True
|
||||||
|
elif in_table and line.startswith('|'):
|
||||||
|
insert_at = i + 1
|
||||||
|
elif in_table and not line.startswith('|'):
|
||||||
|
break
|
||||||
|
|
||||||
|
if insert_at is not None:
|
||||||
|
lines.insert(insert_at, new_row)
|
||||||
|
with open(org_file, 'w') as f:
|
||||||
|
f.write('\n'.join(lines))
|
||||||
|
print('Added: ' + agent + ' (' + role + ') → ' + reports_to)
|
||||||
|
else:
|
||||||
|
print('Error: Could not find table in ' + org_file)
|
||||||
|
exit(1)
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove)
|
||||||
|
if [ -z "$ARG1" ]; then
|
||||||
|
echo "Usage: $0 remove <agent-name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
AGENT_NAME="$ARG1"
|
||||||
|
python3 -c "
|
||||||
|
import re
|
||||||
|
|
||||||
|
org_file = '$ORG_FILE'
|
||||||
|
agent = '$AGENT_NAME'
|
||||||
|
|
||||||
|
with open(org_file) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Find the agent row and its parent
|
||||||
|
agent_parent = None
|
||||||
|
new_lines = []
|
||||||
|
removed = False
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith('|'):
|
||||||
|
parts = [p.strip() for p in line.strip('|').split('|')]
|
||||||
|
if len(parts) >= 3 and parts[0] == agent:
|
||||||
|
agent_parent = parts[2]
|
||||||
|
removed = True
|
||||||
|
continue # skip this row
|
||||||
|
# Reassign direct reports to agent's parent
|
||||||
|
if len(parts) >= 3 and parts[2] == agent and agent_parent:
|
||||||
|
parts[2] = agent_parent
|
||||||
|
line = '| ' + ' | '.join(parts) + ' |\n'
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
with open(org_file, 'w') as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
print('Removed: ' + agent + ' (reports reassigned to ' + str(agent_parent) + ')')
|
||||||
|
else:
|
||||||
|
print('Agent not found: ' + agent)
|
||||||
|
exit(1)
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
|
||||||
|
list)
|
||||||
|
parse_org_table | python3 -c "
|
||||||
|
import sys
|
||||||
|
print('Agent | Role | Reports To | Status')
|
||||||
|
print('-' * 60)
|
||||||
|
for line in sys.stdin:
|
||||||
|
parts = line.strip().split('|')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
print(parts[0] + ' | ' + parts[1] + ' | ' + parts[2] + ' | ' + parts[3])
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [tree|validate|add <agent> <role> <reports-to>|remove <agent>|list]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Loading…
Add table
Add a link
Reference in a new issue