muvue 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muvue-0.1.0/PKG-INFO +164 -0
- muvue-0.1.0/README.md +142 -0
- muvue-0.1.0/pyproject.toml +48 -0
- muvue-0.1.0/pyproject.toml.orig +42 -0
- muvue-0.1.0/src/muvue/__init__.py +0 -0
- muvue-0.1.0/src/muvue/__main__.py +4 -0
- muvue-0.1.0/src/muvue/_hook.py +282 -0
- muvue-0.1.0/src/muvue/api/__init__.py +9 -0
- muvue-0.1.0/src/muvue/api/app.py +901 -0
- muvue-0.1.0/src/muvue/api/static/index.html +451 -0
- muvue-0.1.0/src/muvue/cli/__init__.py +0 -0
- muvue-0.1.0/src/muvue/cli/main.py +1027 -0
- muvue-0.1.0/src/muvue/core/__init__.py +77 -0
- muvue-0.1.0/src/muvue/core/adapters.py +196 -0
- muvue-0.1.0/src/muvue/core/asks.py +179 -0
- muvue-0.1.0/src/muvue/core/claude_hooks.py +111 -0
- muvue-0.1.0/src/muvue/core/close.py +447 -0
- muvue-0.1.0/src/muvue/core/config.py +200 -0
- muvue-0.1.0/src/muvue/core/daemon.py +160 -0
- muvue-0.1.0/src/muvue/core/db.py +120 -0
- muvue-0.1.0/src/muvue/core/doctor.py +436 -0
- muvue-0.1.0/src/muvue/core/drift.py +399 -0
- muvue-0.1.0/src/muvue/core/drivers.py +254 -0
- muvue-0.1.0/src/muvue/core/events.py +84 -0
- muvue-0.1.0/src/muvue/core/gates.py +276 -0
- muvue-0.1.0/src/muvue/core/github.py +64 -0
- muvue-0.1.0/src/muvue/core/gitutil.py +36 -0
- muvue-0.1.0/src/muvue/core/history.py +65 -0
- muvue-0.1.0/src/muvue/core/hooks.py +273 -0
- muvue-0.1.0/src/muvue/core/imports.py +90 -0
- muvue-0.1.0/src/muvue/core/merge.py +247 -0
- muvue-0.1.0/src/muvue/core/migrate.py +59 -0
- muvue-0.1.0/src/muvue/core/nodes.py +792 -0
- muvue-0.1.0/src/muvue/core/pr.py +67 -0
- muvue-0.1.0/src/muvue/core/projects.py +95 -0
- muvue-0.1.0/src/muvue/core/queries.py +150 -0
- muvue-0.1.0/src/muvue/core/rebuild.py +224 -0
- muvue-0.1.0/src/muvue/core/repo_init.py +258 -0
- muvue-0.1.0/src/muvue/core/review.py +171 -0
- muvue-0.1.0/src/muvue/core/revisions.py +174 -0
- muvue-0.1.0/src/muvue/core/risk.py +151 -0
- muvue-0.1.0/src/muvue/core/runner.py +692 -0
- muvue-0.1.0/src/muvue/core/schema.py +314 -0
- muvue-0.1.0/src/muvue/core/spend.py +73 -0
- muvue-0.1.0/src/muvue/core/state_machine.py +93 -0
- muvue-0.1.0/src/muvue/core/strict.py +266 -0
- muvue-0.1.0/src/muvue/core/trailers.py +47 -0
- muvue-0.1.0/src/muvue/fake_agent.py +159 -0
- muvue-0.1.0/src/muvue/mcp_server.py +212 -0
muvue-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muvue
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run an AI coding agent's work as an auditable, gated state machine instead of a chat transcript
|
|
5
|
+
Keywords: ai-agents,cli,orchestration,coding-agent
|
|
6
|
+
Author: Eugene Kim
|
|
7
|
+
Author-email: Eugene Kim <eugkim0120@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
14
|
+
Requires-Dist: fastapi>=0.141.1
|
|
15
|
+
Requires-Dist: pydantic>=2.13.5
|
|
16
|
+
Requires-Dist: typer>=0.27.2
|
|
17
|
+
Requires-Dist: uvicorn[standard]>=0.53.0
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Homepage, https://github.com/eugkim0120/muvue
|
|
20
|
+
Project-URL: Repository, https://github.com/eugkim0120/muvue
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# muvue
|
|
24
|
+
|
|
25
|
+
muvue runs an AI coding agent's work as an auditable state machine instead of
|
|
26
|
+
a chat transcript: every task is a node in a SQLite-backed graph
|
|
27
|
+
(`pending -> ready -> in_progress -> review -> done`), every mutation is a
|
|
28
|
+
recorded event, and a two-gate approval flow (spec approval, then task
|
|
29
|
+
decomposition approval) sits between an agent's plan and it touching your
|
|
30
|
+
repo. An unattended runner can then drive approved work through pluggable
|
|
31
|
+
vendor-CLI drivers (`claude`, `codex`, `gemini`, or `fake` for testing),
|
|
32
|
+
enforcing per-driver budgets, risk-tiered auto-approval, and — in strict
|
|
33
|
+
mode — a worktree-per-node git airlock that blocks a `push` unless the
|
|
34
|
+
node's frozen acceptance criteria are met.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python >= 3.12
|
|
39
|
+
- [`uv`](https://docs.astral.sh/uv/)
|
|
40
|
+
- `git`
|
|
41
|
+
- `gh` CLI, authenticated, only if you use `muvue import --from github#N` or
|
|
42
|
+
`muvue merge --pr --create`
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
No clone needed — pick one:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install muvue # from PyPI
|
|
50
|
+
pipx install muvue # isolated, puts `muvue` on PATH
|
|
51
|
+
uvx muvue --help # try it without installing anything
|
|
52
|
+
uv tool install muvue # isolated, uv-managed
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Installing gives you two console scripts: `muvue` (the CLI) and
|
|
56
|
+
`muvue-fake-agent` (a scripted driver for testing, no vendor subscription
|
|
57
|
+
required).
|
|
58
|
+
|
|
59
|
+
### From source
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/eugkim0120/muvue && cd muvue
|
|
63
|
+
uv sync
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Run via `uv run muvue ...`, or `uv pip install -e .` into an active venv to
|
|
67
|
+
get `muvue` directly on `PATH`. Also installable straight from GitHub without
|
|
68
|
+
cloning: `pipx install git+https://github.com/eugkim0120/muvue`.
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
muvue operates on an external git repo — point it at one:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run muvue init /path/to/your/repo
|
|
76
|
+
uv run muvue doctor /path/to/your/repo
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`init` scaffolds `.muvue/` in that repo: `config.toml`, `muvue.db`, git hook
|
|
80
|
+
shims, and a `.gitignore` entry. `doctor` validates the install and, if no
|
|
81
|
+
daemon is already running, spins up a throwaway one against a scratch repo
|
|
82
|
+
to live-probe its security controls (loopback bind, Host/Origin checks) then
|
|
83
|
+
tears it down.
|
|
84
|
+
|
|
85
|
+
Walk a project through both gates and have the (fake, no-login-required)
|
|
86
|
+
driver execute it:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv run muvue project create --goal "add a health endpoint" --path /path/to/your/repo
|
|
90
|
+
# -> prints the new project's id
|
|
91
|
+
|
|
92
|
+
uv run muvue spec <project_id> --title "Health endpoint" \
|
|
93
|
+
--body "Add GET /health returning 200" --path /path/to/your/repo
|
|
94
|
+
# -> Gate 1: spec node, status=pending
|
|
95
|
+
|
|
96
|
+
uv run muvue approve spec:<spec_id> --path /path/to/your/repo
|
|
97
|
+
# -> spec node, status=ready
|
|
98
|
+
|
|
99
|
+
uv run muvue decompose <spec_id> --title "Implement /health" \
|
|
100
|
+
--criteria "GET /health returns 200" --path /path/to/your/repo
|
|
101
|
+
# -> Gate 2: task node, status=pending
|
|
102
|
+
|
|
103
|
+
uv run muvue approve gate2:<project_id> --path /path/to/your/repo
|
|
104
|
+
# -> freezes acceptance criteria, unblocks the task
|
|
105
|
+
|
|
106
|
+
uv run muvue run --path /path/to/your/repo
|
|
107
|
+
# -> unattended runner drives ready nodes through the routed driver
|
|
108
|
+
# (config.toml routes every kind to `fake` by default)
|
|
109
|
+
|
|
110
|
+
uv run muvue approve review:<node_id> --path /path/to/your/repo
|
|
111
|
+
uv run muvue close <project_id> --yes --path /path/to/your/repo
|
|
112
|
+
# -> commits the project's proposed structure diff, flips it to closed
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`uv run muvue uninit /path/to/your/repo` reverses everything `init` did —
|
|
116
|
+
config, db, hook shims, gitignore entry, and the `muvue/structure` git ref
|
|
117
|
+
if present — leaving the repo byte-for-byte as it was before.
|
|
118
|
+
|
|
119
|
+
## Other entry points
|
|
120
|
+
|
|
121
|
+
- `uv run muvue serve --path <repo>` — one daemon per repo: HTTP API +
|
|
122
|
+
SSE-driven dashboard at `http://127.0.0.1:8765` (loopback only).
|
|
123
|
+
- `uv run muvue mcp --path <repo>` — MCP stdio server exposing agent verbs
|
|
124
|
+
(`start`/`done`/`fail`/`ask`/...), for wiring muvue into an editor or
|
|
125
|
+
agent harness directly instead of driving it from the CLI.
|
|
126
|
+
- `uv run muvue audit --path <repo>` — structure-graph drift audit: samples
|
|
127
|
+
stale/unverified components, drafts proposed diffs into the inbox, and
|
|
128
|
+
archives lessons that have decayed past their retrieval threshold.
|
|
129
|
+
- `uv run muvue import --from github#<N> --path <repo>` / `muvue merge --pr
|
|
130
|
+
--create` — real `gh` CLI integration; needs `gh auth login` first.
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
`.muvue/config.toml`, written by `init` with working defaults. Key sections:
|
|
135
|
+
`mode` (`light` or `strict`), `[agents.<name>]` (driver command + budget +
|
|
136
|
+
cost unit), `[routing]` (node kind -> driver name), `[risk]` (globs and
|
|
137
|
+
diff-size thresholds that raise risk tier), `[budget]` (wall-clock and
|
|
138
|
+
node-count caps for `muvue run`).
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv run pytest # 537 tests
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
No network or vendor-CLI login is required for the test suite or the
|
|
147
|
+
quickstart above — everything routes through the `fake` driver unless you
|
|
148
|
+
edit `[routing]` yourself.
|
|
149
|
+
|
|
150
|
+
## Known limitations
|
|
151
|
+
|
|
152
|
+
- The `claude`/`codex`/`gemini` driver output parsers were built from
|
|
153
|
+
documented/synthetic samples of each vendor CLI's output format, not
|
|
154
|
+
verified against a live, subscription-authenticated run of that CLI.
|
|
155
|
+
- The VS Code extension (`vscode-extension/`) compiles and unit-tests
|
|
156
|
+
clean but has not been run inside a real VS Code Extension Host.
|
|
157
|
+
|
|
158
|
+
## Docs
|
|
159
|
+
|
|
160
|
+
- `docs/protocol.md` — node status machine, verb reference, event schema.
|
|
161
|
+
- `docs/decisions.md` — numbered decision log, append-only.
|
|
162
|
+
- `docs/threat-model.md` — daemon/API security model.
|
|
163
|
+
- `docs/providers.md` — vendor driver adapter contracts.
|
|
164
|
+
- `CHANGELOG.md`
|
muvue-0.1.0/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# muvue
|
|
2
|
+
|
|
3
|
+
muvue runs an AI coding agent's work as an auditable state machine instead of
|
|
4
|
+
a chat transcript: every task is a node in a SQLite-backed graph
|
|
5
|
+
(`pending -> ready -> in_progress -> review -> done`), every mutation is a
|
|
6
|
+
recorded event, and a two-gate approval flow (spec approval, then task
|
|
7
|
+
decomposition approval) sits between an agent's plan and it touching your
|
|
8
|
+
repo. An unattended runner can then drive approved work through pluggable
|
|
9
|
+
vendor-CLI drivers (`claude`, `codex`, `gemini`, or `fake` for testing),
|
|
10
|
+
enforcing per-driver budgets, risk-tiered auto-approval, and — in strict
|
|
11
|
+
mode — a worktree-per-node git airlock that blocks a `push` unless the
|
|
12
|
+
node's frozen acceptance criteria are met.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Python >= 3.12
|
|
17
|
+
- [`uv`](https://docs.astral.sh/uv/)
|
|
18
|
+
- `git`
|
|
19
|
+
- `gh` CLI, authenticated, only if you use `muvue import --from github#N` or
|
|
20
|
+
`muvue merge --pr --create`
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
No clone needed — pick one:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install muvue # from PyPI
|
|
28
|
+
pipx install muvue # isolated, puts `muvue` on PATH
|
|
29
|
+
uvx muvue --help # try it without installing anything
|
|
30
|
+
uv tool install muvue # isolated, uv-managed
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Installing gives you two console scripts: `muvue` (the CLI) and
|
|
34
|
+
`muvue-fake-agent` (a scripted driver for testing, no vendor subscription
|
|
35
|
+
required).
|
|
36
|
+
|
|
37
|
+
### From source
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/eugkim0120/muvue && cd muvue
|
|
41
|
+
uv sync
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run via `uv run muvue ...`, or `uv pip install -e .` into an active venv to
|
|
45
|
+
get `muvue` directly on `PATH`. Also installable straight from GitHub without
|
|
46
|
+
cloning: `pipx install git+https://github.com/eugkim0120/muvue`.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
muvue operates on an external git repo — point it at one:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run muvue init /path/to/your/repo
|
|
54
|
+
uv run muvue doctor /path/to/your/repo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`init` scaffolds `.muvue/` in that repo: `config.toml`, `muvue.db`, git hook
|
|
58
|
+
shims, and a `.gitignore` entry. `doctor` validates the install and, if no
|
|
59
|
+
daemon is already running, spins up a throwaway one against a scratch repo
|
|
60
|
+
to live-probe its security controls (loopback bind, Host/Origin checks) then
|
|
61
|
+
tears it down.
|
|
62
|
+
|
|
63
|
+
Walk a project through both gates and have the (fake, no-login-required)
|
|
64
|
+
driver execute it:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv run muvue project create --goal "add a health endpoint" --path /path/to/your/repo
|
|
68
|
+
# -> prints the new project's id
|
|
69
|
+
|
|
70
|
+
uv run muvue spec <project_id> --title "Health endpoint" \
|
|
71
|
+
--body "Add GET /health returning 200" --path /path/to/your/repo
|
|
72
|
+
# -> Gate 1: spec node, status=pending
|
|
73
|
+
|
|
74
|
+
uv run muvue approve spec:<spec_id> --path /path/to/your/repo
|
|
75
|
+
# -> spec node, status=ready
|
|
76
|
+
|
|
77
|
+
uv run muvue decompose <spec_id> --title "Implement /health" \
|
|
78
|
+
--criteria "GET /health returns 200" --path /path/to/your/repo
|
|
79
|
+
# -> Gate 2: task node, status=pending
|
|
80
|
+
|
|
81
|
+
uv run muvue approve gate2:<project_id> --path /path/to/your/repo
|
|
82
|
+
# -> freezes acceptance criteria, unblocks the task
|
|
83
|
+
|
|
84
|
+
uv run muvue run --path /path/to/your/repo
|
|
85
|
+
# -> unattended runner drives ready nodes through the routed driver
|
|
86
|
+
# (config.toml routes every kind to `fake` by default)
|
|
87
|
+
|
|
88
|
+
uv run muvue approve review:<node_id> --path /path/to/your/repo
|
|
89
|
+
uv run muvue close <project_id> --yes --path /path/to/your/repo
|
|
90
|
+
# -> commits the project's proposed structure diff, flips it to closed
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`uv run muvue uninit /path/to/your/repo` reverses everything `init` did —
|
|
94
|
+
config, db, hook shims, gitignore entry, and the `muvue/structure` git ref
|
|
95
|
+
if present — leaving the repo byte-for-byte as it was before.
|
|
96
|
+
|
|
97
|
+
## Other entry points
|
|
98
|
+
|
|
99
|
+
- `uv run muvue serve --path <repo>` — one daemon per repo: HTTP API +
|
|
100
|
+
SSE-driven dashboard at `http://127.0.0.1:8765` (loopback only).
|
|
101
|
+
- `uv run muvue mcp --path <repo>` — MCP stdio server exposing agent verbs
|
|
102
|
+
(`start`/`done`/`fail`/`ask`/...), for wiring muvue into an editor or
|
|
103
|
+
agent harness directly instead of driving it from the CLI.
|
|
104
|
+
- `uv run muvue audit --path <repo>` — structure-graph drift audit: samples
|
|
105
|
+
stale/unverified components, drafts proposed diffs into the inbox, and
|
|
106
|
+
archives lessons that have decayed past their retrieval threshold.
|
|
107
|
+
- `uv run muvue import --from github#<N> --path <repo>` / `muvue merge --pr
|
|
108
|
+
--create` — real `gh` CLI integration; needs `gh auth login` first.
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
`.muvue/config.toml`, written by `init` with working defaults. Key sections:
|
|
113
|
+
`mode` (`light` or `strict`), `[agents.<name>]` (driver command + budget +
|
|
114
|
+
cost unit), `[routing]` (node kind -> driver name), `[risk]` (globs and
|
|
115
|
+
diff-size thresholds that raise risk tier), `[budget]` (wall-clock and
|
|
116
|
+
node-count caps for `muvue run`).
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv run pytest # 537 tests
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
No network or vendor-CLI login is required for the test suite or the
|
|
125
|
+
quickstart above — everything routes through the `fake` driver unless you
|
|
126
|
+
edit `[routing]` yourself.
|
|
127
|
+
|
|
128
|
+
## Known limitations
|
|
129
|
+
|
|
130
|
+
- The `claude`/`codex`/`gemini` driver output parsers were built from
|
|
131
|
+
documented/synthetic samples of each vendor CLI's output format, not
|
|
132
|
+
verified against a live, subscription-authenticated run of that CLI.
|
|
133
|
+
- The VS Code extension (`vscode-extension/`) compiles and unit-tests
|
|
134
|
+
clean but has not been run inside a real VS Code Extension Host.
|
|
135
|
+
|
|
136
|
+
## Docs
|
|
137
|
+
|
|
138
|
+
- `docs/protocol.md` — node status machine, verb reference, event schema.
|
|
139
|
+
- `docs/decisions.md` — numbered decision log, append-only.
|
|
140
|
+
- `docs/threat-model.md` — daemon/API security model.
|
|
141
|
+
- `docs/providers.md` — vendor driver adapter contracts.
|
|
142
|
+
- `CHANGELOG.md`
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "muvue"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run an AI coding agent's work as an auditable, gated state machine instead of a chat transcript"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.141.1",
|
|
9
|
+
"pydantic>=2.13.5",
|
|
10
|
+
"typer>=0.27.2",
|
|
11
|
+
"uvicorn[standard]>=0.53.0",
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
keywords = [
|
|
15
|
+
"ai-agents",
|
|
16
|
+
"cli",
|
|
17
|
+
"orchestration",
|
|
18
|
+
"coding-agent",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Build Tools",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Eugene Kim"
|
|
30
|
+
email = "eugkim0120@gmail.com"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/eugkim0120/muvue"
|
|
34
|
+
Repository = "https://github.com/eugkim0120/muvue"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
muvue = "muvue.cli.main:main"
|
|
38
|
+
muvue-fake-agent = "muvue.fake_agent:main"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.12.6,<0.13.0"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"httpx>=0.28.1",
|
|
47
|
+
"pytest>=9.1.1",
|
|
48
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "muvue"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run an AI coding agent's work as an auditable, gated state machine instead of a chat transcript"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Eugene Kim", email = "eugkim0120@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.141.1",
|
|
12
|
+
"pydantic>=2.13.5",
|
|
13
|
+
"typer>=0.27.2",
|
|
14
|
+
"uvicorn[standard]>=0.53.0",
|
|
15
|
+
]
|
|
16
|
+
license = "MIT"
|
|
17
|
+
keywords = ["ai-agents", "cli", "orchestration", "coding-agent"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Software Development :: Build Tools",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/eugkim0120/muvue"
|
|
28
|
+
Repository = "https://github.com/eugkim0120/muvue"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
muvue = "muvue.cli.main:main"
|
|
32
|
+
muvue-fake-agent = "muvue.fake_agent:main"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.12.6,<0.13.0"]
|
|
36
|
+
build-backend = "uv_build"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"httpx>=0.28.1",
|
|
41
|
+
"pytest>=9.1.1",
|
|
42
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Stdlib-only hot-path entry point for git/agent hook shims (v4 handoff
|
|
2
|
+
plan section 4a: "the 50 ms budget was unachievable with Typer +
|
|
3
|
+
Pydantic on a cold interpreter, and PreToolUse fires per tool call").
|
|
4
|
+
|
|
5
|
+
Invoked as `<abs-python> -S -m muvue._hook NAME [PATH]` by the shim
|
|
6
|
+
files `core/repo_init.py` (git hooks) and `core/adapters.py` (the
|
|
7
|
+
Claude Code adapter's `.claude/settings.json` hook commands) write.
|
|
8
|
+
`-S` skips `site` module initialization; this module itself imports
|
|
9
|
+
**only** `sys`, `os`, `json`, `time` at module scope, and `sqlite3`
|
|
10
|
+
lazily inside the one path that needs it. It must never import
|
|
11
|
+
anything from `muvue` itself beyond the bare (empty) `muvue/__init__.py`
|
|
12
|
+
package init, and never `muvue.core` or any of its submodules --
|
|
13
|
+
`muvue/core/__init__.py` eagerly imports every core module including
|
|
14
|
+
`core/config.py`, which imports Typer/Pydantic transitively. See
|
|
15
|
+
`tests/test_hook_import_graph.py` (proves the transitive closure, not
|
|
16
|
+
just this file's own import statements) and docs/decisions.md #79.
|
|
17
|
+
|
|
18
|
+
Every hook event except `PreToolUse` does the minimum possible amount
|
|
19
|
+
of work: append one JSON line describing the event to
|
|
20
|
+
`.muvue/queue.jsonl` (gitignored, append-only spool) and exit. All the
|
|
21
|
+
real processing those events used to do synchronously (parsing commit
|
|
22
|
+
trailers, linking commits, blocking on unlogged in-progress work, ...)
|
|
23
|
+
now happens later, when something drains the queue --
|
|
24
|
+
`core.hooks.drain_queue`, a normal `muvue.core` module that runs
|
|
25
|
+
through `write_txn` as usual (working rule 3: the queue *append* here
|
|
26
|
+
is intentionally NOT a core DB write, by design, to stay off SQLite on
|
|
27
|
+
the hot path; the *drain* is a core write like any other mutation).
|
|
28
|
+
|
|
29
|
+
`PreToolUse` is the one exception (v4 section 4a): it must answer
|
|
30
|
+
allow/deny, so it is the only event permitted to read the DB. It opens
|
|
31
|
+
`.muvue/muvue.db` read-only, runs a single indexed query, and has a
|
|
32
|
+
hard 150ms wall-clock deadline after which it fails open (allows the
|
|
33
|
+
tool call) and spools a `hook_timeout` line so the miss is still
|
|
34
|
+
visible later (v4 principle 10: "detection everywhere else").
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
QUEUE_RELPATH = os.path.join(".muvue", "queue.jsonl")
|
|
45
|
+
CURRENT_NODE_RELPATH = os.path.join(".muvue", "current_node")
|
|
46
|
+
DB_RELPATH = os.path.join(".muvue", "muvue.db")
|
|
47
|
+
|
|
48
|
+
# v4 section 4a: "a hard 150 ms deadline after which it fails open".
|
|
49
|
+
PRE_TOOL_USE_DEADLINE_S = 0.150
|
|
50
|
+
|
|
51
|
+
# v4 section 7 / changelog item 10: `Bash` used to be here too, for the
|
|
52
|
+
# `git commit`-without-trailer string-match block -- REMOVED, not
|
|
53
|
+
# fixed, in favor of post-hoc `post-commit` detection (see
|
|
54
|
+
# `_decide`'s old Bash branch, now gone, and docs/decisions.md #100).
|
|
55
|
+
# With no decision logic left for `Bash`, it no longer needs the DB at
|
|
56
|
+
# all -- dropping it here (not just short-circuiting inside `_decide`)
|
|
57
|
+
# keeps every `Bash` PreToolUse call on the zero-DB-open fast path,
|
|
58
|
+
# same as Read/Grep/Glob/etc.
|
|
59
|
+
_BLOCKING_TOOL_NAMES = ("Edit", "Write")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now_iso() -> str:
|
|
63
|
+
"""`%Y-%m-%dT%H:%M:%S.mmmZ`, stdlib-`time`-only (no `datetime`) --
|
|
64
|
+
close enough to the rest of the codebase's timestamp convention
|
|
65
|
+
(`core.events`/`core.daemon` use sqlite's own millisecond-precision
|
|
66
|
+
`strftime`); this value is metadata inside the spooled line, not a
|
|
67
|
+
DB column, so an exact-format round-trip isn't load-bearing."""
|
|
68
|
+
t = time.time()
|
|
69
|
+
ms = int((t % 1) * 1000)
|
|
70
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(t)) + f".{ms:03d}Z"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_repo_root(start: str) -> str | None:
|
|
74
|
+
cur = os.path.abspath(start)
|
|
75
|
+
while True:
|
|
76
|
+
if os.path.isdir(os.path.join(cur, ".muvue")):
|
|
77
|
+
return cur
|
|
78
|
+
parent = os.path.dirname(cur)
|
|
79
|
+
if parent == cur:
|
|
80
|
+
return None
|
|
81
|
+
cur = parent
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _append_queue(repo_root: str, event: dict) -> None:
|
|
85
|
+
path = os.path.join(repo_root, QUEUE_RELPATH)
|
|
86
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
87
|
+
with open(path, "a") as f:
|
|
88
|
+
f.write(json.dumps(event, default=str) + "\n")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_current_node(repo_root: str) -> int | None:
|
|
92
|
+
"""Inlined equivalent of `core.adapters.get_current_node` -- not
|
|
93
|
+
imported from there, since even that (otherwise stdlib-only) module
|
|
94
|
+
lives under `muvue.core`, and `import muvue.core.anything` runs
|
|
95
|
+
`muvue/core/__init__.py`, which pulls in Typer/Pydantic. Duplicated
|
|
96
|
+
on purpose; see module docstring."""
|
|
97
|
+
path = os.path.join(repo_root, CURRENT_NODE_RELPATH)
|
|
98
|
+
try:
|
|
99
|
+
with open(path) as f:
|
|
100
|
+
return int(f.read().strip())
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read_stdin_json() -> dict:
|
|
106
|
+
try:
|
|
107
|
+
raw = sys.stdin.read()
|
|
108
|
+
except Exception:
|
|
109
|
+
return {}
|
|
110
|
+
if not raw.strip():
|
|
111
|
+
return {}
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(raw)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_head_sha(repo_root: str) -> str | None:
|
|
119
|
+
"""Resolve HEAD's commit sha by reading `.git` directly (no
|
|
120
|
+
`subprocess` -- not on this module's stdlib allow-list, and
|
|
121
|
+
spawning `git` would undo the whole point of the fast path).
|
|
122
|
+
Handles a loose ref and the `packed-refs` fallback; a detached HEAD
|
|
123
|
+
(`.git/HEAD` holding a raw sha, not a `ref: ...` line) is returned
|
|
124
|
+
as-is."""
|
|
125
|
+
git_dir_env = os.environ.get("GIT_DIR")
|
|
126
|
+
if git_dir_env:
|
|
127
|
+
git_dir = git_dir_env if os.path.isabs(git_dir_env) else os.path.join(repo_root, git_dir_env)
|
|
128
|
+
else:
|
|
129
|
+
git_dir = os.path.join(repo_root, ".git")
|
|
130
|
+
try:
|
|
131
|
+
with open(os.path.join(git_dir, "HEAD")) as f:
|
|
132
|
+
head = f.read().strip()
|
|
133
|
+
except OSError:
|
|
134
|
+
return None
|
|
135
|
+
if not head.startswith("ref:"):
|
|
136
|
+
return head or None
|
|
137
|
+
ref = head.split(" ", 1)[1].strip()
|
|
138
|
+
ref_path = os.path.join(git_dir, ref)
|
|
139
|
+
try:
|
|
140
|
+
with open(ref_path) as f:
|
|
141
|
+
sha = f.read().strip()
|
|
142
|
+
if sha:
|
|
143
|
+
return sha
|
|
144
|
+
except OSError:
|
|
145
|
+
pass
|
|
146
|
+
packed_path = os.path.join(git_dir, "packed-refs")
|
|
147
|
+
try:
|
|
148
|
+
with open(packed_path) as f:
|
|
149
|
+
for line in f:
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if line.endswith(" " + ref):
|
|
152
|
+
return line.split()[0]
|
|
153
|
+
except OSError:
|
|
154
|
+
return None
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def pre_tool_use(
|
|
159
|
+
repo_root: str,
|
|
160
|
+
payload: dict,
|
|
161
|
+
*,
|
|
162
|
+
clock=time.monotonic,
|
|
163
|
+
deadline_s: float = PRE_TOOL_USE_DEADLINE_S,
|
|
164
|
+
) -> dict:
|
|
165
|
+
"""The one DB-reading path (v4 section 4a). Mirrors
|
|
166
|
+
`core.claude_hooks.pre_tool_use`'s decision logic exactly (that
|
|
167
|
+
logic does not change, only its execution path does), against a
|
|
168
|
+
read-only connection, under a hard wall-clock deadline enforced by
|
|
169
|
+
an elapsed-time check after the query -- a pathologically slow
|
|
170
|
+
query still can't be preempted mid-flight, but it can't make this
|
|
171
|
+
process block past the deadline either: on overrun this fails open
|
|
172
|
+
and still records `hook_timeout` to the queue so the miss is
|
|
173
|
+
auditable."""
|
|
174
|
+
start = clock()
|
|
175
|
+
tool_name = payload.get("tool_name", "")
|
|
176
|
+
node_id = payload.get("node_id")
|
|
177
|
+
if node_id is None:
|
|
178
|
+
node_id = _get_current_node(repo_root)
|
|
179
|
+
|
|
180
|
+
if tool_name not in _BLOCKING_TOOL_NAMES:
|
|
181
|
+
# Most PreToolUse calls (Read/Grep/Glob/...) never need the DB
|
|
182
|
+
# at all -- keep them on the zero-import-cost path.
|
|
183
|
+
return {"decision": "allow"}
|
|
184
|
+
|
|
185
|
+
db_path = os.path.join(repo_root, DB_RELPATH)
|
|
186
|
+
if not os.path.exists(db_path):
|
|
187
|
+
return {"decision": "allow"}
|
|
188
|
+
|
|
189
|
+
import sqlite3 # lazy: only this branch pays for it (v4 section 4a)
|
|
190
|
+
|
|
191
|
+
conn = None
|
|
192
|
+
try:
|
|
193
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
194
|
+
conn.row_factory = sqlite3.Row
|
|
195
|
+
decision = _decide(conn, tool_name=tool_name, node_id=node_id)
|
|
196
|
+
finally:
|
|
197
|
+
if conn is not None:
|
|
198
|
+
conn.close()
|
|
199
|
+
|
|
200
|
+
if clock() - start > deadline_s:
|
|
201
|
+
_append_queue(
|
|
202
|
+
repo_root,
|
|
203
|
+
{"event": "hook_timeout", "ts": _now_iso(), "hook": "pre-tool-use", "node_id": node_id},
|
|
204
|
+
)
|
|
205
|
+
return {"decision": "allow"}
|
|
206
|
+
return decision
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _decide(conn, *, tool_name: str, node_id) -> dict:
|
|
210
|
+
# `tool_name` is already filtered to `_BLOCKING_TOOL_NAMES` (Edit/Write
|
|
211
|
+
# only, as of v4 section 7 -- see that tuple's docstring) by the one
|
|
212
|
+
# caller, `pre_tool_use`, before this ever runs.
|
|
213
|
+
if node_id is None:
|
|
214
|
+
return {
|
|
215
|
+
"decision": "block",
|
|
216
|
+
"reason": "no active muvue node -- run `muvue start NODE_ID --owner ...` first",
|
|
217
|
+
}
|
|
218
|
+
row = conn.execute(
|
|
219
|
+
"SELECT status FROM nodes WHERE id = ? AND deleted_at IS NULL", (node_id,)
|
|
220
|
+
).fetchone()
|
|
221
|
+
if row is None:
|
|
222
|
+
return {"decision": "block", "reason": f"no such muvue node: {node_id}"}
|
|
223
|
+
if row["status"] == "awaiting_approval":
|
|
224
|
+
return {"decision": "block", "reason": f"node {node_id} is awaiting_approval"}
|
|
225
|
+
if row["status"] != "in_progress":
|
|
226
|
+
return {
|
|
227
|
+
"decision": "block",
|
|
228
|
+
"reason": f"node {node_id} is {row['status']!r}, not in_progress",
|
|
229
|
+
}
|
|
230
|
+
return {"decision": "allow"}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main(argv: list[str] | None = None) -> int:
|
|
234
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
235
|
+
if not argv:
|
|
236
|
+
return 0
|
|
237
|
+
name = argv[0]
|
|
238
|
+
path = argv[1] if len(argv) > 1 else "."
|
|
239
|
+
repo_root = _find_repo_root(path)
|
|
240
|
+
if repo_root is None:
|
|
241
|
+
# No .muvue/ found -- nothing to spool against, nothing to
|
|
242
|
+
# decide. Fail open silently rather than raise: a hook shim
|
|
243
|
+
# left behind in a repo that later ran `muvue uninit` must not
|
|
244
|
+
# break every tool call.
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
if name == "pre-tool-use":
|
|
248
|
+
payload = _read_stdin_json()
|
|
249
|
+
result = pre_tool_use(repo_root, payload)
|
|
250
|
+
sys.stdout.write(json.dumps(result) + "\n")
|
|
251
|
+
return 2 if result.get("decision") == "block" else 0
|
|
252
|
+
|
|
253
|
+
if name == "post-commit":
|
|
254
|
+
sha = _read_head_sha(repo_root)
|
|
255
|
+
_append_queue(repo_root, {"event": "post-commit", "ts": _now_iso(), "sha": sha})
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
if name in ("session-start", "pre-compact", "stop"):
|
|
259
|
+
payload = _read_stdin_json()
|
|
260
|
+
node_id = payload.get("node_id")
|
|
261
|
+
if node_id is None:
|
|
262
|
+
node_id = _get_current_node(repo_root)
|
|
263
|
+
event = {"event": name, "ts": _now_iso(), "node_id": node_id}
|
|
264
|
+
if name == "pre-compact":
|
|
265
|
+
event["summary"] = payload.get("summary")
|
|
266
|
+
_append_queue(repo_root, event)
|
|
267
|
+
sys.stdout.write(json.dumps({"decision": "allow"}) + "\n")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
if name == "pre-push":
|
|
271
|
+
_append_queue(repo_root, {"event": "pre-push", "ts": _now_iso()})
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
# Unknown/unsupported event name (e.g. `pre-receive`, which runs
|
|
275
|
+
# server-side in a bare airlock repo with no `.muvue/` -- it is
|
|
276
|
+
# never reached via this branch, and stays on the full `muvue hook
|
|
277
|
+
# pre-receive` CLI path; see docs/decisions.md). Fail open.
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
sys.exit(main())
|