flanner 0.4.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 (59) hide show
  1. flanner-0.4.0/LICENSE +21 -0
  2. flanner-0.4.0/PKG-INFO +189 -0
  3. flanner-0.4.0/README.md +139 -0
  4. flanner-0.4.0/flanner/__init__.py +7 -0
  5. flanner-0.4.0/flanner/__main__.py +8 -0
  6. flanner-0.4.0/flanner/agent_hooks.py +226 -0
  7. flanner-0.4.0/flanner/claude_integration.py +386 -0
  8. flanner-0.4.0/flanner/cli.py +1283 -0
  9. flanner-0.4.0/flanner/database.py +946 -0
  10. flanner-0.4.0/flanner/exceptions.py +51 -0
  11. flanner-0.4.0/flanner/frontmatter.py +213 -0
  12. flanner-0.4.0/flanner/git_integration.py +321 -0
  13. flanner-0.4.0/flanner/jira_utils.py +138 -0
  14. flanner-0.4.0/flanner/plan_ops.py +68 -0
  15. flanner-0.4.0/flanner/server.py +981 -0
  16. flanner-0.4.0/flanner/storage.py +242 -0
  17. flanner-0.4.0/flanner/utils.py +225 -0
  18. flanner-0.4.0/flanner/web/static/css/styles.css +843 -0
  19. flanner-0.4.0/flanner/web/static/js/app.js +145 -0
  20. flanner-0.4.0/flanner/web/templates/_empty_glyph.html +12 -0
  21. flanner-0.4.0/flanner/web/templates/base.html +66 -0
  22. flanner-0.4.0/flanner/web/templates/dashboard.html +109 -0
  23. flanner-0.4.0/flanner/web/templates/error.html +13 -0
  24. flanner-0.4.0/flanner/web/templates/plan_edit.html +85 -0
  25. flanner-0.4.0/flanner/web/templates/plan_history.html +110 -0
  26. flanner-0.4.0/flanner/web/templates/plan_new.html +92 -0
  27. flanner-0.4.0/flanner/web/templates/plan_view.html +88 -0
  28. flanner-0.4.0/flanner/web/templates/project_detail.html +109 -0
  29. flanner-0.4.0/flanner/web/templates/project_new.html +93 -0
  30. flanner-0.4.0/flanner/web/templates/projects.html +72 -0
  31. flanner-0.4.0/flanner/web.py +788 -0
  32. flanner-0.4.0/flanner.egg-info/PKG-INFO +189 -0
  33. flanner-0.4.0/flanner.egg-info/SOURCES.txt +57 -0
  34. flanner-0.4.0/flanner.egg-info/dependency_links.txt +1 -0
  35. flanner-0.4.0/flanner.egg-info/entry_points.txt +2 -0
  36. flanner-0.4.0/flanner.egg-info/requires.txt +24 -0
  37. flanner-0.4.0/flanner.egg-info/top_level.txt +1 -0
  38. flanner-0.4.0/pyproject.toml +86 -0
  39. flanner-0.4.0/setup.cfg +4 -0
  40. flanner-0.4.0/tests/test_agent_hooks.py +241 -0
  41. flanner-0.4.0/tests/test_architecture.py +59 -0
  42. flanner-0.4.0/tests/test_claude_integration.py +25 -0
  43. flanner-0.4.0/tests/test_claude_integration_registry.py +185 -0
  44. flanner-0.4.0/tests/test_cli.py +703 -0
  45. flanner-0.4.0/tests/test_database.py +40 -0
  46. flanner-0.4.0/tests/test_frontmatter.py +121 -0
  47. flanner-0.4.0/tests/test_git_integration.py +155 -0
  48. flanner-0.4.0/tests/test_jira_utils.py +54 -0
  49. flanner-0.4.0/tests/test_mcp_stdio.py +76 -0
  50. flanner-0.4.0/tests/test_migrations.py +68 -0
  51. flanner-0.4.0/tests/test_plan_ops.py +62 -0
  52. flanner-0.4.0/tests/test_scale.py +167 -0
  53. flanner-0.4.0/tests/test_server.py +53 -0
  54. flanner-0.4.0/tests/test_server_tools.py +359 -0
  55. flanner-0.4.0/tests/test_states.py +83 -0
  56. flanner-0.4.0/tests/test_storage.py +104 -0
  57. flanner-0.4.0/tests/test_utils.py +100 -0
  58. flanner-0.4.0/tests/test_web.py +32 -0
  59. flanner-0.4.0/tests/test_web_routes.py +225 -0
