
Control Metalayer
- 5 installs
- 1 repo stars
- Updated June 28, 2026
- broomva/control-metalayer
Control-metalayer is a Claude skill that initializes a repository into a control-loop driven agentic development system with setpoints, sensors, a controller policy, actuators, and a feedback loop.
About
Control-metalayer initializes or upgrades a repository into a control-loop driven agentic development system. Through a Python Typer wizard it installs control primitives such as setpoints, sensors, a controller policy, actuators, and a feedback loop, plus command and rule governance and a scalable folder topology. Developers use it to let coding agents operate safely and keep improving over time across baseline, governed, and autonomous profiles. It also provides an audit command to detect and close gaps. (Note: this listing's SKILL.md is the repo root, identical to control-metalayer-loop.)
- Initializes a repo into a control-loop driven agentic development system
- Ships baseline, governed, and autonomous profiles via a Typer CLI wizard
- Adds policy, commands, topology, git hooks, and control metrics
Control Metalayer by the numbers
- 5 all-time installs (skills.sh)
- Ranked #13,046 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
control-metalayer capabilities & compatibility
- Capabilities
- control loop init · policy governance · command governance · control audit
- Works with
- github
- Use cases
- orchestration
- Pricing
- Free
What control-metalayer says it does
Create and maintain a control-system metalayer for autonomous code-agent development in any repository.
Use this skill to initialize or upgrade a repository into a control-loop driven agentic development system.
python3 scripts/control_wizard.py init <repo-path> --profile governed
npx skills add https://github.com/broomva/control-metalayer --skill control-metalayerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 1 |
| Last updated | June 28, 2026 |
| Repository | broomva/control-metalayer ↗ |
What it does
Initialize a repo into a control-loop agentic system with setpoints, sensors, policy, actuators, and command governance.
Who is it for?
Developers who want coding agents to operate safely under explicit control primitives and governance.
Skip if: Small repos that do not need a formal control loop or policy gates.
When should I use this skill?
You need explicit control primitives, repo command and rule governance, and a scalable folder topology for safe agent operation.
What you get
A repository governed by a control loop with policy gates, stable command names, and auditable behavior.
- .control/policy.yaml
- commands.yaml and topology.yaml
- git hooks and CI control workflows
By the numbers
- 3 profiles (baseline, governed, autonomous)
- 5-step workflow (baseline to operate-and-grow)
- control law: Setpoints, Sensors, Controller, Actuators, Verify
Files
Agent Consciousness Architecture
Broomva Stack Layer 2 (Memory & Consciousness) — part of the 24-skill Broomva Stack.
Implement a persistent consciousness layer for AI coding agents that gives every new stateless session the accumulated understanding of all prior sessions.
Core Concept
Each agent session is ephemeral — it starts blank. The consciousness architecture weaves three systems into a single persistent substrate:
1. Control Metalayer — How to behave (gates, policies, setpoints, feedback loops) 2. Knowledge Graph — What is known (Obsidian vault, wikilinks, MOC navigation, tag taxonomy) 3. Conversation Logs — What was done (session records, tool traces, decision chains)
See references/architecture.md for the complete system design and data flow. See references/philosophy.md for design principles and the self-evolution model.
Quick Start
New repo (from scratch)
1. Initialize control metalayer with control-metalayer-loop skill 2. Create docs/ with Obsidian vault structure (MOC pattern per section) 3. Install conversation history bridge with knowledge-graph-memory skill 4. Wire hooks: pre-push regenerates conversation docs, smoke validates MOC
Existing repo with control metalayer
1. Add docs/conversations/ directory 2. Install scripts/conversation-history.py from knowledge-graph-memory skill 3. Update CLAUDE.md context acquisition to reference conversation history 4. Update AGENTS.md working rules to check prior sessions 5. Add pre-push hook entry for incremental conversation doc generation
The Three Substrates
Control Metalayer (How to Behave)
Closed-loop feedback: Setpoints → Sensors → Controller → Actuators → Verify → loop
- Setpoints: Quality targets (pass_at_1 ≥ 0.70, gate_pass_rate ≥ 0.85)
- Sensors: CI, tests, linters, PR review agents, harness validation
- Controller:
.control/policy.yaml— hard gates (block) + soft gates (warn) - Actuators: Code edits, doc updates, policy changes
- Gate sequence:
smoke → check → test → push → review → resolve
Knowledge Graph (What Is Known)
An Obsidian vault with wikilinks, tag taxonomy, and MOC navigation:
docs/
├── Documentation Hub.md ← MOC of MOCs (start here)
├── architecture/ ← System design
├── conversations/ ← Session history (auto-generated)
├── agentic-harness/ ← Execution framework
├── control/ ← Metalayer docs
└── {section}/ ← Features, operations, security, etc.Every doc has YAML frontmatter with tags:, related:, type: for machine navigation.
Conversation Logs (What Was Done)
Raw session data bridged to Obsidian:
.entire/logs/entire.log ──┐
├──▶ conversation-history.py ──▶ docs/conversations/*.md
~/.claude/projects/*.jsonl ─┘Each session doc: full conversation thread, tool call details (expandable callouts), files touched, commits, branch metadata, wikilinks to knowledge graph.
The Consciousness Stack
From most ephemeral to most permanent:
| Layer | Lifetime | Location | Update Frequency |
|---|---|---|---|
| Working memory | Single session | Context window | Every message |
| Auto-memory | Cross-session | ~/.claude/.../memory/ | On learning events |
| User vault | Cross-session | Lago /v1/memory/* | On store/ingest |
| Conversation logs | Permanent | docs/conversations/ | Pre-push hook |
| Knowledge graph | Permanent | docs/ | On architectural changes |
| Policy rules | Permanent | .control/policy.yaml | On new failure modes |
| Invariants | Permanent | CLAUDE.md | Rarely (foundational) |
Information flows upward: working observations → memory notes → session records → architecture docs → enforced rules → core invariants. Only recurring patterns crystallize into permanent rules.
Self-Evolution Cycle
Agent Session → Conversation Log → Knowledge Graph → Control Metalayer → Governs Next Session1. Agent encounters failure mode not covered by existing policy 2. Agent fixes immediate issue 3. Pattern captured in conversation log 4. If recurring, crystallizes into architecture doc 5. If enforceable, becomes a gate in .control/policy.yaml 6. Future agents governed by this rule automatically
Agent Session Protocol
On Session Start
1. Read CLAUDE.md (invariants), AGENTS.md (tools), METALAYER.md (control loop) 2. Check PLANS.md (active plan to continue?) 3. Check .control/state.json (current metrics) 4. Check git status + git log (recent changes) 5. Scan docs/conversations/Conversations.md for prior sessions on current branch
Before Making Changes
Search conversation history: grep -rl "keyword" docs/conversations/ Traverse knowledge graph via MOC files and wikilinks. Check if prior sessions already solved this problem.
On Task Completion
1. Run make smoke (validate gates) 2. Update docs per Doc-Update-on-Push policy 3. Pre-push hook auto-regenerates conversation history
Lago Context Engine
The consciousness architecture now has a server-side persistence backend via Lago:
- Dual-vault search: broomva.tech chat agent searches both server vault (
VAULT_PATH) and user vault (LAGO_URL) with merged, ranked results - Per-user memory: Each authenticated user gets a Lago session for persistent
.mdstorage with server-side knowledge indexing - lago-knowledge: Frontmatter parsing, wikilink extraction, scored search, BFS graph traversal — the same operations the local vault reader does, but server-side
- JWT auth: Shared-secret validation with broomva.tech
AUTH_SECRET— one OAuth login, both CLIs work
This aligns with the planned Mnemo AOS primitive (knowledge store) and provides the foundation for persistent agent memory.
Stack Integration
This skill is consumed by higher layers:
- Strategy (L7):
decision-logandweekly-reviewpersist outputs through the consciousness substrate - Strategy (L7):
drift-checkreads control-metalayer setpoints to detect misalignment - Strategy (L7):
braindumpandmorning-briefingread/write vault via knowledge-graph-memory - Orchestration (L3):
symphonyandautoanyinherit session context through the consciousness stack - Persistence (L0): Lago context engine provides the durable substrate for user vaults and knowledge graph operations
Architecture: Agent Consciousness System
Data Flow
┌─────────────────────────────────────────────────────────────┐
│ Claude Code Session (real-time agent execution) │
└──────────┬──────────────────────────────────────────────────┘
│ events (JSON lines)
┌──────────▼──────────────────────┐
│ .entire/logs/entire.log │ Session lifecycle, checkpoints,
│ │ attribution, phase transitions
└──────────┬──────────────────────┘
│ transcripts (JSONL per session)
┌──────────▼──────────────────────┐
│ ~/.claude/projects/{KEY}/ │ Every message, tool call,
│ {session-uuid}.jsonl │ tool result, git branch, version
└──────────┬──────────────────────┘
│ bridge script
┌──────────▼──────────────────────┐
│ scripts/conversation-history.py │ Parses both sources, merges,
│ │ filters noise, builds timeline,
│ │ generates Obsidian markdown
└──────────┬──────────────────────┘
│ session docs
┌──────────▼──────────────────────┐
│ docs/conversations/ │ Per-session records with:
│ ├── Conversations.md (MOC) │ conversation thread, tool calls,
│ └── session-{date}-{id}.md │ files touched, commits, wikilinks
└──────────┬──────────────────────┘
│ wikilinks + tags
┌──────────▼──────────────────────┐
│ Knowledge Graph (docs/) │ Architecture, features, operations,
│ │ security, workflows, control docs
└──────────┬──────────────────────┘
│ patterns crystallize
┌──────────▼──────────────────────┐
│ Control Metalayer (.control/) │ policy.yaml, state.json,
│ │ topology.yaml, commands.yaml
└──────────────────────────────────┘Control Metalayer Components
| Component | File | Purpose |
|---|---|---|
| Setpoints | METALAYER.md, evals/control-metrics.yaml | 15 target metrics |
| Sensors | CI, test suites, linters, PR reviews | Measure current state |
| Controller | .control/policy.yaml | Hard gates (block) + soft gates (warn) |
| Actuators | Code edits, doc updates, policy changes | Close the gap |
| State | .control/state.json | Current metrics, gate results |
| Topology | .control/topology.yaml | Code ownership zones |
| Commands | .control/commands.yaml | Available operations |
Gate Sequence
smoke (1-2 min) → check (2-3 min) → test (5-10 min) → push → review → resolve
│ │ │ │ │ │
env sanity lint unit tests CI agents reply
typecheck format integration E2E triage resolve
ruff prettier audit fix P1
harness validHard vs Soft Gates
Hard gates block merge:
no-direct-fastapi-from-browser— BFF proxy patterntenant-isolation-required— schema-based multi-tenancyno-m2m-tokens-in-browser— session tokens onlypr-review-comments-resolved— all P1 comments fixed
Soft gates warn:
unified-state-only— prefer single state slicereusable-components-first— check shared componentsdoc-update-on-tool-change— keep docs current
Knowledge Graph Structure
/ ← Obsidian vault root
├── CLAUDE.md ← Invariants + agent protocol
├── AGENTS.md ← Tool catalog + working rules
├── METALAYER.md ← Control loop definition
├── PLANS.md ← Active execution plans
├── docs/
│ ├── Documentation Hub.md ← MOC of MOCs
│ ├── architecture/ ← System design
│ ├── conversations/ ← Session history (auto-generated)
│ ├── agentic-harness/ ← Execution framework
│ ├── control/ ← Metalayer documentation
│ ├── workflows/ ← Procurement/domain workflows
│ └── {section}/ ← Features, ops, security, dev
├── .control/ ← Machine-readable control state
│ ├── policy.yaml
│ ├── state.json
│ ├── topology.yaml
│ └── commands.yaml
└── .entire/ ← Session event logs
└── logs/entire.logFrontmatter Schema
Every doc carries structured YAML for machine navigation:
---
title: Document Title
description: One-line description
tags:
- stimulus/{section}
- {topic-tag}
type: architecture | guide | specification | moc | conversation
status: active | deprecated | draft
created: YYYY-MM-DD
updated: YYYY-MM-DD
related:
- "[[Connected Doc]]"
---Session Doc Structure
Each conversation session generates a doc with:
---
title: "First user message (truncated)..."
type: conversation
session_id: uuid
branch: feature/branch-name
tags:
- stimulus/conversations
- branch/{branch-name}
related:
- "[[Conversations]]"
- "[[CLAUDE]]"
---Content sections: 1. Metadata table (session ID, date, duration, turns, tools, branch) 2. Conversation thread (chronological user → assistant → tools timeline) 3. Tool call details (nested expandable callouts per tool) 4. Files touched 5. Commits
Hooks Chain
| Hook | Trigger | Actions |
|---|---|---|
| pre-commit | Before staging | make smoke (fast static checks) |
| pre-push | Before push | make check, conversation history update, stage docs |
| CI | Push/PR | make ci (smoke + check + test) |
| Nightly | Cron | make control-audit-strict |
Retry Budget and Escalation
Agents get 2 attempts to fix a failing gate. On third failure, escalate to human. This prevents infinite correction loops while allowing autonomous recovery from transient issues.
Philosophy: Agent Consciousness Design Principles
The Problem: Ephemeral Agents, Persistent Codebase
An AI coding agent starts each session blank. It reads instructions, infers context from code, and acts. But the codebase carries the sedimented decisions of hundreds of prior agent sessions — architectural choices, abandoned approaches, hard-won patterns, implicit constraints. Without access to this history, each new session risks:
- Repeating solved problems — re-discovering what a prior session already figured out
- Contradicting prior decisions — changing patterns chosen for reasons not visible in code alone
- Losing momentum — starting from scratch on multi-session work
- Violating invisible constraints — breaking rules that exist in policy, not code
The Solution: Structured Forgetting with Selective Recall
Rather than giving agents "total recall" (which overwhelms context windows), implement structured forgetting with selective recall:
1. Everything is captured — every tool call, every reasoning step, every decision 2. Everything is indexed — session metadata, dates, branches, files touched, tools used 3. Recall is on-demand — agents search the index when they need context, not by default 4. Knowledge decays naturally — older sessions stay available but newer ones surface first 5. Patterns crystallize into rules — recurring lessons harden into invariants, gates, and rules
This mirrors how human institutional memory works: raw experience → indexed records → searchable archives → crystallized policy.
Six Design Principles
1. Capture Everything, Surface Selectively
Raw data is cheap to store and expensive to reconstruct. Capture every tool call, every reasoning step, every file modification. But agents don't read 900KB of raw logs — they read the indexed MOC and search by keyword/branch when they need context.
2. Code Over Documentation, Documentation Over Convention
If a rule can be enforced by code (a gate script), it should be. If it can't be coded, document it (CLAUDE.md). If it can't be documented, capture it in conversation history as a convention. The farther up this hierarchy, the more reliable the enforcement.
3. Graceful Degradation
Each substrate works independently. If .entire/ isn't installed, conversation history generation skips cleanly. If an agent doesn't read conversation history, the knowledge graph still provides context. If the knowledge graph is stale, CLAUDE.md invariants still govern behavior. No single point of failure.
4. Progressive Crystallization
Knowledge flows from volatile to permanent through natural selection pressure. Only patterns that recur across multiple sessions graduate from conversation logs to architecture docs to policy rules. This prevents premature abstraction while ensuring important patterns are captured.
5. Machine-Navigable, Human-Readable
Every document serves two audiences: AI agents (who search by grep, parse frontmatter, follow wikilinks) and human developers (who use Obsidian's graph view, tag filtering, and callout formatting). The same markdown works for both.
6. The Agent IS the App
The consciousness architecture isn't separate from the product — it's the same system. The agent that uses conversation history to recall prior work is the same agent that serves users. The knowledge graph that documents architecture decisions also teaches the agent how to make new ones.
The Self-Evolution Model
Progressive Crystallization Path
Ephemeral Permanent
─────────────────────────────────────────────────────────────────
Working → Auto- → Conversation → Knowledge → Policy → Invariants
memory memory logs graph rules (CLAUDE.md)
(context (cross- (docs/conv/) (docs/) (.control/
window) session) policy.yaml)Each layer filters signal from noise:
- Working memory: Everything the agent considers
- Auto-memory: Only things worth remembering across sessions
- Conversation logs: Only real user prompts and agent reasoning (noise filtered)
- Knowledge graph: Only patterns worth documenting
- Policy rules: Only patterns worth enforcing
- Invariants: Only patterns that are foundational
How Lessons Graduate
1. Agent encounters new failure mode during a session 2. Fix is applied immediately (working memory) 3. If user corrects agent behavior, saved as feedback memory (auto-memory) 4. Session is captured with full reasoning chain (conversation log) 5. If pattern recurs in multiple sessions, documented in architecture (knowledge graph) 6. If pattern is enforceable, added as gate to policy.yaml (policy rule) 7. If pattern is foundational, added to CLAUDE.md (invariant)
Concrete Examples
BFF-First Rule Evolution: Session → CORS errors from direct FastAPI calls → documented in architecture → became hard gate no-direct-fastapi-from-browser → now enforced on every session
Conversation History System Evolution: Session → recognized need for cross-session context → built bridge script → wired into hooks → documented in CLAUDE.md/AGENTS.md → the system that captures this improvement is the system it improves (self-referential evolution)
Future Directions
- Semantic search over conversation history (vector embeddings instead of grep)
- Automatic pattern detection across sessions (propose new policy rules)
- Cross-session dependency graphs (which sessions depend on which)
- Conversation-informed code review (surface reasoning behind changes in PR context)
interface:
display_name: "Control Metalayer Loop"
short_description: "Control-loop harness for autonomous code agents"
default_prompt: "Set up this repository with a control-system metalayer, policy rules, commands, and folder topology for stable autonomous agent development."
version: 1
commands:
- id: smoke
command: make smoke
preconditions:
- dependencies_installed
on_failure: fix_environment
- id: check
command: make check
preconditions:
- smoke_passed
on_failure: fix_static_issues
- id: test
command: make test
preconditions:
- check_passed
on_failure: diagnose_and_retry
- id: recover
command: make recover
preconditions:
- failure_detected
on_failure: escalate_human
version: 1
controller:
gate_sequence:
- smoke
- check
- test
retry_budget: 2
escalation:
trigger: retry_budget_exhausted
owner: human_oncall
rules:
- id: no-merge-with-failing-checks
type: hard
condition: "check == fail or test == fail"
action: block_merge
- id: require-plan-for-long-task
type: hard
condition: "task_duration_minutes > 30 and plan_missing"
action: block_execution
{
"version": 1,
"last_audit_at": null,
"last_entropy_review_at": null,
"controller_mode": "governed",
"notes": []
}
version: 1
zones:
product_code:
paths:
- src/
- app/
- packages/
control_plane:
paths:
- .control/
- docs/control/
- scripts/control/
- evals/
ownership:
policy_owner: engineering
command_owner: platform
observability_owner: sre
#!/usr/bin/env bash
set -euo pipefail
if [ -x ./scripts/control/check.sh ]; then
./scripts/control/check.sh
exit 0
fi
if command -v make >/dev/null 2>&1; then
make check
exit 0
fi
echo "No check command available for pre-commit." >&2
exit 1
#!/usr/bin/env bash
set -euo pipefail
if [ -x ./scripts/control/test.sh ]; then
./scripts/control/test.sh
exit 0
fi
if command -v make >/dev/null 2>&1; then
make test
exit 0
fi
echo "No test command available for pre-push." >&2
exit 1
name: CLI E2E
on:
pull_request:
workflow_dispatch:
jobs:
cli-e2e:
runs-on: ubuntu-latest
env:
APP_CLI_BIN: ${{ vars.APP_CLI_BIN }}
APP_CLI_VERSION_ARG: ${{ vars.APP_CLI_VERSION_ARG }}
steps:
- uses: actions/checkout@v4
- name: Run CLI e2e wrapper
run: ./scripts/control/cli_e2e.sh
name: Control Harness CI
on:
push:
branches: [main]
pull_request:
jobs:
control:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run control CI
run: make ci
name: Control Nightly Audit
on:
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Baseline audit
run: scripts/audit_control.sh .
- name: Strict audit
run: scripts/audit_control.sh . --strict
name: Web E2E
on:
pull_request:
workflow_dispatch:
jobs:
web-e2e:
runs-on: ubuntu-latest
env:
APP_BASE_URL: ${{ vars.APP_BASE_URL }}
PLAYWRIGHT_BASE_URL: ${{ vars.PLAYWRIGHT_BASE_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Node dependencies (if present)
run: |
if [ -f package-lock.json ]; then
npm ci
elif [ -f package.json ]; then
npm install
fi
- name: Install Playwright browser deps (if configured)
run: |
if [ -f playwright.config.ts ] || [ -f playwright.config.js ]; then
npx playwright install --with-deps
fi
- name: Run web e2e wrapper
run: ./scripts/control/web_e2e.sh
AGENTS.md
Project Goal
- Product objective:
- Quality objective:
- Reliability objective:
Control Commands
| Intent | Command |
|---|---|
| Quick environment and build sanity | make smoke |
| Static quality gates | make check |
| Full verification | make test |
| Web integration E2E | make web-e2e |
| CLI integration E2E | make cli-e2e |
| Install git hooks | make hooks-install |
| Recovery playbook | make recover |
| Metalayer audit | make control-audit |
Rules
- Never bypass
checkortestwithout explicit escalation. - Do not merge browser or CLI features without corresponding E2E coverage.
- Keep changes scoped to one plan objective at a time.
- Update control docs and policy when behavior changes.
- Escalate to human when retry budget is exhausted.
Execution Plans
- For tasks > 30 minutes, update
PLANS.mdbefore coding. - Record checkpoints and final verification commands.
Observability
- Include
run_id,trace_id, andtask_idin major workflow logs.
Control-Aware Architecture
Boundaries
- Interface boundary: parse/validate external input.
- Domain boundary: operate on internal typed models.
- Persistence boundary: serialize state transitions.
Ownership
- Product modules own product behavior.
- Control modules own governance and reliability behavior.
Control Loop
Loop Definition
1. Measure sensor outputs. 2. Compare against setpoints. 3. Select control action. 4. Execute command/action. 5. Verify and persist results.
Escalation
Escalate when retries exceed budget or when hard policy rules are violated.
Control Observability
Required Fields
- run_id
- trace_id
- task_id
- command_id
- status
- duration_ms
Required Events
- control.step.start
- control.step.success
- control.step.failure
- control.escalation
version: 1
metrics:
pass_at_1:
target: 0.70
alert_below: 0.55
retry_rate:
target: 0.20
alert_above: 0.40
merge_cycle_time_hours:
target: 24
alert_above: 48
revert_rate:
target: 0.03
alert_above: 0.08
human_intervention_rate:
target: 0.20
alert_above: 0.40
.PHONY: smoke check test recover hooks-install web-e2e cli-e2e ci-e2e control-audit ci
smoke:
@./scripts/control/smoke.sh
check:
@./scripts/control/check.sh
test:
@./scripts/control/test.sh
recover:
@if [ -x ./scripts/control/recover.sh ]; then ./scripts/control/recover.sh; else echo "recover primitive not installed"; exit 2; fi
hooks-install:
@if [ -x ./scripts/control/install_hooks.sh ]; then ./scripts/control/install_hooks.sh; else echo "hooks primitive not installed"; exit 2; fi
web-e2e:
@if [ -x ./scripts/control/web_e2e.sh ]; then ./scripts/control/web_e2e.sh; else echo "web primitive not installed"; exit 2; fi
cli-e2e:
@if [ -x ./scripts/control/cli_e2e.sh ]; then ./scripts/control/cli_e2e.sh; else echo "cli primitive not installed"; exit 2; fi
control-audit:
@./scripts/audit_control.sh .
ci: smoke check test
ci-e2e: ci web-e2e cli-e2e
METALAYER
This repository operates as a control loop for autonomous agent development.
Setpoints
- pass_at_1 target:
- merge_cycle_time target:
- revert_rate target:
- human_intervention_rate target:
Sensors
- CI checks
- Test outcomes
- Web E2E outcomes
- CLI E2E outcomes
- Static checks
- Runtime traces/logs
Controller Policy
- Gate sequence: smoke -> check -> test
- Retry budget:
- Escalation conditions:
Actuators
- Code edits
- Script updates
- Policy updates
- Documentation updates
- Hook and workflow updates
Feedback Loop
1. Measure 2. Compare 3. Decide 4. Act 5. Verify
PLANS.md
Objective
- Outcome:
- Scope:
- Non-goals:
Constraints
- Technical:
- Policy:
- Risk:
Steps
1. Step 2. Step 3. Step
Verification
make smokemake checkmake test
Decisions
- Date / decision / reason
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "tests/e2e/web",
timeout: 45_000,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || process.env.APP_BASE_URL || "http://127.0.0.1:3000",
trace: "retain-on-failure",
},
reporter: [["line"]],
});
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/audit_control.sh [repo_path] [--strict]
Audit repository for control metalayer artifacts.
USAGE
}
repo_path="."
strict=0
while [ $# -gt 0 ]; do
case "$1" in
--strict)
strict=1
;;
-h|--help)
usage
exit 0
;;
*)
if [ "$repo_path" != "." ]; then
echo "error: multiple repo paths provided" >&2
exit 1
fi
repo_path="$1"
;;
esac
shift
done
if [ ! -d "$repo_path" ]; then
echo "error: repo path not found: $repo_path" >&2
exit 1
fi
repo_path=$(cd "$repo_path" && pwd)
failures=0
ok() { echo "[ok] $1"; }
fail() {
echo "[missing] $1"
failures=$((failures + 1))
}
check_file() {
local rel="$1"
if [ -f "$repo_path/$rel" ]; then
ok "$rel"
else
fail "$rel"
fi
}
check_contains() {
local rel="$1"
local pattern="$2"
local label="$3"
local f="$repo_path/$rel"
if [ ! -f "$f" ]; then
fail "$label (file missing: $rel)"
return
fi
if grep -Eq "$pattern" "$f"; then
ok "$label"
else
fail "$label"
fi
}
check_hooks_path() {
if [ ! -d "$repo_path/.git" ]; then
ok "git hooks path check skipped (not a git repo)"
return
fi
local hooks_path
hooks_path=$(git -C "$repo_path" config --get core.hooksPath || true)
if [ "$hooks_path" = ".githooks" ]; then
ok "git core.hooksPath configured"
else
fail "git core.hooksPath configured (.githooks expected)"
fi
}
echo "Auditing control metalayer: $repo_path"
echo
baseline=(
"AGENTS.md"
"PLANS.md"
"METALAYER.md"
"Makefile.control"
"scripts/audit_control.sh"
"scripts/control/smoke.sh"
"scripts/control/check.sh"
"scripts/control/test.sh"
"docs/control/ARCHITECTURE.md"
"docs/control/OBSERVABILITY.md"
".github/workflows/control-harness.yml"
)
for rel in "${baseline[@]}"; do
check_file "$rel"
done
echo
check_contains "AGENTS.md" "Harness Commands|Control Commands" "AGENTS.md command section"
check_contains "METALAYER.md" "Setpoints" "METALAYER setpoint section"
check_contains "Makefile.control" "^control-audit:" "Makefile.control control-audit target"
check_contains ".github/workflows/control-harness.yml" "make ci" "control harness workflow invokes make ci"
if [ "$strict" -eq 1 ]; then
echo
strict_files=(
".control/policy.yaml"
".control/commands.yaml"
".control/topology.yaml"
".control/state.json"
"docs/control/CONTROL_LOOP.md"
"evals/control-metrics.yaml"
"scripts/control/install_hooks.sh"
".githooks/pre-commit"
".githooks/pre-push"
"scripts/control/recover.sh"
"scripts/control/web_e2e.sh"
"scripts/control/cli_e2e.sh"
"tests/e2e/web/smoke.spec.ts"
"tests/e2e/cli/smoke.sh"
"playwright.config.ts"
".github/workflows/web-e2e.yml"
".github/workflows/cli-e2e.yml"
".github/workflows/control-nightly.yml"
)
for rel in "${strict_files[@]}"; do
check_file "$rel"
done
check_hooks_path
fi
echo
if [ "$failures" -gt 0 ]; then
echo "Control audit failed: $failures issue(s)."
exit 1
fi
echo "Control audit passed."
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
if [ -n "${CONTROL_CHECK_CMD:-}" ]; then
eval "$CONTROL_CHECK_CMD"
exit 0
fi
if [ -f Cargo.toml ] && command -v cargo >/dev/null 2>&1; then
cargo clippy --all-targets --all-features -- -D warnings
exit 0
fi
if [ -f package.json ] && command -v npm >/dev/null 2>&1; then
npm run -s lint
npm run -s typecheck || true
exit 0
fi
if [ -f pyproject.toml ]; then
if command -v ruff >/dev/null 2>&1; then
ruff check .
fi
if command -v mypy >/dev/null 2>&1; then
mypy .
fi
exit 0
fi
echo "No check command detected. Set CONTROL_CHECK_CMD."
exit 1
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
if [ -n "${CONTROL_CLI_E2E_CMD:-}" ]; then
eval "$CONTROL_CLI_E2E_CMD"
exit 0
fi
if [ -x ./tests/e2e/cli/smoke.sh ]; then
./tests/e2e/cli/smoke.sh
exit 0
fi
cli_bin="${APP_CLI_BIN:-}"
if [ -n "$cli_bin" ]; then
"$cli_bin" --help >/dev/null
echo "CLI reachable: $cli_bin"
exit 0
fi
echo "No CLI e2e command configured. Set CONTROL_CLI_E2E_CMD or APP_CLI_BIN." >&2
exit 1
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
if [ ! -d .git ]; then
echo "error: not a git repository: $root" >&2
exit 1
fi
mkdir -p .githooks
chmod +x .githooks/pre-commit .githooks/pre-push
git config core.hooksPath .githooks
echo "Git hooks installed: core.hooksPath=.githooks"
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
echo "Recovery workflow"
echo "1) Re-run smoke"
echo "2) Re-run check"
echo "3) Capture failing tests and open escalation if needed"
./scripts/control/smoke.sh || true
./scripts/control/check.sh || true
./scripts/control/test.sh || true
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
if [ -n "${CONTROL_SMOKE_CMD:-}" ]; then
eval "$CONTROL_SMOKE_CMD"
exit 0
fi
if [ -f Cargo.toml ] && command -v cargo >/dev/null 2>&1; then
cargo check --quiet
exit 0
fi
if [ -f package.json ] && command -v npm >/dev/null 2>&1; then
npm run -s build || npm run -s smoke
exit 0
fi
if [ -f pyproject.toml ] && command -v pytest >/dev/null 2>&1; then
pytest -q -k smoke || pytest -q -k "not integration and not e2e"
exit 0
fi
echo "No smoke command detected. Set CONTROL_SMOKE_CMD."
exit 1
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
if [ -n "${CONTROL_TEST_CMD:-}" ]; then
eval "$CONTROL_TEST_CMD"
exit 0
fi
if [ -f Cargo.toml ] && command -v cargo >/dev/null 2>&1; then
cargo test --quiet
exit 0
fi
if [ -f package.json ] && command -v npm >/dev/null 2>&1; then
npm run -s test
exit 0
fi
if [ -f pyproject.toml ] && command -v pytest >/dev/null 2>&1; then
pytest -q
exit 0
fi
echo "No test command detected. Set CONTROL_TEST_CMD."
exit 1
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
if [ -n "${CONTROL_WEB_E2E_CMD:-}" ]; then
eval "$CONTROL_WEB_E2E_CMD"
exit 0
fi
base_url="${APP_BASE_URL:-${PLAYWRIGHT_BASE_URL:-}}"
if [ -f package.json ] && command -v npm >/dev/null 2>&1; then
if node -e 'const p=require("./package.json"); process.exit(p.scripts&&p.scripts["e2e:web"]?0:1)' >/dev/null 2>&1; then
npm run -s e2e:web
exit 0
fi
fi
if [ -f playwright.config.ts ] && command -v npx >/dev/null 2>&1; then
npx playwright test tests/e2e/web --reporter=line
exit 0
fi
if [ -n "$base_url" ] && command -v curl >/dev/null 2>&1; then
curl -fsS "$base_url" >/dev/null
echo "Web deployment reachable: $base_url"
exit 0
fi
echo "No web e2e command configured. Set CONTROL_WEB_E2E_CMD or APP_BASE_URL, or install Playwright config/tests." >&2
exit 1
#!/usr/bin/env bash
set -euo pipefail
cli_bin="${APP_CLI_BIN:-}"
if [ -z "$cli_bin" ]; then
echo "Set APP_CLI_BIN for CLI E2E smoke." >&2
exit 1
fi
"$cli_bin" --help >/dev/null
if [ -n "${APP_CLI_VERSION_ARG:-}" ]; then
"$cli_bin" "$APP_CLI_VERSION_ARG" >/dev/null
fi
echo "CLI smoke test passed for $cli_bin"
import { expect, test } from "@playwright/test";
test("home page loads", async ({ page }) => {
await page.goto("/");
await expect(page.locator("body")).toBeVisible();
});
Control Primitives
Use this map to reason about autonomous repo development as a dynamic control system.
Mapping
- Plant: repository + CI + runtime behavior.
- Controller: policy rules + decision logic + supervising humans.
- Actuators: agent edits, command execution, PR operations.
- Sensors: tests, static checks, logs, traces, eval outcomes.
- Setpoints: target quality, reliability, speed, autonomy.
- Disturbances: requirement changes, dependency updates, outages, flaky tests.
Minimal Control Law
1. Run smoke. 2. If smoke fails, stop and fix environment/build issues only. 3. Run check (lint + typecheck). 4. If check fails, block merge and repair static issues. 5. Run test. 6. If test fails, allow bounded retries; then escalate. 7. If failures persist across runs, tighten policy or reduce change surface.
Required Metrics
- pass_at_1
- retry_rate
- time_to_actionable_failure
- merge_cycle_time
- revert_rate
- human_intervention_rate
Stability Criteria
- Bounded retries.
- Decreasing regression frequency.
- Consistent audit pass rate.
- Controlled entropy (docs/scripts/rules in sync).
Rules And Commands
Keep command and rule governance explicit and versioned.
Rule Types
- Hard gates: non-negotiable checks.
- Soft policies: preferred behavior with override paths.
- Escalation rules: when autonomy must hand off to human.
- Recovery rules: rollback, retry, or de-scope actions.
Command Governance
Expose a stable command surface through wrappers:
make smokemake checkmake testmake web-e2emake cli-e2emake hooks-installmake recovermake control-audit
Keep direct tooling (cargo, npm, pytest) behind wrapper scripts for portability and deterministic behavior.
Command Contract Pattern
For each command:
- Preconditions
- Expected outputs
- Failure modes
- Recovery action
- Escalation path
Store this in .control/commands.yaml.
End-To-End Validation
- Web changes require browser-level E2E checks against deployed or preview URLs.
- CLI changes require binary-level E2E checks using real command invocations.
- Keep these checks in dedicated workflows so failures are isolated and actionable.
Topology And Growth
This skill uses a split between product code and control-plane artifacts.
Recommended Topology
- Product code: existing repo structure.
- Control plane:
.control/for policy, command catalog, topology, and state.docs/control/for architecture, observability, and loop docs.scripts/control/for deterministic command wrappers..githooks/for local gate enforcement.tests/e2e/web/andtests/e2e/cli/for integration checks.evals/for control metrics and drift tracking.
Growth Path
1. Baseline: command wrappers + AGENTS/PLANS. 2. Governed: explicit policy, commands, topology, and metrics. 3. Autonomous: recovery scripts, nightly audits, entropy controls.
Scaling Pattern
- Keep policy declarations data-driven (
yaml/json) rather than hardcoded in prompts. - Keep orchestration deterministic and inspectable.
- Add specialized primitives per domain, but preserve common command interface.
Wizard CLI
Main tool: scripts/control_wizard.py
Init
python3 scripts/control_wizard.py init <repo-path> --profile baseline
python3 scripts/control_wizard.py init <repo-path> --profile governed
python3 scripts/control_wizard.py init <repo-path> --profile autonomousgovernedinstalls policy/commands/topology + hooks primitives.autonomousinstalls governed + recovery + web/cli E2E primitives.
Audit
python3 scripts/control_wizard.py audit <repo-path>
python3 scripts/control_wizard.py audit <repo-path> --strictStatus
python3 scripts/control_wizard.py status <repo-path>Primitive Operations
python3 scripts/control_wizard.py primitive list
python3 scripts/control_wizard.py primitive add policy loop hooks --repo <repo-path>
python3 scripts/control_wizard.py primitive add web cli --repo <repo-path>#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: audit_control.sh [repo_path] [--strict]
Audit repository for control metalayer artifacts.
USAGE
}
repo_path="."
strict=0
while [ $# -gt 0 ]; do
case "$1" in
--strict)
strict=1
;;
-h|--help)
usage
exit 0
;;
*)
if [ "$repo_path" != "." ]; then
echo "error: multiple repo paths provided" >&2
exit 1
fi
repo_path="$1"
;;
esac
shift
done
if [ ! -d "$repo_path" ]; then
echo "error: repo path not found: $repo_path" >&2
exit 1
fi
repo_path=$(cd "$repo_path" && pwd)
failures=0
ok() { echo "[ok] $1"; }
fail() {
echo "[missing] $1"
failures=$((failures + 1))
}
check_file() {
local rel="$1"
if [ -f "$repo_path/$rel" ]; then
ok "$rel"
else
fail "$rel"
fi
}
check_contains() {
local rel="$1"
local pattern="$2"
local label="$3"
local f="$repo_path/$rel"
if [ ! -f "$f" ]; then
fail "$label (file missing: $rel)"
return
fi
if grep -Eq "$pattern" "$f"; then
ok "$label"
else
fail "$label"
fi
}
check_hooks_path() {
if [ ! -d "$repo_path/.git" ]; then
ok "git hooks path check skipped (not a git repo)"
return
fi
local hooks_path
hooks_path=$(git -C "$repo_path" config --get core.hooksPath || true)
if [ "$hooks_path" = ".githooks" ]; then
ok "git core.hooksPath configured"
else
fail "git core.hooksPath configured (.githooks expected)"
fi
}
echo "Auditing control metalayer: $repo_path"
echo
baseline=(
"AGENTS.md"
"PLANS.md"
"METALAYER.md"
"Makefile.control"
"scripts/audit_control.sh"
"scripts/control/smoke.sh"
"scripts/control/check.sh"
"scripts/control/test.sh"
"docs/control/ARCHITECTURE.md"
"docs/control/OBSERVABILITY.md"
".github/workflows/control-harness.yml"
)
for rel in "${baseline[@]}"; do
check_file "$rel"
done
echo
check_contains "AGENTS.md" "Harness Commands|Control Commands" "AGENTS.md command section"
check_contains "METALAYER.md" "Setpoints" "METALAYER setpoint section"
check_contains "Makefile.control" "^control-audit:" "Makefile.control control-audit target"
check_contains ".github/workflows/control-harness.yml" "make ci" "control harness workflow invokes make ci"
if [ "$strict" -eq 1 ]; then
echo
strict_files=(
".control/policy.yaml"
".control/commands.yaml"
".control/topology.yaml"
".control/state.json"
"docs/control/CONTROL_LOOP.md"
"evals/control-metrics.yaml"
"scripts/control/install_hooks.sh"
".githooks/pre-commit"
".githooks/pre-push"
"scripts/control/recover.sh"
"scripts/control/web_e2e.sh"
"scripts/control/cli_e2e.sh"
"tests/e2e/web/smoke.spec.ts"
"tests/e2e/cli/smoke.sh"
"playwright.config.ts"
".github/workflows/web-e2e.yml"
".github/workflows/cli-e2e.yml"
".github/workflows/control-nightly.yml"
)
for rel in "${strict_files[@]}"; do
check_file "$rel"
done
check_hooks_path
fi
echo
if [ "$failures" -gt 0 ]; then
echo "Control audit failed: $failures issue(s)."
exit 1
fi
echo "Control audit passed."
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: bootstrap_control.sh [repo_path] [--force]
Install baseline control metalayer templates into a target repository.
USAGE
}
repo_path="."
force=0
while [ $# -gt 0 ]; do
case "$1" in
--force)
force=1
;;
-h|--help)
usage
exit 0
;;
*)
if [ "$repo_path" != "." ]; then
echo "error: multiple repo paths provided" >&2
exit 1
fi
repo_path="$1"
;;
esac
shift
done
if [ ! -d "$repo_path" ]; then
echo "error: repo path not found: $repo_path" >&2
exit 1
fi
repo_path=$(cd "$repo_path" && pwd)
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
skill_dir=$(cd "$script_dir/.." && pwd)
template_dir="$skill_dir/assets/templates"
copy_template() {
local rel="$1"
local src="$template_dir/$rel"
local dst="$repo_path/$rel"
if [ ! -f "$src" ]; then
echo "[error] missing template: $rel" >&2
exit 1
fi
mkdir -p "$(dirname "$dst")"
if [ -f "$dst" ] && [ "$force" -ne 1 ]; then
echo "[skip] $rel"
return
fi
cp "$src" "$dst"
echo "[write] $rel"
}
baseline=(
"AGENTS.md"
"PLANS.md"
"METALAYER.md"
"Makefile.control"
"scripts/audit_control.sh"
"scripts/control/smoke.sh"
"scripts/control/check.sh"
"scripts/control/test.sh"
"docs/control/ARCHITECTURE.md"
"docs/control/OBSERVABILITY.md"
".github/workflows/control-harness.yml"
)
for rel in "${baseline[@]}"; do
copy_template "$rel"
done
makefile="$repo_path/Makefile"
if [ ! -f "$makefile" ]; then
cat > "$makefile" <<'MAKEFILE'
-include Makefile.control
MAKEFILE
echo "[write] Makefile"
elif ! grep -Eq '(^|[[:space:]])-?include[[:space:]]+Makefile\.control([[:space:]]|$)' "$makefile"; then
cat >> "$makefile" <<'MAKEFILE'
# Control metalayer targets
-include Makefile.control
MAKEFILE
echo "[update] Makefile"
else
echo "[skip] Makefile already includes Makefile.control"
fi
chmod +x \
"$repo_path/scripts/audit_control.sh" \
"$repo_path/scripts/control/smoke.sh" \
"$repo_path/scripts/control/check.sh" \
"$repo_path/scripts/control/test.sh"
echo
echo "Baseline control metalayer bootstrap complete."
echo "Next: run python3 scripts/control_wizard.py audit $repo_path"
#!/usr/bin/env python3
"""Typer wizard for control metalayer setup in agent-operated repositories."""
from __future__ import annotations
import subprocess
from enum import Enum
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
try:
import typer
except ImportError as exc: # pragma: no cover - import guard
raise SystemExit(
"Missing dependency: typer. Install with `python3 -m pip install typer`."
) from exc
app = typer.Typer(help="Control metalayer wizard for agentic repository setup.")
primitive_app = typer.Typer(help="Manage control primitives.")
app.add_typer(primitive_app, name="primitive")
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
TEMPLATE_DIR = SKILL_DIR / "assets" / "templates"
BOOTSTRAP_SCRIPT = SCRIPT_DIR / "bootstrap_control.sh"
AUDIT_SCRIPT = SCRIPT_DIR / "audit_control.sh"
BASELINE_FILES: Tuple[str, ...] = (
"AGENTS.md",
"PLANS.md",
"METALAYER.md",
"Makefile.control",
"scripts/audit_control.sh",
"scripts/control/smoke.sh",
"scripts/control/check.sh",
"scripts/control/test.sh",
"docs/control/ARCHITECTURE.md",
"docs/control/OBSERVABILITY.md",
".github/workflows/control-harness.yml",
)
class Profile(str, Enum):
baseline = "baseline"
governed = "governed"
autonomous = "autonomous"
class Primitive(str, Enum):
policy = "policy"
commands = "commands"
topology = "topology"
loop = "loop"
metrics = "metrics"
hooks = "hooks"
recovery = "recovery"
state = "state"
nightly = "nightly"
web = "web"
cli = "cli"
PRIMITIVE_FILES: Dict[Primitive, Tuple[str, ...]] = {
Primitive.policy: (".control/policy.yaml",),
Primitive.commands: (".control/commands.yaml",),
Primitive.topology: (".control/topology.yaml",),
Primitive.loop: ("docs/control/CONTROL_LOOP.md",),
Primitive.metrics: ("evals/control-metrics.yaml",),
Primitive.hooks: (
"scripts/control/install_hooks.sh",
".githooks/pre-commit",
".githooks/pre-push",
),
Primitive.recovery: ("scripts/control/recover.sh",),
Primitive.state: (".control/state.json",),
Primitive.nightly: (".github/workflows/control-nightly.yml",),
Primitive.web: (
"scripts/control/web_e2e.sh",
".github/workflows/web-e2e.yml",
"tests/e2e/web/smoke.spec.ts",
"playwright.config.ts",
),
Primitive.cli: (
"scripts/control/cli_e2e.sh",
".github/workflows/cli-e2e.yml",
"tests/e2e/cli/smoke.sh",
),
}
GOVERNED_PRIMITIVES: Tuple[Primitive, ...] = (
Primitive.policy,
Primitive.commands,
Primitive.topology,
Primitive.loop,
Primitive.metrics,
Primitive.hooks,
)
AUTONOMOUS_PRIMITIVES: Tuple[Primitive, ...] = (
*GOVERNED_PRIMITIVES,
Primitive.recovery,
Primitive.state,
Primitive.nightly,
Primitive.web,
Primitive.cli,
)
def _resolve_repo(path: Path) -> Path:
repo = path.expanduser().resolve()
if not repo.exists() or not repo.is_dir():
typer.secho(f"error: repo path does not exist: {repo}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=2)
return repo
def _run(script: Path, args: List[str]) -> None:
if not script.exists():
typer.secho(f"error: script not found: {script}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=2)
result = subprocess.run([str(script), *args], check=False)
if result.returncode != 0:
raise typer.Exit(code=result.returncode)
def _copy_template(relative_path: str, repo: Path, force: bool) -> str:
source = TEMPLATE_DIR / relative_path
target = repo / relative_path
if not source.exists():
typer.secho(f"error: missing template: {source}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=2)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() and not force:
return "skip"
target.write_bytes(source.read_bytes())
if target.suffix == ".sh" or relative_path.startswith(".githooks/"):
target.chmod(0o755)
return "write"
def _activate_hooks(repo: Path) -> None:
install_script = repo / "scripts" / "control" / "install_hooks.sh"
if not install_script.exists():
return
result = subprocess.run([str(install_script)], cwd=str(repo), check=False)
if result.returncode != 0:
typer.secho(
" [warn] failed to activate git hooks automatically; run scripts/control/install_hooks.sh manually.",
fg=typer.colors.YELLOW,
)
def _apply_primitives(repo: Path, primitives: Iterable[Primitive], force: bool) -> None:
for primitive in primitives:
typer.secho(f"\n[{primitive.value}]", fg=typer.colors.CYAN)
for relative_path in PRIMITIVE_FILES[primitive]:
state = _copy_template(relative_path, repo, force)
label = "write" if state == "write" else "skip "
typer.echo(f" [{label}] {relative_path}")
if primitive == Primitive.hooks:
_activate_hooks(repo)
@app.command()
def init(
repo_path: Path = typer.Argument(Path("."), help="Target repository path."),
profile: Profile = typer.Option(Profile.governed, "--profile", "-p", help="Setup profile."),
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
) -> None:
"""Initialize control metalayer in a repository."""
repo = _resolve_repo(repo_path)
typer.secho(f"Initializing control metalayer in {repo}", fg=typer.colors.GREEN)
args = [str(repo)]
if force:
args.append("--force")
_run(BOOTSTRAP_SCRIPT, args)
if profile == Profile.baseline:
return
primitives = GOVERNED_PRIMITIVES if profile == Profile.governed else AUTONOMOUS_PRIMITIVES
_apply_primitives(repo, primitives, force)
typer.secho("\nInitialization complete.", fg=typer.colors.GREEN)
@app.command()
def audit(
repo_path: Path = typer.Argument(Path("."), help="Target repository path."),
strict: bool = typer.Option(False, "--strict", help="Require governed/autonomous primitives."),
) -> None:
"""Run control metalayer audit."""
repo = _resolve_repo(repo_path)
args = [str(repo)]
if strict:
args.append("--strict")
_run(AUDIT_SCRIPT, args)
@app.command()
def status(
repo_path: Path = typer.Argument(Path("."), help="Target repository path."),
) -> None:
"""Show baseline and primitive coverage."""
repo = _resolve_repo(repo_path)
typer.secho(f"Control metalayer status for {repo}", fg=typer.colors.GREEN)
typer.echo()
baseline_present = sum(1 for rel in BASELINE_FILES if (repo / rel).exists())
typer.echo(f"baseline: {baseline_present}/{len(BASELINE_FILES)}")
for rel in BASELINE_FILES:
marker = "OK " if (repo / rel).exists() else "MISS"
typer.echo(f" [{marker}] {rel}")
typer.echo()
typer.echo("primitives:")
for primitive in Primitive:
files = PRIMITIVE_FILES[primitive]
present = sum(1 for rel in files if (repo / rel).exists())
marker = "OK " if present == len(files) else "PARTIAL" if present > 0 else "MISS"
typer.echo(f" [{marker}] {primitive.value}: {present}/{len(files)}")
@primitive_app.command("list")
def primitive_list() -> None:
"""List primitive names and files."""
for primitive in Primitive:
typer.echo(primitive.value)
for rel in PRIMITIVE_FILES[primitive]:
typer.echo(f" - {rel}")
@primitive_app.command("add")
def primitive_add(
primitives: List[Primitive] = typer.Argument(..., help="Primitive names to add."),
repo: Path = typer.Option(Path("."), "--repo", "-r", help="Target repository path."),
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
) -> None:
"""Add selected primitives incrementally."""
repo_path = _resolve_repo(repo)
_apply_primitives(repo_path, primitives, force)
typer.secho("\nPrimitive update complete.", fg=typer.colors.GREEN)
if __name__ == "__main__":
app()
#!/usr/bin/env python3
"""
conversation-history.py — Bridge .entire/ conversation logs to Obsidian knowledge graph.
Parses Claude Code session logs and transcript files to generate
Obsidian-compatible markdown documents per conversation session,
with wikilinks, frontmatter, and a Map of Content (MOC) index.
Usage:
python3 scripts/conversation-history.py [--output docs/conversations] [--limit N]
"""
import json
import re
import sys
import argparse
from collections import defaultdict
from datetime import datetime
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────────────────────
REPO_ROOT = Path(__file__).resolve().parent.parent
ENTIRE_LOG = REPO_ROOT / ".entire" / "logs" / "entire.log"
DEFAULT_OUTPUT = REPO_ROOT / "docs" / "conversations"
def _resolve_transcripts_dir() -> Path:
"""Derive the Claude Code transcripts directory from the repo path.
Claude Code stores transcripts at:
~/.claude/projects/{repo_path_with_slashes_as_dashes}/{session}.jsonl
The project key is the absolute repo path with '/' replaced by '-'.
Since the path starts with '/', the first char becomes '-' automatically.
This works for any user/machine.
"""
project_key = str(REPO_ROOT).replace("/", "-")
return Path.home() / ".claude" / "projects" / project_key
TRANSCRIPTS_DIR = _resolve_transcripts_dir()
# ── Event Log Parser ───────────────────────────────────────────────────────────
def parse_event_log(log_path: Path) -> dict:
"""Parse .entire/logs/entire.log into per-session metadata."""
sessions = defaultdict(lambda: {
"starts": [],
"ends": [],
"turns": [],
"subagents": [],
"checkpoints": [],
"commits": [],
"attributions": [],
"phases": [],
"first_seen": None,
"last_seen": None,
"branch": None,
})
with open(log_path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
sid = entry.get("session_id", "")
if not sid:
continue
ts = entry.get("time", "")
msg = entry.get("msg", "")
event = entry.get("event", "")
session = sessions[sid]
# Track time bounds
if ts:
if session["first_seen"] is None or ts < session["first_seen"]:
session["first_seen"] = ts
if session["last_seen"] is None or ts > session["last_seen"]:
session["last_seen"] = ts
# Lifecycle events
if event == "SessionStart":
session["starts"].append(ts)
ref = entry.get("session_ref", "")
if ref:
session["transcript_ref"] = ref
elif event == "SessionEnd":
session["ends"].append(ts)
elif event == "TurnStart":
session["turns"].append({"start": ts})
elif event == "TurnEnd":
if session["turns"]:
session["turns"][-1]["end"] = ts
# Subagents
if event == "SubagentStart":
session["subagents"].append({
"tool_use_id": entry.get("tool_use_id", ""),
"start": ts,
"type": entry.get("subagent_type", ""),
})
elif event == "SubagentEnd":
agent_id = entry.get("agent_id", "")
tool_id = entry.get("tool_use_id", "")
for sa in reversed(session["subagents"]):
if sa.get("tool_use_id") == tool_id:
sa["end"] = ts
sa["agent_id"] = agent_id
break
# Checkpoints
if "checkpoint" in msg:
session["checkpoints"].append({
"time": ts,
"type": entry.get("checkpoint_type", ""),
"modified": entry.get("modified_files", 0),
"new": entry.get("new_files", 0),
"deleted": entry.get("deleted_files", 0),
"shadow_branch": entry.get("shadow_branch", ""),
"subagent_type": entry.get("subagent_type", ""),
})
# Attribution
if "attribution" in msg:
session["attributions"].append({
"time": ts,
"agent_lines": entry.get("agent_lines", 0),
"human_added": entry.get("human_added", 0),
"human_modified": entry.get("human_modified", 0),
"files_touched": entry.get("files_touched", 0),
"agent_pct": entry.get("agent_percentage", 0),
})
# Phase transitions
if "phase transition" in msg:
session["phases"].append({
"time": ts,
"from": entry.get("from", ""),
"to": entry.get("to", ""),
})
# Commits
if "commit" in msg.lower() and "prepare-commit-msg" in msg:
session["commits"].append({
"time": ts,
"checkpoint_id": entry.get("checkpoint_id", ""),
})
return dict(sessions)
# Patterns that indicate a message is internal/system, not a real user prompt
_SYSTEM_MSG_PATTERNS = [
"<task-notification>",
"<task-id>",
"<system-reminder>",
"<local-command-caveat>",
"[Request interrupted",
"toolu_", # Raw tool use IDs leaking into content
"/private/tmp/claude", # Internal file paths
]
# ── Transcript Parser ──────────────────────────────────────────────────────────
def parse_transcript(jsonl_path: Path, max_user_msgs: int = 50) -> dict:
"""Extract conversation content from a .jsonl transcript file.
Builds a chronological timeline of events, grouped into 'turns':
each turn starts with a user message and includes all assistant
text blocks, tool calls, and tool results until the next user message.
"""
result: dict = {
"timeline": [], # Chronological list of {type, ...} events
"tools_used": set(),
"files_touched": set(),
"git_branch": None,
"version": None,
"total_messages": 0,
"first_timestamp": None,
"last_timestamp": None,
}
if not jsonl_path.exists():
return result
all_entries: list[dict] = []
try:
with open(jsonl_path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
all_entries.append(entry)
except Exception as e:
result["parse_error"] = str(e)
return result
result["total_messages"] = len(all_entries)
user_count = 0
for entry in all_entries:
ts = entry.get("timestamp", "")
if ts:
if result["first_timestamp"] is None or ts < result["first_timestamp"]:
result["first_timestamp"] = ts
if result["last_timestamp"] is None or ts > result["last_timestamp"]:
result["last_timestamp"] = ts
if entry.get("gitBranch"):
result["git_branch"] = entry["gitBranch"]
if entry.get("version"):
result["version"] = entry["version"]
entry_type = entry.get("type", "")
# ── User messages (filter out system/tool-result noise) ──
if entry_type == "user":
# Skip tool result messages (internal tool→assistant feedback)
if entry.get("toolUseResult") is not None:
continue
msg = entry.get("message", {})
content = msg.get("content", "")
text = _extract_text(content)
if not text or _is_system_message(text):
continue
if user_count < max_user_msgs:
result["timeline"].append({
"kind": "user",
"text": text[:2000],
"timestamp": ts,
})
user_count += 1
# ── Assistant entries: extract ALL text blocks + tool calls ──
elif entry_type == "assistant":
msg = entry.get("message", {})
content = msg.get("content", "")
if isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type", "")
if btype == "text":
t = block.get("text", "").strip()
if t and len(t) > 10:
result["timeline"].append({
"kind": "assistant_text",
"text": t[:2000],
"timestamp": ts,
})
elif btype == "tool_use":
tool_name = block.get("name", "")
if tool_name:
result["tools_used"].add(tool_name)
tool_input = block.get("input", {})
desc = _summarize_tool_call(tool_name, tool_input)
details = _tool_detail_lines(tool_name, tool_input)
result["timeline"].append({
"kind": "tool_call",
"tool": tool_name,
"description": desc,
"details": details,
"timestamp": ts,
})
elif isinstance(content, str) and content.strip() and len(content.strip()) > 10:
result["timeline"].append({
"kind": "assistant_text",
"text": content.strip()[:2000],
"timestamp": ts,
})
# ── File operations from progress events ──
elif entry_type == "progress":
data = entry.get("data", {})
if isinstance(data, dict):
fp = data.get("file_path", "") or data.get("path", "")
if fp:
fp = fp.replace(str(REPO_ROOT) + "/", "")
result["files_touched"].add(fp)
# Convert sets to sorted lists
result["tools_used"] = sorted(result["tools_used"])
result["files_touched"] = sorted(list(result["files_touched"])[:50])
return result
def _summarize_tool_call(tool_name: str, tool_input: dict) -> str:
"""Create a one-line summary of a tool call for the timeline."""
rp = str(REPO_ROOT) + "/"
if tool_name == "Bash":
return tool_input.get("description", "") or tool_input.get("command", "")[:200]
elif tool_name in ("Read", "Write", "Edit"):
return tool_input.get("file_path", "").replace(rp, "")
elif tool_name == "Grep":
pattern = tool_input.get("pattern", "")
path = tool_input.get("path", "").replace(rp, "")
return f'"{pattern}" in {path}' if path else f'"{pattern}"'
elif tool_name == "Glob":
return tool_input.get("pattern", "")
elif tool_name == "Agent":
return tool_input.get("description", tool_input.get("prompt", "")[:120])
elif tool_name in ("TaskCreate", "TaskUpdate"):
return tool_input.get("description", tool_input.get("subject", ""))[:120]
else:
for key in ("description", "prompt", "command", "query", "pattern"):
if key in tool_input:
return str(tool_input[key])[:120]
return ""
def _tool_detail_lines(tool_name: str, tool_input: dict) -> list[str]:
"""Return detail lines for a tool call to show inside a collapsible block."""
rp = str(REPO_ROOT) + "/"
lines: list[str] = []
if tool_name == "Bash":
cmd = tool_input.get("command", "")
desc = tool_input.get("description", "")
if desc:
lines.append(f"*{desc}*")
if cmd:
cmd_preview = cmd[:500].replace("\n", " && ")
lines.append(f"`{cmd_preview}`")
elif tool_name == "Read":
fp = tool_input.get("file_path", "").replace(rp, "")
if fp:
lines.append(f"`{fp}`")
offset = tool_input.get("offset")
limit = tool_input.get("limit")
if offset or limit:
parts = []
if offset:
parts.append(f"offset: {offset}")
if limit:
parts.append(f"limit: {limit}")
lines.append(f"Range: {', '.join(parts)}")
elif tool_name == "Write":
fp = tool_input.get("file_path", "").replace(rp, "")
content = tool_input.get("content", "")
if fp:
lines.append(f"`{fp}`")
if content:
preview = content[:500].replace("\n", " ").replace("`", "'")
lines.append(f"Content: `{preview}{'...' if len(content) > 500 else ''}`")
if len(content) > 500:
lines.append(f"({len(content)} chars total)")
elif tool_name == "Edit":
fp = tool_input.get("file_path", "").replace(rp, "")
old = tool_input.get("old_string", "")
new = tool_input.get("new_string", "")
if fp:
lines.append(f"`{fp}`")
if old:
old_preview = old[:500].replace("\n", " ").replace("`", "'")
lines.append(f"Old: `{old_preview}{'...' if len(old) > 500 else ''}`")
if new:
new_preview = new[:500].replace("\n", " ").replace("`", "'")
lines.append(f"New: `{new_preview}{'...' if len(new) > 500 else ''}`")
elif tool_name == "Grep":
pattern = tool_input.get("pattern", "")
path = tool_input.get("path", "").replace(rp, "")
mode = tool_input.get("output_mode", "")
if pattern:
lines.append(f"Pattern: `{pattern}`")
if path:
lines.append(f"Path: `{path}`")
if mode:
lines.append(f"Mode: {mode}")
elif tool_name == "Glob":
pattern = tool_input.get("pattern", "")
path = tool_input.get("path", "").replace(rp, "")
if pattern:
lines.append(f"Pattern: `{pattern}`")
if path:
lines.append(f"In: `{path}`")
elif tool_name == "Agent":
desc = tool_input.get("description", "")
prompt = tool_input.get("prompt", "")
sat = tool_input.get("subagent_type", "")
bg = tool_input.get("run_in_background", False)
if sat:
lines.append(f"Type: **{sat}**")
if desc:
lines.append(f"Task: {desc}")
if prompt:
prompt_preview = prompt[:600].replace("\n", " ")
lines.append(f"Prompt: {prompt_preview}")
if bg:
lines.append("*(background)*")
elif tool_name == "TaskCreate":
subj = tool_input.get("subject", "")
desc = tool_input.get("description", "")
if subj:
lines.append(f"**{subj}**")
if desc:
desc_preview = desc[:500].replace("\n", " ")
lines.append(desc_preview)
elif tool_name == "TaskUpdate":
tid = tool_input.get("taskId", "")
status = tool_input.get("status", "")
blocked = tool_input.get("addBlockedBy", [])
if tid:
lines.append(f"Task: #{tid}")
if status:
lines.append(f"Status: {status}")
if blocked:
lines.append(f"Blocked by: {blocked}")
elif tool_name == "Skill":
skill = tool_input.get("skill", "")
args = tool_input.get("args", "")
if skill:
lines.append(f"Skill: `{skill}`")
if args:
lines.append(f"Args: {args[:200]}")
else:
# Generic: show all input fields
for k, v in list(tool_input.items())[:5]:
val = str(v)[:200]
lines.append(f"{k}: {val}")
return lines
def _is_system_message(text: str) -> bool:
"""Check if a message is internal/system noise, not a real user prompt."""
for pattern in _SYSTEM_MSG_PATTERNS:
if pattern in text:
return True
# Skip messages that are just very short acknowledgements from tool results
if len(text) < 5:
return True
return False
def _extract_text(content) -> str:
"""Extract plain text from message content (string or content blocks)."""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
return " ".join(parts).strip()
return ""
# ── Helpers ────────────────────────────────────────────────────────────────────
def _ts_short(ts: str) -> str:
"""Extract HH:MM from an ISO timestamp."""
if not ts:
return ""
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return dt.strftime("%H:%M")
except (ValueError, TypeError):
return ""
# ── Callout-safe text ──────────────────────────────────────────────────────────
def _callout_safe(text: str) -> str:
"""Sanitize text for use inside Obsidian callout blocks (> prefixed).
Issues addressed:
- Markdown headers (## Foo) render as real headers, breaking the callout
- HTML/XML tags (<task-notification>) render as raw markup
- Blank lines inside callouts break the callout block
"""
result_lines = []
for line in text.split("\n"):
# Strip XML/HTML tags (task-notification, system-reminder, etc.)
line = re.sub(r'<[^>]+>', '', line)
# Convert markdown headers to bold text (headers break callouts)
line = re.sub(r'^(#{1,6})\s+(.+)$', r'**\2**', line)
# Preserve blank lines inside callout with empty quote marker
if not line.strip():
result_lines.append("")
else:
result_lines.append(line)
return "\n".join(result_lines)
# ── Markdown Generator ─────────────────────────────────────────────────────────
def generate_session_doc(session_id: str, meta: dict, transcript: dict) -> str:
"""Generate an Obsidian markdown document for a single session."""
# Determine date from first_seen or transcript
date_str = ""
ts = meta.get("first_seen") or transcript.get("first_timestamp", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
date_str = ts[:10] if len(ts) >= 10 else ""
short_id = session_id[:8]
branch = transcript.get("git_branch", "") or ""
version = transcript.get("version", "") or ""
turn_count = len(meta.get("turns", []))
tools = transcript.get("tools_used", [])
files = transcript.get("files_touched", [])
timeline = transcript.get("timeline", [])
# Find first user message in timeline for title
first_user_text = ""
for ev in timeline:
if ev.get("kind") == "user":
first_user_text = ev.get("text", "")
break
# Derive a title from first user message
title = f"Session {short_id}"
if first_user_text:
first_msg = first_user_text[:80].replace("\n", " ").strip()
first_msg = re.sub(r'[#\[\]|`]', '', first_msg).strip()
if first_msg:
title = first_msg[:60] + ("..." if len(first_msg) > 60 else "")
# Compute duration
duration = ""
start_ts = meta.get("first_seen", "")
end_ts = meta.get("last_seen", "")
if start_ts and end_ts:
try:
s = datetime.fromisoformat(start_ts.replace("Z", "+00:00"))
e = datetime.fromisoformat(end_ts.replace("Z", "+00:00"))
delta = e - s
mins = int(delta.total_seconds() / 60)
if mins >= 60:
duration = f"{mins // 60}h {mins % 60}m"
else:
duration = f"{mins}m"
except (ValueError, TypeError):
pass
# Build tags
tags = ["stimulus/conversations"]
if branch:
tag_branch = branch.replace("/", "-").replace("_", "-")
tags.append(f"branch/{tag_branch}")
# Subagent types used
subagent_types = set()
for sa in meta.get("subagents", []):
if sa.get("type"):
subagent_types.add(sa["type"])
# Attribution summary
total_agent_lines = sum(a.get("agent_lines", 0) for a in meta.get("attributions", []))
total_human_modified = sum(a.get("human_modified", 0) for a in meta.get("attributions", []))
total_files_touched = max((a.get("files_touched", 0) for a in meta.get("attributions", [])), default=0)
lines = []
# Frontmatter
lines.append("---")
lines.append(f"title: \"{title}\"")
lines.append(f"description: Claude Code session {short_id} on {date_str}")
lines.append("tags:")
for tag in tags:
lines.append(f" - {tag}")
lines.append("type: conversation")
lines.append("status: active")
lines.append(f"created: {date_str}")
lines.append(f"updated: {date_str}")
lines.append(f"session_id: {session_id}")
if branch:
lines.append(f"branch: {branch}")
lines.append("related:")
lines.append(" - \"[[Conversations]]\"")
lines.append(" - \"[[CLAUDE]]\"")
lines.append("---")
lines.append("")
# Header
lines.append(f"# {title}")
lines.append("")
# Metadata table
lines.append("| Field | Value |")
lines.append("|-------|-------|")
lines.append(f"| **Session** | `{session_id}` |")
lines.append(f"| **Date** | {date_str} |")
if duration:
lines.append(f"| **Duration** | {duration} |")
lines.append(f"| **Turns** | {turn_count} |")
if branch:
lines.append(f"| **Branch** | `{branch}` |")
if version:
lines.append(f"| **Claude Code** | v{version} |")
lines.append(f"| **Messages** | {transcript.get('total_messages', 0)} |")
if total_agent_lines or total_human_modified:
lines.append(f"| **Agent lines** | {total_agent_lines} |")
lines.append(f"| **Human modified** | {total_human_modified} |")
lines.append("")
# Tools used
if tools:
lines.append("## Tools Used")
lines.append("")
lines.append(", ".join(f"`{t}`" for t in tools))
lines.append("")
# Subagents
if subagent_types:
lines.append("## Subagents")
lines.append("")
for sat in sorted(subagent_types):
lines.append(f"- {sat}")
lines.append("")
# Conversation thread — render full chronological timeline
timeline = transcript.get("timeline", [])
if timeline:
lines.append("## Conversation Thread")
lines.append("")
# Group consecutive tool_call events to avoid clutter
tool_batch: list[dict] = []
def _flush_tools():
"""Render accumulated tool calls with nested collapsible details."""
nonlocal tool_batch
if not tool_batch:
return
lines.append("> [!example] Tool Calls")
for tc in tool_batch:
desc = tc.get("description", "")
details = tc.get("details", [])
tool_label = f"**{tc['tool']}**"
if desc:
tool_label += f" — {desc}"
if details:
# Nested collapsible callout per tool with details
lines.append(f">> [!note] {tool_label}")
for dl in details:
# Prefix every line for nested callout (multi-line values)
for sub_line in dl.split("\n"):
lines.append(f">> {sub_line}")
else:
# Simple bullet if no details
lines.append(f"> - {tool_label}")
lines.append("")
tool_batch = []
for event in timeline:
kind = event.get("kind", "")
if kind == "user":
# Flush any pending tool calls before a new user message
_flush_tools()
ts_short = _ts_short(event.get("timestamp", ""))
safe_text = _callout_safe(event["text"])
header = f"**User** ({ts_short})" if ts_short else "**User**"
lines.append(f"> [!quote] {header}")
for lt in safe_text.split("\n"):
lines.append(f"> {lt}")
lines.append("")
elif kind == "assistant_text":
# Flush tool calls before assistant text
_flush_tools()
safe_text = _callout_safe(event["text"])
lines.append(f"> [!info] **Assistant**")
for lt in safe_text.split("\n"):
lines.append(f"> {lt}")
lines.append("")
elif kind == "tool_call":
tool_batch.append(event)
# Flush any remaining tool calls
_flush_tools()
lines.append("")
# Files touched
if files:
lines.append("## Files Touched")
lines.append("")
for fp in files[:30]:
lines.append(f"- `{fp}`")
if len(files) > 30:
lines.append(f"- ... and {len(files) - 30} more")
lines.append("")
# Checkpoints
if meta.get("commits"):
lines.append("## Commits")
lines.append("")
for c in meta["commits"]:
lines.append(f"- `{c.get('checkpoint_id', '')}` at {c.get('time', '')[:19]}")
lines.append("")
# Navigation
lines.append("---")
lines.append("")
lines.append("*Part of [[Conversations]] | See [[CLAUDE]] for project invariants*")
return "\n".join(lines)
def generate_moc(session_docs: list, output_dir: Path) -> str:
"""Generate the Conversations.md Map of Content."""
lines = []
lines.append("---")
lines.append("title: Conversations")
lines.append("description: Map of Content for Claude Code conversation history sessions")
lines.append("tags:")
lines.append(" - stimulus/conversations")
lines.append(" - moc")
lines.append("type: moc")
lines.append("status: active")
lines.append(f"created: {datetime.now().strftime('%Y-%m-%d')}")
lines.append(f"updated: {datetime.now().strftime('%Y-%m-%d')}")
lines.append("related:")
lines.append(" - \"[[Documentation Hub]]\"")
lines.append(" - \"[[CLAUDE]]\"")
lines.append(" - \"[[AGENTS]]\"")
lines.append("---")
lines.append("")
lines.append("# Conversations")
lines.append("")
lines.append("> [!info] Agent Session History")
lines.append("> This directory contains Obsidian-compatible records of every Claude Code")
lines.append("> conversation session in this project. Each document traces the prompts,")
lines.append("> tool usage, files modified, and commits — linking agent work to the")
lines.append("> knowledge graph.")
lines.append("")
lines.append(f"**Total sessions indexed**: {len(session_docs)}")
lines.append("")
# Group by date
by_date = defaultdict(list)
for doc in session_docs:
by_date[doc["date"]].append(doc)
for date in sorted(by_date.keys(), reverse=True):
lines.append(f"## {date}")
lines.append("")
lines.append("| Session | Branch | Turns | Duration | Topic |")
lines.append("|---------|--------|-------|----------|-------|")
for doc in sorted(by_date[date], key=lambda d: d.get("time", "")):
name = doc["filename"].replace(".md", "")
branch = doc.get("branch", "—")
turns = doc.get("turns", 0)
duration = doc.get("duration", "—")
topic = doc.get("title", "")[:50]
lines.append(f"| [[{name}]] | `{branch}` | {turns} | {duration} | {topic} |")
lines.append("")
# Stats
lines.append("## Statistics")
lines.append("")
total_turns = sum(d.get("turns", 0) for d in session_docs)
branches = set(d.get("branch", "") for d in session_docs if d.get("branch"))
lines.append(f"- **Total turns**: {total_turns}")
lines.append(f"- **Branches worked on**: {len(branches)}")
lines.append(f"- **Date range**: {min(d['date'] for d in session_docs) if session_docs else '—'} → {max(d['date'] for d in session_docs) if session_docs else '—'}")
lines.append("")
# Navigation
lines.append("---")
lines.append("")
lines.append("*Part of [[Documentation Hub]] | Generated by `scripts/conversation-history.py`*")
return "\n".join(lines)
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Bridge .entire/ logs to Obsidian knowledge graph")
parser.add_argument("--output", default=str(DEFAULT_OUTPUT), help="Output directory for conversation docs")
parser.add_argument("--limit", type=int, default=0, help="Limit number of sessions to process (0 = all)")
parser.add_argument("--force", action="store_true", help="Overwrite existing session docs")
parser.add_argument("--dry-run", action="store_true", help="Print stats without writing files")
args = parser.parse_args()
output_dir = Path(args.output)
print(f"📂 Parsing event log: {ENTIRE_LOG}")
if not ENTIRE_LOG.exists():
print("⏭ .entire/ not configured — skipping conversation history generation.")
print(" Install Entire (https://entire.dev) to enable session logging.")
sys.exit(0)
if not TRANSCRIPTS_DIR.exists():
print(f"⏭ Transcripts dir not found: {TRANSCRIPTS_DIR}")
print(" No Claude Code sessions found for this project — skipping.")
sys.exit(0)
sessions = parse_event_log(ENTIRE_LOG)
print(f" Found {len(sessions)} sessions")
# Filter to sessions with meaningful activity (at least 1 turn)
active_sessions = {
sid: meta for sid, meta in sessions.items()
if len(meta.get("turns", [])) >= 1
}
print(f" {len(active_sessions)} sessions with ≥1 turn")
if args.limit:
# Take the most recent N
sorted_sids = sorted(
active_sessions.keys(),
key=lambda s: active_sessions[s].get("first_seen", ""),
reverse=True,
)[:args.limit]
active_sessions = {s: active_sessions[s] for s in sorted_sids}
print(f" Limited to {len(active_sessions)} most recent")
if args.dry_run:
print("\n📊 Dry run — would generate:")
for sid, meta in sorted(active_sessions.items(), key=lambda x: x[1].get("first_seen", "")):
turns = len(meta.get("turns", []))
ts = meta.get("first_seen", "?")[:19]
print(f" {sid[:8]} | {ts} | {turns} turns")
print(f"\n Total: {len(active_sessions)} session docs + 1 MOC")
return
# Create output directory
output_dir.mkdir(parents=True, exist_ok=True)
session_docs_meta = []
processed = 0
for sid, meta in sorted(active_sessions.items(), key=lambda x: x[1].get("first_seen", "")):
short_id = sid[:8]
date_str = ""
ts = meta.get("first_seen", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
date_str = ts[:10] if len(ts) >= 10 else "unknown"
filename = f"session-{date_str}-{short_id}.md"
filepath = output_dir / filename
# Skip existing unless --force
if filepath.exists() and not args.force:
# Still collect metadata for MOC
session_docs_meta.append(_read_existing_meta(filepath, filename, sid, meta))
continue
# Parse transcript
transcript_path = TRANSCRIPTS_DIR / f"{sid}.jsonl"
transcript = parse_transcript(transcript_path)
# Generate doc
doc_content = generate_session_doc(sid, meta, transcript)
# Write
filepath.write_text(doc_content, encoding="utf-8")
processed += 1
# Compute duration for MOC
duration = ""
start_ts = meta.get("first_seen", "")
end_ts = meta.get("last_seen", "")
if start_ts and end_ts:
try:
s = datetime.fromisoformat(start_ts.replace("Z", "+00:00"))
e = datetime.fromisoformat(end_ts.replace("Z", "+00:00"))
mins = int((e - s).total_seconds() / 60)
duration = f"{mins // 60}h {mins % 60}m" if mins >= 60 else f"{mins}m"
except (ValueError, TypeError):
pass
# Derive title from first user message in timeline
title = f"Session {short_id}"
for ev in transcript.get("timeline", []):
if ev.get("kind") == "user":
first = ev["text"][:60]
first = re.sub(r'[#\[\]|`]', '', first).replace("\n", " ").strip()
if first:
title = first
break
session_docs_meta.append({
"filename": filename,
"session_id": sid,
"date": date_str,
"time": ts,
"branch": transcript.get("git_branch", ""),
"turns": len(meta.get("turns", [])),
"duration": duration,
"title": title,
})
sys.stdout.write(f"\r Processed {processed} sessions...")
sys.stdout.flush()
print(f"\n Wrote {processed} new session docs")
# Generate MOC
moc_content = generate_moc(session_docs_meta, output_dir)
moc_path = output_dir / "Conversations.md"
moc_path.write_text(moc_content, encoding="utf-8")
print(f" Wrote MOC: {moc_path.relative_to(REPO_ROOT)}")
print(f"\n✅ Done. {len(session_docs_meta)} sessions indexed in {output_dir.relative_to(REPO_ROOT)}/")
def _read_existing_meta(filepath: Path, filename: str, sid: str, meta: dict) -> dict:
"""Read minimal metadata from an existing session doc for MOC generation."""
content = filepath.read_text(encoding="utf-8")
title = f"Session {sid[:8]}"
branch = ""
for line in content.split("\n"):
if line.startswith("title:"):
title = line.split(":", 1)[1].strip().strip('"')
if line.startswith("branch:"):
branch = line.split(":", 1)[1].strip()
date_str = ""
ts = meta.get("first_seen", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
date_str = ts[:10] if len(ts) >= 10 else "unknown"
duration = ""
start_ts = meta.get("first_seen", "")
end_ts = meta.get("last_seen", "")
if start_ts and end_ts:
try:
s = datetime.fromisoformat(start_ts.replace("Z", "+00:00"))
e = datetime.fromisoformat(end_ts.replace("Z", "+00:00"))
mins = int((e - s).total_seconds() / 60)
duration = f"{mins // 60}h {mins % 60}m" if mins >= 60 else f"{mins}m"
except (ValueError, TypeError):
pass
return {
"filename": filename,
"session_id": sid,
"date": date_str,
"time": ts,
"branch": branch,
"turns": len(meta.get("turns", [])),
"duration": duration,
"title": title,
}
if __name__ == "__main__":
main()
MIT License
Copyright (c) 2026 Carlos Escobar (BroomVA)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Agent Consciousness & Control Metalayer
Layers 1-2 of the Broomva Stack — 24 skills across 7 layers.
Skills for persistent consciousness in autonomous AI agent development. Three complementary skills that give stateless agent sessions the accumulated understanding of all prior sessions.
Skills
control-metalayer-loop — Behavioral Governance
Control-system metalayer with setpoints, sensors, gates, feedback loops, and escalation budgets.
npx skills add broomva/control-metalayer --skill control-metalayer-loopagent-consciousness — Architecture & Philosophy
The synthesis: how control metalayer + knowledge graph + conversation logs form a self-evolving persistent consciousness.
npx skills add broomva/control-metalayer --skill agent-consciousnessknowledge-graph-memory — Episodic Memory Bridge
Bridges Claude Code conversation logs (.entire/ + transcripts) to an Obsidian knowledge graph for searchable session history.
npx skills add broomva/control-metalayer --skill knowledge-graph-memoryThe Consciousness Stack
Working memory → Auto-memory → Conversation logs → Knowledge graph → Policy rules → Invariants
(ephemeral) (permanent)| Substrate | Skill | Purpose |
|---|---|---|
| Control Metalayer | control-metalayer-loop | How to behave (gates, policies, setpoints) |
| Knowledge Graph | agent-consciousness | What is known (Obsidian vault, wikilinks, MOCs) |
| Conversation Logs | knowledge-graph-memory | What was done (session records, tool traces) |
Quick Start
# 1. Initialize control metalayer
python3 .agents/skills/control-metalayer-loop/scripts/control_wizard.py init . --profile autonomous
# 2. Generate conversation history
python3 scripts/conversation-history.py --force
# 3. Audit
python3 .agents/skills/control-metalayer-loop/scripts/control_wizard.py audit . --strictSelf-Evolution
The system gets smarter the more it's used:
1. Agent encounters a failure mode not covered by existing policy 2. Agent fixes the immediate issue 3. Pattern is captured in conversation log (docs/conversations/) 4. If recurring, crystallizes into architecture doc (docs/architecture/) 5. If enforceable, becomes a gate in .control/policy.yaml 6. Future agents are governed by this rule automatically
Related skills
FAQ
What profiles does it support?
baseline (minimal harness), governed (adds policy, commands, topology, control loop, metrics, git hooks), and autonomous (adds recovery, nightly controls, and web/CLI E2E primitives).
How is it initialized?
Via the Typer wizard: python3 scripts/control_wizard.py init <repo-path> --profile governed.