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.
Files changed (49) hide show
  1. muvue-0.1.0/PKG-INFO +164 -0
  2. muvue-0.1.0/README.md +142 -0
  3. muvue-0.1.0/pyproject.toml +48 -0
  4. muvue-0.1.0/pyproject.toml.orig +42 -0
  5. muvue-0.1.0/src/muvue/__init__.py +0 -0
  6. muvue-0.1.0/src/muvue/__main__.py +4 -0
  7. muvue-0.1.0/src/muvue/_hook.py +282 -0
  8. muvue-0.1.0/src/muvue/api/__init__.py +9 -0
  9. muvue-0.1.0/src/muvue/api/app.py +901 -0
  10. muvue-0.1.0/src/muvue/api/static/index.html +451 -0
  11. muvue-0.1.0/src/muvue/cli/__init__.py +0 -0
  12. muvue-0.1.0/src/muvue/cli/main.py +1027 -0
  13. muvue-0.1.0/src/muvue/core/__init__.py +77 -0
  14. muvue-0.1.0/src/muvue/core/adapters.py +196 -0
  15. muvue-0.1.0/src/muvue/core/asks.py +179 -0
  16. muvue-0.1.0/src/muvue/core/claude_hooks.py +111 -0
  17. muvue-0.1.0/src/muvue/core/close.py +447 -0
  18. muvue-0.1.0/src/muvue/core/config.py +200 -0
  19. muvue-0.1.0/src/muvue/core/daemon.py +160 -0
  20. muvue-0.1.0/src/muvue/core/db.py +120 -0
  21. muvue-0.1.0/src/muvue/core/doctor.py +436 -0
  22. muvue-0.1.0/src/muvue/core/drift.py +399 -0
  23. muvue-0.1.0/src/muvue/core/drivers.py +254 -0
  24. muvue-0.1.0/src/muvue/core/events.py +84 -0
  25. muvue-0.1.0/src/muvue/core/gates.py +276 -0
  26. muvue-0.1.0/src/muvue/core/github.py +64 -0
  27. muvue-0.1.0/src/muvue/core/gitutil.py +36 -0
  28. muvue-0.1.0/src/muvue/core/history.py +65 -0
  29. muvue-0.1.0/src/muvue/core/hooks.py +273 -0
  30. muvue-0.1.0/src/muvue/core/imports.py +90 -0
  31. muvue-0.1.0/src/muvue/core/merge.py +247 -0
  32. muvue-0.1.0/src/muvue/core/migrate.py +59 -0
  33. muvue-0.1.0/src/muvue/core/nodes.py +792 -0
  34. muvue-0.1.0/src/muvue/core/pr.py +67 -0
  35. muvue-0.1.0/src/muvue/core/projects.py +95 -0
  36. muvue-0.1.0/src/muvue/core/queries.py +150 -0
  37. muvue-0.1.0/src/muvue/core/rebuild.py +224 -0
  38. muvue-0.1.0/src/muvue/core/repo_init.py +258 -0
  39. muvue-0.1.0/src/muvue/core/review.py +171 -0
  40. muvue-0.1.0/src/muvue/core/revisions.py +174 -0
  41. muvue-0.1.0/src/muvue/core/risk.py +151 -0
  42. muvue-0.1.0/src/muvue/core/runner.py +692 -0
  43. muvue-0.1.0/src/muvue/core/schema.py +314 -0
  44. muvue-0.1.0/src/muvue/core/spend.py +73 -0
  45. muvue-0.1.0/src/muvue/core/state_machine.py +93 -0
  46. muvue-0.1.0/src/muvue/core/strict.py +266 -0
  47. muvue-0.1.0/src/muvue/core/trailers.py +47 -0
  48. muvue-0.1.0/src/muvue/fake_agent.py +159 -0
  49. 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,4 @@
1
+ from muvue.cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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())