flanner-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jayson Mulwa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
flanner-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: flanner
3
+ Version: 0.4.0
4
+ Summary: Plan file management for AI assistants via MCP
5
+ Author-email: Jayson Mulwa <jayson.mulwa@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://flanner.io
8
+ Project-URL: Repository, https://github.com/jaysonmulwa/flanner
9
+ Project-URL: Changelog, https://github.com/jaysonmulwa/flanner/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/jaysonmulwa/flanner/issues
11
+ Keywords: mcp,claude,codex,ai-agents,plan-files,markdown,cli,planning
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Environment :: Console
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Documentation
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: click<9,>=8.1
27
+ Requires-Dist: rich<16,>=13.0
28
+ Requires-Dist: sqlalchemy<3,>=2.0
29
+ Requires-Dist: python-frontmatter<2,>=1.0
30
+ Requires-Dist: pyyaml<7,>=6.0
31
+ Requires-Dist: fastapi<1,>=0.104
32
+ Requires-Dist: uvicorn<1,>=0.24
33
+ Requires-Dist: jinja2<4,>=3.1
34
+ Requires-Dist: python-multipart<0.1,>=0.0.6
35
+ Requires-Dist: markdown<4,>=3.5
36
+ Requires-Dist: pygments<3,>=2.17
37
+ Requires-Dist: pydantic<3,>=2.5
38
+ Requires-Dist: mcp<2,>=1.0
39
+ Provides-Extra: dev
40
+ Requires-Dist: pytest<10,>=9.0.3; extra == "dev"
41
+ Requires-Dist: pytest-cov<9,>=6.0; extra == "dev"
42
+ Requires-Dist: httpx<1,>=0.25; extra == "dev"
43
+ Requires-Dist: ruff>=0.8; extra == "dev"
44
+ Requires-Dist: mypy<2,>=1.13; extra == "dev"
45
+ Requires-Dist: pip-audit<3,>=2.7; extra == "dev"
46
+ Requires-Dist: pre-commit>=3.5; extra == "dev"
47
+ Requires-Dist: types-PyYAML; extra == "dev"
48
+ Requires-Dist: types-Markdown; extra == "dev"
49
+ Dynamic: license-file
50
+
51
+ # Flanner
52
+
53
+ [![CI](https://github.com/jaysonmulwa/flanner/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jaysonmulwa/flanner/actions/workflows/ci.yml)
54
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
55
+
56
+ A plan-file manager for AI coding agents, wired into Claude Code and other assistants over MCP (Model Context Protocol).
57
+
58
+ AI agents write markdown constantly: design docs, migration plans, architecture notes. It piles up fast, scattered across your repo, quietly going stale, and easy to commit by accident. Flanner gives those files one home, versions them automatically as the agent revises, and keeps them out of git until you decide otherwise, with a browsable reading view and an audit trail on top.
59
+
60
+ Today Flanner is local-first; the goal is cloud-hosted plans: shared workspaces for easier collaboration, effectively unlimited storage and history, and clean links to the tools teams already work in, from product trackers and chat to second brains like Notion.
61
+
62
+ <div align="center">
63
+ <img src="docs/assets/plan-view.png" alt="flanner reading view: a versioned plan file" width="840">
64
+ </div>
65
+
66
+ ## Features
67
+
68
+ - **MCP integration**: exposes plan-file tools to Claude Code and Codex.
69
+ - **Automatic headers and versioning**: every plan gets YAML frontmatter, and each revision is a new version with a full history.
70
+ - **Git protection**: plans live in `.plans/` and are kept out of commits automatically.
71
+ - **Agent integration**: `flanner init` wires CLAUDE.md, AGENTS.md, and a guard hook so agents save plans through flanner instead of scattering raw markdown.
72
+ - **Reading view**: a browser dashboard to read, edit, and walk the history of plans (light and dark, fully offline).
73
+ - **Per-project config**: customize the plan directory per repository.
74
+
75
+ ## Quick start
76
+
77
+ ```bash
78
+ cd flanner
79
+ pip install -e . # provides the `flanner` command
80
+ flanner init # database, MCP registration, and a project for this repo
81
+ ```
82
+
83
+ Then ask your agent to work with plans:
84
+
85
+ > "Create an architecture plan for the auth service"
86
+ >
87
+ > "Show me the history of the architecture plan"
88
+
89
+ And open the dashboard to browse them:
90
+
91
+ ```bash
92
+ flanner web --open-browser # http://localhost:8080
93
+ ```
94
+
95
+ `flanner init` is safe to re-run. It detects your git root, creates `.plans/`, updates `.gitignore`, registers the MCP server with Claude Code, and installs the agent integration.
96
+
97
+ ## CLI commands
98
+
99
+ ```bash
100
+ flanner init [--project-root PATH] [--plan-dir DIR] # set up a project
101
+ flanner status # projects, plan files, db path
102
+ flanner list [--project NAME] [--output json] # list projects or a project's plans
103
+ flanner sync [--project NAME] [--dry-run] # import existing .plans/ files
104
+ flanner config NAME [--plan-dir DIR] [...] # change project settings
105
+ flanner web [--port 8080] [--host 127.0.0.1] [--open-browser]
106
+ flanner register [--force] / flanner unregister # MCP registration with Claude Code
107
+ flanner claude-info # integration status
108
+ ```
109
+
110
+ <details>
111
+ <summary><b>Plan file format</b></summary>
112
+
113
+ Every managed plan carries YAML frontmatter, generated by the tools and never hand-written:
114
+
115
+ ```markdown
116
+ ---
117
+ mcp_plan_file: true
118
+ project_id: 3d816ecd-489a-4fa0-abe2-15ec93f60d5a
119
+ plan_file_id: 59c34f9c-8471-47fc-97f2-8dcfefa15434
120
+ plan_name: architecture
121
+ version: 2
122
+ created_by: claude
123
+ ---
124
+
125
+ # Architecture Plan
126
+
127
+ Your plan content here...
128
+ ```
129
+
130
+ </details>
131
+
132
+ <details>
133
+ <summary><b>Web interface</b></summary>
134
+
135
+ ![flanner dashboard](docs/assets/dashboard.png)
136
+
137
+ A server-rendered dashboard, no build step, works offline:
138
+
139
+ - Dashboard (`/`): projects, stats, and recent activity
140
+ - Project detail (`/projects/{id}`): a project's plans, paginated
141
+ - Plan viewer (`/plans/{id}`): rendered markdown, version selector, frontmatter
142
+ - Editor (`/plans/{id}/edit`) and version history (`/plans/{id}/history`)
143
+
144
+ The web UI binds `127.0.0.1` with no authentication. Do not expose it beyond localhost.
145
+
146
+ </details>
147
+
148
+ <details>
149
+ <summary><b>Where data lives</b></summary>
150
+
151
+ - Catalog (SQLite): `~/.flanner/data.db`, override with `FLANNER_HOME` or `FLANNER_DB_PATH`
152
+ - Plan files: `.plans/` in your repo, git-ignored, named `name_v1.md`, `name_v2.md`, and so on
153
+
154
+ </details>
155
+
156
+ <details>
157
+ <summary><b>Architecture</b></summary>
158
+
159
+ Layering is enforced by `tests/test_architecture.py`:
160
+
161
+ - **foundation** (`exceptions`, `utils`, `frontmatter`, `git_integration`, `jira_utils`) imports nothing else from the package
162
+ - **data** (`database`, `storage`) sits on the foundation only
163
+ - **composition roots** (`server` for MCP, `web`, `cli`) wire everything together and do not import each other (except `cli`, which launches both)
164
+
165
+ Decisions are recorded in [docs/adr/](docs/adr/), with more guides in [docs/](docs/).
166
+
167
+ </details>
168
+
169
+ ## How it works
170
+
171
+ An agent calls `get_plan_config` to learn where plans go, then `create_plan_file_tool` or `update_plan_file_tool` to write them. Flanner places the file in the project's plan directory, adds the header, and bumps the version. Files stay in `.plans/` (git-ignored), so they never land in a commit by accident.
172
+
173
+ ## Roadmap
174
+
175
+ Flanner is local-first today. Planned next:
176
+
177
+ - Cloud-hosted plans: a PostgreSQL catalog and S3-backed storage for effectively unlimited history
178
+ - Shared workspaces for team collaboration
179
+ - Full-text search across plans
180
+ - Links out to product trackers, chat, and second brains like Notion
181
+ - Real-time updates in the web UI
182
+
183
+ ## Contributing
184
+
185
+ Setup, the CI gates, benchmarks, and the release process are in [CONTRIBUTING.md](CONTRIBUTING.md).
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,139 @@
1
+ # Flanner
2
+
3
+ [![CI](https://github.com/jaysonmulwa/flanner/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jaysonmulwa/flanner/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ A plan-file manager for AI coding agents, wired into Claude Code and other assistants over MCP (Model Context Protocol).
7
+
8
+ AI agents write markdown constantly: design docs, migration plans, architecture notes. It piles up fast, scattered across your repo, quietly going stale, and easy to commit by accident. Flanner gives those files one home, versions them automatically as the agent revises, and keeps them out of git until you decide otherwise, with a browsable reading view and an audit trail on top.
9
+
10
+ Today Flanner is local-first; the goal is cloud-hosted plans: shared workspaces for easier collaboration, effectively unlimited storage and history, and clean links to the tools teams already work in, from product trackers and chat to second brains like Notion.
11
+
12
+ <div align="center">
13
+ <img src="docs/assets/plan-view.png" alt="flanner reading view: a versioned plan file" width="840">
14
+ </div>
15
+
16
+ ## Features
17
+
18
+ - **MCP integration**: exposes plan-file tools to Claude Code and Codex.
19
+ - **Automatic headers and versioning**: every plan gets YAML frontmatter, and each revision is a new version with a full history.
20
+ - **Git protection**: plans live in `.plans/` and are kept out of commits automatically.
21
+ - **Agent integration**: `flanner init` wires CLAUDE.md, AGENTS.md, and a guard hook so agents save plans through flanner instead of scattering raw markdown.
22
+ - **Reading view**: a browser dashboard to read, edit, and walk the history of plans (light and dark, fully offline).
23
+ - **Per-project config**: customize the plan directory per repository.
24
+
25
+ ## Quick start
26
+
27
+ ```bash
28
+ cd flanner
29
+ pip install -e . # provides the `flanner` command
30
+ flanner init # database, MCP registration, and a project for this repo
31
+ ```
32
+
33
+ Then ask your agent to work with plans:
34
+
35
+ > "Create an architecture plan for the auth service"
36
+ >
37
+ > "Show me the history of the architecture plan"
38
+
39
+ And open the dashboard to browse them:
40
+
41
+ ```bash
42
+ flanner web --open-browser # http://localhost:8080
43
+ ```
44
+
45
+ `flanner init` is safe to re-run. It detects your git root, creates `.plans/`, updates `.gitignore`, registers the MCP server with Claude Code, and installs the agent integration.
46
+
47
+ ## CLI commands
48
+
49
+ ```bash
50
+ flanner init [--project-root PATH] [--plan-dir DIR] # set up a project
51
+ flanner status # projects, plan files, db path
52
+ flanner list [--project NAME] [--output json] # list projects or a project's plans
53
+ flanner sync [--project NAME] [--dry-run] # import existing .plans/ files
54
+ flanner config NAME [--plan-dir DIR] [...] # change project settings
55
+ flanner web [--port 8080] [--host 127.0.0.1] [--open-browser]
56
+ flanner register [--force] / flanner unregister # MCP registration with Claude Code
57
+ flanner claude-info # integration status
58
+ ```
59
+
60
+ <details>
61
+ <summary><b>Plan file format</b></summary>
62
+
63
+ Every managed plan carries YAML frontmatter, generated by the tools and never hand-written:
64
+
65
+ ```markdown
66
+ ---
67
+ mcp_plan_file: true
68
+ project_id: 3d816ecd-489a-4fa0-abe2-15ec93f60d5a
69
+ plan_file_id: 59c34f9c-8471-47fc-97f2-8dcfefa15434
70
+ plan_name: architecture
71
+ version: 2
72
+ created_by: claude
73
+ ---
74
+
75
+ # Architecture Plan
76
+
77
+ Your plan content here...
78
+ ```
79
+
80
+ </details>
81
+
82
+ <details>
83
+ <summary><b>Web interface</b></summary>
84
+
85
+ ![flanner dashboard](docs/assets/dashboard.png)
86
+
87
+ A server-rendered dashboard, no build step, works offline:
88
+
89
+ - Dashboard (`/`): projects, stats, and recent activity
90
+ - Project detail (`/projects/{id}`): a project's plans, paginated
91
+ - Plan viewer (`/plans/{id}`): rendered markdown, version selector, frontmatter
92
+ - Editor (`/plans/{id}/edit`) and version history (`/plans/{id}/history`)
93
+
94
+ The web UI binds `127.0.0.1` with no authentication. Do not expose it beyond localhost.
95
+
96
+ </details>
97
+
98
+ <details>
99
+ <summary><b>Where data lives</b></summary>
100
+
101
+ - Catalog (SQLite): `~/.flanner/data.db`, override with `FLANNER_HOME` or `FLANNER_DB_PATH`
102
+ - Plan files: `.plans/` in your repo, git-ignored, named `name_v1.md`, `name_v2.md`, and so on
103
+
104
+ </details>
105
+
106
+ <details>
107
+ <summary><b>Architecture</b></summary>
108
+
109
+ Layering is enforced by `tests/test_architecture.py`:
110
+
111
+ - **foundation** (`exceptions`, `utils`, `frontmatter`, `git_integration`, `jira_utils`) imports nothing else from the package
112
+ - **data** (`database`, `storage`) sits on the foundation only
113
+ - **composition roots** (`server` for MCP, `web`, `cli`) wire everything together and do not import each other (except `cli`, which launches both)
114
+
115
+ Decisions are recorded in [docs/adr/](docs/adr/), with more guides in [docs/](docs/).
116
+
117
+ </details>
118
+
119
+ ## How it works
120
+
121
+ An agent calls `get_plan_config` to learn where plans go, then `create_plan_file_tool` or `update_plan_file_tool` to write them. Flanner places the file in the project's plan directory, adds the header, and bumps the version. Files stay in `.plans/` (git-ignored), so they never land in a commit by accident.
122
+
123
+ ## Roadmap
124
+
125
+ Flanner is local-first today. Planned next:
126
+
127
+ - Cloud-hosted plans: a PostgreSQL catalog and S3-backed storage for effectively unlimited history
128
+ - Shared workspaces for team collaboration
129
+ - Full-text search across plans
130
+ - Links out to product trackers, chat, and second brains like Notion
131
+ - Real-time updates in the web UI
132
+
133
+ ## Contributing
134
+
135
+ Setup, the CI gates, benchmarks, and the release process are in [CONTRIBUTING.md](CONTRIBUTING.md).
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,7 @@
1
+ """
2
+ MCP Plan File Manager
3
+
4
+ A comprehensive plan file management system for Claude Code and AI assistants.
5
+ """
6
+
7
+ __version__ = "0.4.0"
@@ -0,0 +1,8 @@
1
+ """
2
+ Main entry point for running the CLI as a module
3
+ """
4
+
5
+ from .cli import cli
6
+
7
+ if __name__ == "__main__":
8
+ cli()
@@ -0,0 +1,226 @@
1
+ """Agent integration: the guard-write hook and init-time wiring.
2
+
3
+ Two cooperating layers steer coding agents toward the flanner MCP tools:
4
+
5
+ - a managed block in the repo's CLAUDE.md and AGENTS.md, plus a skill (soft:
6
+ guidance; AGENTS.md is the cross-tool convention Codex and others read)
7
+ - a PreToolUse hook that denies raw Writes into the plan directory (hard:
8
+ enforcement, Claude Code only). The MCP tools write through flanner's
9
+ storage layer, not the agent's Write tool, so the correct path is never
10
+ blocked.
11
+
12
+ `decide_write` is pure and takes a parsed payload + session so it can be
13
+ tested without stdin or a live hook.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from sqlalchemy.orm import Session
23
+
24
+ from .database import ProjectModel, get_project_by_root
25
+ from .git_integration import find_git_root
26
+
27
+ AGENT_MD_START = "<!-- flanner:managed -->"
28
+ AGENT_MD_END = "<!-- /flanner:managed -->"
29
+
30
+ # The hook entry flanner merges into a repo's .claude/settings.json.
31
+ HOOK_COMMAND = "flanner hook guard-write"
32
+ HOOK_MATCHER = "Write|Edit|MultiEdit"
33
+
34
+
35
+ def _resolve(file_path: str, cwd: str) -> Path:
36
+ """Absolute, normalized target path (file_path may be relative to cwd)."""
37
+ p = Path(file_path)
38
+ if not p.is_absolute():
39
+ p = Path(cwd) / p
40
+ return p.resolve()
41
+
42
+
43
+ def decide_write(payload: dict[str, Any], session: Session) -> dict[str, Any] | None:
44
+ """Return a PreToolUse deny decision, or None to allow the write.
45
+
46
+ Allows (returns None) unless all three gates match: the repo is
47
+ flanner-managed, the target is a .md, and it sits inside that project's
48
+ plan directory. Any missing field or lookup miss allows the write.
49
+ """
50
+ tool_input = payload.get("tool_input") or {}
51
+ file_path = tool_input.get("file_path")
52
+ cwd = payload.get("cwd")
53
+ if not file_path or not cwd:
54
+ return None
55
+
56
+ target = _resolve(file_path, cwd)
57
+
58
+ root = find_git_root(str(target.parent)) or find_git_root(cwd)
59
+ if not root:
60
+ return None
61
+
62
+ project = get_project_by_root(session, root)
63
+ if not project or not project.project_root:
64
+ return None # gate 1: not a flanner repo
65
+
66
+ plan_dir = (Path(project.project_root) / project.plan_directory).resolve()
67
+ if target.suffix != ".md" or not target.is_relative_to(plan_dir):
68
+ return None # gate 2: not a plan file
69
+
70
+ return _deny(_steer_message(project, target)) # gate 3: wrong tool for a plan
71
+
72
+
73
+ def _steer_message(project: ProjectModel, target: Path) -> str:
74
+ return (
75
+ f"{target.name} is a flanner-managed plan file (project '{project.name}', "
76
+ f"dir '{project.plan_directory}'). Don't write it directly; the plan header "
77
+ f"and versioning are added by the flanner MCP tools. To create it, call "
78
+ f"create_plan_file_tool(project_id='{project.id}', name='{target.stem}', "
79
+ f"content=<body without frontmatter>). To revise an existing plan, use "
80
+ f"update_plan_file_tool(plan_file_id=...)."
81
+ )
82
+
83
+
84
+ def _deny(reason: str) -> dict[str, Any]:
85
+ return {
86
+ "hookSpecificOutput": {
87
+ "hookEventName": "PreToolUse",
88
+ "permissionDecision": "deny",
89
+ "permissionDecisionReason": reason,
90
+ }
91
+ }
92
+
93
+
94
+ def run_guard_write(raw_stdin: str, session: Session) -> str:
95
+ """Read a PreToolUse payload, return the JSON to print (empty = allow).
96
+
97
+ Fails open: any error yields an allow, so a broken guard never blocks
98
+ a legitimate write.
99
+ """
100
+ try:
101
+ payload = json.loads(raw_stdin) if raw_stdin.strip() else {}
102
+ decision = decide_write(payload, session)
103
+ except Exception:
104
+ return ""
105
+ return json.dumps(decision) if decision else ""
106
+
107
+
108
+ # Agent-instruction files flanner writes the managed block into. CLAUDE.md is
109
+ # read by Claude Code; AGENTS.md is the cross-tool convention read by Codex and
110
+ # others. The block is tool-agnostic (it points at the MCP tools), so both get
111
+ # the same guidance.
112
+ AGENT_MD_FILES = ("CLAUDE.md", "AGENTS.md")
113
+
114
+
115
+ def agent_md_block(project: ProjectModel) -> str:
116
+ """The managed section naming the plan dir and the tools to use."""
117
+ return (
118
+ f"{AGENT_MD_START}\n"
119
+ f"## Plan files (managed by flanner)\n\n"
120
+ f"Design, architecture, and planning markdown for this repo is managed by "
121
+ f"flanner and lives in `{project.plan_directory}/` (project: {project.name}).\n\n"
122
+ f"When the user asks to save a plan/design/architecture doc, confirm it is a "
123
+ f"plan, then use the flanner MCP tools instead of writing the file directly:\n\n"
124
+ f"- `get_plan_config` to confirm the location and header format\n"
125
+ f"- `create_plan_file_tool(project_id, name, content)` to create it "
126
+ f"(adds the YAML header and versions it)\n"
127
+ f"- `update_plan_file_tool(plan_file_id, content)` to revise it\n\n"
128
+ f"Never hand-write the YAML header; the tools generate it.\n"
129
+ f"{AGENT_MD_END}"
130
+ )
131
+
132
+
133
+ def upsert_agent_md(root: str, filename: str, block: str) -> bool:
134
+ """Write or replace the managed block in <root>/<filename>. Returns True if changed."""
135
+ path = Path(root) / filename
136
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
137
+
138
+ if AGENT_MD_START in existing and AGENT_MD_END in existing:
139
+ head, _, rest = existing.partition(AGENT_MD_START)
140
+ _, _, tail = rest.partition(AGENT_MD_END)
141
+ updated = f"{head.rstrip()}\n\n{block}\n{tail.lstrip()}".strip() + "\n"
142
+ elif existing.strip():
143
+ updated = existing.rstrip() + "\n\n" + block + "\n"
144
+ else:
145
+ updated = block + "\n"
146
+
147
+ if updated == existing:
148
+ return False
149
+ path.write_text(updated, encoding="utf-8")
150
+ return True
151
+
152
+
153
+ def ensure_settings_hook(root: str) -> bool:
154
+ """Merge the guard-write PreToolUse hook into <root>/.claude/settings.json.
155
+
156
+ Idempotent; returns True if the file was changed.
157
+ """
158
+ settings_path = Path(root) / ".claude" / "settings.json"
159
+ settings: dict[str, Any] = {}
160
+ if settings_path.exists():
161
+ try:
162
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
163
+ except json.JSONDecodeError:
164
+ settings = {}
165
+
166
+ hooks = settings.setdefault("hooks", {})
167
+ pre = hooks.setdefault("PreToolUse", [])
168
+
169
+ already = any(
170
+ h.get("type") == "command" and h.get("command") == HOOK_COMMAND
171
+ for entry in pre
172
+ if isinstance(entry, dict)
173
+ for h in entry.get("hooks", [])
174
+ )
175
+ if already:
176
+ return False
177
+
178
+ pre.append(
179
+ {
180
+ "matcher": HOOK_MATCHER,
181
+ "hooks": [{"type": "command", "command": HOOK_COMMAND}],
182
+ }
183
+ )
184
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
185
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
186
+ return True
187
+
188
+
189
+ SKILL_NAME = "flanner-plan"
190
+
191
+ _SKILL_BODY = """---
192
+ name: flanner-plan
193
+ description: >
194
+ Save or update a plan, design, architecture, migration, or RFC document
195
+ through flanner so it is placed in the managed plan directory, given the
196
+ standard YAML header, and versioned. Use when the user asks to write or
197
+ revise any planning markdown that should be tracked, or when you are about
198
+ to create such a document yourself.
199
+ ---
200
+
201
+ # Saving a plan through flanner
202
+
203
+ This repo manages planning docs with flanner. Do not write them with the
204
+ Write tool; the plan directory and header are handled by the MCP tools.
205
+
206
+ 1. Confirm intent. If it is ambiguous whether a markdown file is a plan
207
+ (versus a README, changelog, or notes), ask the user before proceeding.
208
+ 2. Resolve the target with `get_plan_config` and `list_projects`.
209
+ 3. Create with `create_plan_file_tool(project_id, name, content)`, passing the
210
+ markdown body WITHOUT frontmatter; the header is added for you.
211
+ 4. Revise an existing plan with `update_plan_file_tool(plan_file_id, content)`,
212
+ which bumps the version and re-hashes the content.
213
+
214
+ Never hand-write the YAML header, and never place plan files outside the
215
+ directory reported by `get_plan_config`.
216
+ """
217
+
218
+
219
+ def install_skill(root: str) -> bool:
220
+ """Write the flanner-plan skill into <root>/.claude/skills. Returns True if changed."""
221
+ skill_path = Path(root) / ".claude" / "skills" / SKILL_NAME / "SKILL.md"
222
+ if skill_path.exists() and skill_path.read_text(encoding="utf-8") == _SKILL_BODY:
223
+ return False
224
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
225
+ skill_path.write_text(_SKILL_BODY, encoding="utf-8")
226
+ return True