prodc 0.7.4__tar.gz → 0.9.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.
- {prodc-0.7.4 → prodc-0.9.0}/CHANGELOG.md +20 -0
- {prodc-0.7.4 → prodc-0.9.0}/PKG-INFO +6 -1
- {prodc-0.7.4 → prodc-0.9.0}/README.md +3 -0
- {prodc-0.7.4 → prodc-0.9.0}/pyproject.toml +3 -1
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/__init__.py +1 -1
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/agent.md +10 -2
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/app.py +15 -2
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/common.py +8 -1
- prodc-0.9.0/src/prodc/cli/issue.py +238 -0
- prodc-0.9.0/src/prodc/cli/mcp.py +132 -0
- prodc-0.9.0/src/prodc/issue_store.py +1516 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/issues.py +2 -1
- prodc-0.9.0/src/prodc/mcp_config.py +147 -0
- prodc-0.9.0/src/prodc/mcp_server.py +348 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/writer.py +10 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_cli.py +1 -1
- prodc-0.9.0/tests/test_edit_sha.py +46 -0
- prodc-0.9.0/tests/test_issue_cli.py +83 -0
- prodc-0.9.0/tests/test_issue_store.py +817 -0
- prodc-0.9.0/tests/test_mcp_install.py +115 -0
- prodc-0.9.0/tests/test_mcp_server.py +118 -0
- {prodc-0.7.4 → prodc-0.9.0}/.gitignore +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/LICENSE +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/adapters.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/anchor.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/board.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/callbacks.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/__init__.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/model.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/ux.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/visual.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/__main__.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/commands.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/render.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/config.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/dedup.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/glossary.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/guide.md +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/inbox.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/loader.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/model.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/py.typed +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/report.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/report_export.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/report_html.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/status.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/storybook.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/src/prodc/views.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/fixtures/storybook/index.json +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_adapters.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_anchor.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_answer.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_attest_policy.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_awaits.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_callbacks.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_capture_block_scalar.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_catalog_add.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_catalog_lint.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_catalog_registry.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_citation.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_claim_gating.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_command_hook.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_confidence.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_config_keys.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_contradicts.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_decisions.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_declared_only.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_dedup.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_dedup_candidates.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_description.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_drafts.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_evalgate.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_expects.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_external_adapter.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_files_key.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_flowsteps.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_gitlab_source.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_glossary.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_importance.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_inbox.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_inventory.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_issues.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_lint_decision.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_loader.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_models.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_needref.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_path.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_path_views.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_playwright.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_principle_withdrawal.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_principles.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_relink.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_report.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_report_export.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_report_html.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_results_reports.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_review.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_roles.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_source_role.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_sources.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_stage.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_staleness.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_status.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_storybook.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_suggest.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_suggest_eval.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_ux_catalog.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_variants.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_view_links.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_views.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_views_report.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_visual_catalog.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_vitest.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_who_variants.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_withdrawn.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_writer.py +0 -0
- {prodc-0.7.4 → prodc-0.9.0}/tests/test_writer_comments.py +0 -0
|
@@ -4,6 +4,26 @@ All notable changes are documented here. Versioning is [SemVer](https://semver.o
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.0] - 2026-11-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `prodc issue board|show|file|update|close|check|reindex` reads and writes the issue register
|
|
12
|
+
(`docs/issues/*.md`). The store moved here from cabildo, same file format.
|
|
13
|
+
- MCP tools `issue_board`, `issue_show`, `issue_check`, `issue_file`, `issue_update`,
|
|
14
|
+
`issue_close`.
|
|
15
|
+
|
|
16
|
+
## [0.8.0] - 2026-11-11
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `prodc mcp serve`: prodc as an MCP server, with the optional extra `pip install 'prodc[mcp]'`.
|
|
21
|
+
10 read tools and 5 write tools. `capture` and `add_need` refuse `primary`. `accept`,
|
|
22
|
+
`outcome`, `attest`, `withdraw` and `sync` are not exposed. See [docs/mcp.md](docs/mcp.md).
|
|
23
|
+
- `prodc mcp install` writes the server into `.mcp.json` (or `--client cursor|vscode`). It changes
|
|
24
|
+
only the `prodc` entry and refuses a file it cannot parse. `prodc mcp uninstall` removes it.
|
|
25
|
+
- A write's `--json` output has `sha_before` and `sha_after`, to pass as `--expect-sha`.
|
|
26
|
+
|
|
7
27
|
## [0.7.4] - 2026-11-11
|
|
8
28
|
|
|
9
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Product management as code: roles, flows, and features traced to evidence.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/cardona-oss/prodc
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/cardona-oss/prodc
|
|
@@ -24,6 +24,8 @@ Requires-Dist: pyyaml>=6
|
|
|
24
24
|
Provides-Extra: docs
|
|
25
25
|
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
26
26
|
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
27
|
+
Provides-Extra: mcp
|
|
28
|
+
Requires-Dist: mcp<3,>=2; extra == 'mcp'
|
|
27
29
|
Description-Content-Type: text/markdown
|
|
28
30
|
|
|
29
31
|
# prodc
|
|
@@ -196,6 +198,9 @@ The Python modules are not a public API. Use the CLI, `--json` and the YAML file
|
|
|
196
198
|
- `prodc agent >> AGENTS.md` adds instructions for a coding agent to act as the project's product
|
|
197
199
|
manager. An agent may record `assumption` or `secondary` sources; `primary` is only for a real
|
|
198
200
|
user's own words. `prodc guide` is a short guide for people.
|
|
201
|
+
- `prodc mcp install` registers prodc as an MCP server, so an agent in Claude Code, Cursor, or VS
|
|
202
|
+
Code uses it through tools. Install with `pip install 'prodc[mcp]'`. See
|
|
203
|
+
[MCP server](https://cardona-oss.gitlab.io/prodc/mcp/).
|
|
199
204
|
|
|
200
205
|
prodc also has goals, releases, roles, principles, and decisions
|
|
201
206
|
([model docs](https://cardona-oss.gitlab.io/prodc/model/)), and the commands `dupes`,
|
|
@@ -168,6 +168,9 @@ The Python modules are not a public API. Use the CLI, `--json` and the YAML file
|
|
|
168
168
|
- `prodc agent >> AGENTS.md` adds instructions for a coding agent to act as the project's product
|
|
169
169
|
manager. An agent may record `assumption` or `secondary` sources; `primary` is only for a real
|
|
170
170
|
user's own words. `prodc guide` is a short guide for people.
|
|
171
|
+
- `prodc mcp install` registers prodc as an MCP server, so an agent in Claude Code, Cursor, or VS
|
|
172
|
+
Code uses it through tools. Install with `pip install 'prodc[mcp]'`. See
|
|
173
|
+
[MCP server](https://cardona-oss.gitlab.io/prodc/mcp/).
|
|
171
174
|
|
|
172
175
|
prodc also has goals, releases, roles, principles, and decisions
|
|
173
176
|
([model docs](https://cardona-oss.gitlab.io/prodc/model/)), and the commands `dupes`,
|
|
@@ -31,6 +31,8 @@ dependencies = ["pyyaml>=6"]
|
|
|
31
31
|
prodc = "prodc.cli:main"
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
|
+
# mcp: the optional MCP server (`prodc mcp serve`). v2 moved the server import, so pin the major.
|
|
35
|
+
mcp = ["mcp>=2,<3"]
|
|
34
36
|
# docs are an *extra* (not a uv dependency-group): CI's `pages` job and moon's `docs` task
|
|
35
37
|
# install them with `--extra docs` to build the GitLab Pages site
|
|
36
38
|
docs = [
|
|
@@ -68,7 +70,7 @@ include = ["src", "tests", "examples", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
|
68
70
|
|
|
69
71
|
[dependency-groups]
|
|
70
72
|
# musil is a dev-only model checker (tests/test_models.py); prodc's runtime stays pyyaml-only.
|
|
71
|
-
dev = ["ruff>=0.6", "pyright[nodejs]>=1.1.390", "pytest>=8", "types-pyyaml>=6", "musil>=0.35"]
|
|
73
|
+
dev = ["ruff>=0.6", "pyright[nodejs]>=1.1.390", "pytest>=8", "types-pyyaml>=6", "musil>=0.35", "mcp>=2,<3"]
|
|
72
74
|
|
|
73
75
|
[tool.ruff]
|
|
74
76
|
line-length = 100
|
|
@@ -44,9 +44,10 @@ Decide where a thing goes before you write it.
|
|
|
44
44
|
|---|---|---|
|
|
45
45
|
| something a person wants | a need, with a source | `When an invited user opens the invite link, they can set a password` |
|
|
46
46
|
| a rule that decides between wants | a principle | `We do not build per-client forks` |
|
|
47
|
-
| engineering work or a bug | an issue in your tracker
|
|
47
|
+
| engineering work or a bug | an issue: `prodc issue file` in `docs/issues/`, or your tracker | `the view generator drops links from shared components` |
|
|
48
48
|
|
|
49
|
-
- prodc never calls a tracker. Engineering work and bugs are issues
|
|
49
|
+
- prodc never calls a tracker. Engineering work and bugs are issues: in `docs/issues/` with
|
|
50
|
+
`prodc issue file`, or in your tracker.
|
|
50
51
|
- A bug that breaks a delivered need shows up on its own: the proof turns PENDING or BROKEN. Fix
|
|
51
52
|
the code. Do not edit the need.
|
|
52
53
|
- A bug that shows users cannot do something at all is a missing want. Write that as a need.
|
|
@@ -132,6 +133,13 @@ boards under), and `prodc diff <snapshot.json>` (what changed since a
|
|
|
132
133
|
saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
|
|
133
134
|
`--wide`, `--include-drafts`, and `--stale <days>`.
|
|
134
135
|
|
|
136
|
+
## As MCP tools
|
|
137
|
+
|
|
138
|
+
If your client has the prodc MCP server (`prodc mcp install`), the same commands are tools:
|
|
139
|
+
`status`, `show`, `plan`, `lint`, `capture`, `set`, `add_need`, and others. The rules above
|
|
140
|
+
still hold. `capture` and `add_need` accept only `secondary` and `assumption`. Chain writes by
|
|
141
|
+
passing the last `sha_after` as `expect_sha`.
|
|
142
|
+
|
|
135
143
|
## Running non-interactively (CI, or no human in the loop)
|
|
136
144
|
|
|
137
145
|
- **Exit codes are the contract, not the stdout.** `0` = clean; `1` = a gate failed (a BROKEN
|
|
@@ -41,9 +41,12 @@ from .commands import (
|
|
|
41
41
|
cmd_views,
|
|
42
42
|
cmd_withdraw,
|
|
43
43
|
)
|
|
44
|
+
from .issue import add_issue_parser
|
|
45
|
+
from .mcp import add_mcp_parser
|
|
44
46
|
|
|
45
47
|
|
|
46
|
-
def
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
"""Return the full ``prodc`` argument parser. The MCP server builds its argv against it."""
|
|
47
50
|
parser = argparse.ArgumentParser(prog="prodc", description="Product management as code.")
|
|
48
51
|
parser.add_argument("--version", action="version", version=f"prodc {__version__}")
|
|
49
52
|
parser.add_argument(
|
|
@@ -752,6 +755,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
752
755
|
)
|
|
753
756
|
sy.set_defaults(func=cmd_sync)
|
|
754
757
|
|
|
758
|
+
add_issue_parser(
|
|
759
|
+
sub.add_parser("issue", help="read and write the issue register (docs/issues)")
|
|
760
|
+
)
|
|
761
|
+
add_mcp_parser(
|
|
762
|
+
sub.add_parser("mcp", help="run prodc as an MCP server, or register it with a client")
|
|
763
|
+
)
|
|
764
|
+
|
|
755
765
|
ag = sub.add_parser(
|
|
756
766
|
"agent",
|
|
757
767
|
help="print prodc's agent playbook (teach a coding agent to run prodc as the project's PM)",
|
|
@@ -784,8 +794,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
784
794
|
)
|
|
785
795
|
df.add_argument("snapshot", help="path to a saved `prodc status --json` file")
|
|
786
796
|
df.set_defaults(func=cmd_diff)
|
|
797
|
+
return parser
|
|
798
|
+
|
|
787
799
|
|
|
788
|
-
|
|
800
|
+
def main(argv: list[str] | None = None) -> int:
|
|
801
|
+
args = build_parser().parse_args(argv)
|
|
789
802
|
if args.chdir is not None:
|
|
790
803
|
try:
|
|
791
804
|
os.chdir(args.chdir)
|
|
@@ -79,7 +79,14 @@ def exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) ->
|
|
|
79
79
|
|
|
80
80
|
def emit_edit(edit: Edit, dry_run: bool, as_json: bool) -> int:
|
|
81
81
|
if as_json:
|
|
82
|
-
|
|
82
|
+
payload = {
|
|
83
|
+
"file": edit.path.name,
|
|
84
|
+
"dry_run": dry_run,
|
|
85
|
+
"diff": edit.diff,
|
|
86
|
+
"sha_before": edit.sha_before,
|
|
87
|
+
"sha_after": edit.sha_after,
|
|
88
|
+
}
|
|
89
|
+
print(json.dumps(payload, indent=2))
|
|
83
90
|
else:
|
|
84
91
|
print(f"{'would edit' if dry_run else 'edited'} {edit.path.name}")
|
|
85
92
|
print(edit.diff, end="")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""``prodc issue``: read and write the issue register (``docs/issues/*.md``).
|
|
2
|
+
|
|
3
|
+
The store is :mod:`prodc.issue_store`. Claiming an issue is not here: a claim is a lease between
|
|
4
|
+
running agents, which cabildo's bus owns. ``update --status in-progress`` records the state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .. import issue_store as store
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_issue_parser(ip: argparse.ArgumentParser) -> None:
|
|
19
|
+
"""Fill ``prodc issue {board,show,file,update,close,check,reindex}``."""
|
|
20
|
+
|
|
21
|
+
def bare(_args: argparse.Namespace) -> int:
|
|
22
|
+
ip.print_help(sys.stderr)
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
ip.set_defaults(func=bare)
|
|
26
|
+
isub = ip.add_subparsers(dest="issue_command")
|
|
27
|
+
|
|
28
|
+
def sub(name: str, help: str) -> argparse.ArgumentParser:
|
|
29
|
+
p = isub.add_parser(name, help=help)
|
|
30
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
31
|
+
return p
|
|
32
|
+
|
|
33
|
+
bd = sub("board", "what is ready to pick up, and what the rest waits on")
|
|
34
|
+
bd.add_argument("--status", default=None, help="flat list filtered by status")
|
|
35
|
+
bd.add_argument("--area", default=None, help="flat list filtered by area")
|
|
36
|
+
bd.add_argument("--owner", default=None, help="flat list filtered by owner ('none': unowned)")
|
|
37
|
+
bd.set_defaults(func=cmd_issue_board)
|
|
38
|
+
|
|
39
|
+
sh = sub("show", "print one issue file")
|
|
40
|
+
sh.add_argument("name", help="a filename or an unambiguous part of the slug")
|
|
41
|
+
sh.set_defaults(func=cmd_issue_show)
|
|
42
|
+
|
|
43
|
+
fi = sub("file", "file a new issue")
|
|
44
|
+
fi.add_argument("--title", required=True)
|
|
45
|
+
fi.add_argument("--area", required=True)
|
|
46
|
+
fi.add_argument("--body", required=True, help="markdown body ('-' reads stdin)")
|
|
47
|
+
fi.add_argument("--blocked-by", action="append", default=[], metavar="SLUG")
|
|
48
|
+
fi.add_argument(
|
|
49
|
+
"--field",
|
|
50
|
+
action="append",
|
|
51
|
+
default=[],
|
|
52
|
+
metavar="KEY=VALUE",
|
|
53
|
+
help="a field the folder's config.toml declares; repeat KEY for a list",
|
|
54
|
+
)
|
|
55
|
+
fi.set_defaults(func=cmd_issue_file)
|
|
56
|
+
|
|
57
|
+
up = sub("update", "change an issue's front matter; only what you pass changes")
|
|
58
|
+
up.add_argument("name")
|
|
59
|
+
up.add_argument("--title", default=None)
|
|
60
|
+
up.add_argument("--status", choices=[s.value for s in store.Status], default=None)
|
|
61
|
+
up.add_argument("--area", default=None)
|
|
62
|
+
up.add_argument("--blocked-by", action="append", default=None, metavar="SLUG")
|
|
63
|
+
up.add_argument("--clear-blocked-by", action="store_true")
|
|
64
|
+
up.add_argument("--field", action="append", default=[], metavar="KEY=VALUE")
|
|
65
|
+
up.set_defaults(func=cmd_issue_update)
|
|
66
|
+
|
|
67
|
+
cl = sub("close", "mark an issue done; the file stays, carrying fixed_in")
|
|
68
|
+
cl.add_argument("name")
|
|
69
|
+
cl.add_argument("--fixed-in", required=True, metavar="SHA", help="the sha on the mainline")
|
|
70
|
+
cl.set_defaults(func=cmd_issue_close)
|
|
71
|
+
|
|
72
|
+
ck = sub("check", "grade the folder against its config.toml (exit 1 on problems)")
|
|
73
|
+
ck.set_defaults(func=cmd_issue_check)
|
|
74
|
+
|
|
75
|
+
ri = sub("reindex", "rewrite the README index table")
|
|
76
|
+
ri.set_defaults(func=cmd_issue_reindex)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _folder() -> Path:
|
|
80
|
+
found = store.find_folder(Path.cwd())
|
|
81
|
+
if found is None:
|
|
82
|
+
raise store.IssueFormatError(f"no docs/issues/ at or above {Path.cwd()}")
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fields(pairs: list[str]) -> dict[str, str | tuple[str, ...]]:
|
|
87
|
+
"""``KEY=VALUE`` pairs to front-matter fields; a repeated key becomes a list."""
|
|
88
|
+
out: dict[str, list[str]] = {}
|
|
89
|
+
for pair in pairs:
|
|
90
|
+
key, sep, value = pair.partition("=")
|
|
91
|
+
if not sep or not key:
|
|
92
|
+
raise store.IssueFormatError(f"--field wants KEY=VALUE, got {pair!r}")
|
|
93
|
+
out.setdefault(key, []).append(value)
|
|
94
|
+
return {k: v[0] if len(v) == 1 else tuple(v) for k, v in out.items()}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _row(issue: store.Issue) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"slug": issue.slug,
|
|
100
|
+
"file": issue.path.name,
|
|
101
|
+
"title": issue.title,
|
|
102
|
+
"status": issue.status.value,
|
|
103
|
+
"area": issue.area,
|
|
104
|
+
"owner": issue.owner,
|
|
105
|
+
"blocked_by": list(issue.blocked_by),
|
|
106
|
+
"fixed_in": issue.fixed_in,
|
|
107
|
+
"fields": {k: list(v) if isinstance(v, tuple) else v for k, v in issue.extra.items()},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _line(issue: store.Issue) -> str:
|
|
112
|
+
owner = f" @{issue.owner}" if issue.owner else ""
|
|
113
|
+
return f" {issue.slug} [{issue.status.value}{owner}] {issue.area}: {issue.title}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _emit(as_json: bool, data: object, text: str) -> None:
|
|
117
|
+
print(json.dumps(data, indent=2) if as_json else text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _guard(func: Any) -> Any:
|
|
121
|
+
def run(args: argparse.Namespace) -> int:
|
|
122
|
+
try:
|
|
123
|
+
return int(func(args))
|
|
124
|
+
except (store.IssueFormatError, OSError) as exc:
|
|
125
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
return run
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@_guard
|
|
132
|
+
def cmd_issue_board(args: argparse.Namespace) -> int:
|
|
133
|
+
"""Ready, waiting, done; or a flat list when a filter is given."""
|
|
134
|
+
folder = _folder()
|
|
135
|
+
if args.status or args.area or args.owner:
|
|
136
|
+
found, _ = store.load_all(folder)
|
|
137
|
+
rows = [
|
|
138
|
+
i
|
|
139
|
+
for i in found
|
|
140
|
+
if (args.status == "done" or i.open)
|
|
141
|
+
and (not args.status or i.status == args.status)
|
|
142
|
+
and (not args.area or i.area == args.area)
|
|
143
|
+
and (not args.owner or (i.owner or "none") == args.owner)
|
|
144
|
+
]
|
|
145
|
+
_emit(args.json, [_row(i) for i in rows], "\n".join(_line(i) for i in rows) or "(none)")
|
|
146
|
+
return 0
|
|
147
|
+
b = store.board(folder)
|
|
148
|
+
data = {
|
|
149
|
+
"ready": [_row(i) for i in b.ready],
|
|
150
|
+
"waiting": [{**_row(w.issue), "reason": w.reason} for w in b.waiting],
|
|
151
|
+
"done": b.done,
|
|
152
|
+
}
|
|
153
|
+
lines = [f"READY ({len(b.ready)}):", *(_line(i) for i in b.ready)]
|
|
154
|
+
lines += [f"WAITING ({len(b.waiting)}):", *(f" {w.issue.slug}: {w.reason}" for w in b.waiting)]
|
|
155
|
+
lines.append(f"{b.done} done")
|
|
156
|
+
_emit(args.json, data, "\n".join(lines))
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@_guard
|
|
161
|
+
def cmd_issue_show(args: argparse.Namespace) -> int:
|
|
162
|
+
"""Print one issue file."""
|
|
163
|
+
path = store.resolve(_folder(), args.name)
|
|
164
|
+
text = path.read_text(encoding="utf-8")
|
|
165
|
+
_emit(args.json, {"file": path.name, "text": text}, text.rstrip("\n"))
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@_guard
|
|
170
|
+
def cmd_issue_file(args: argparse.Namespace) -> int:
|
|
171
|
+
"""File a new issue and print its filename."""
|
|
172
|
+
body = sys.stdin.read() if args.body == "-" else args.body
|
|
173
|
+
path = store.file_issue(
|
|
174
|
+
_folder(),
|
|
175
|
+
title=args.title,
|
|
176
|
+
area=args.area,
|
|
177
|
+
body=body,
|
|
178
|
+
blocked_by=tuple(args.blocked_by),
|
|
179
|
+
extra=_fields(args.field),
|
|
180
|
+
)
|
|
181
|
+
_emit(args.json, {"file": path.name}, f"filed {path.name}")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@_guard
|
|
186
|
+
def cmd_issue_update(args: argparse.Namespace) -> int:
|
|
187
|
+
"""Change only the front-matter keys passed."""
|
|
188
|
+
changes: dict[str, Any] = {}
|
|
189
|
+
if args.title:
|
|
190
|
+
changes["title"] = args.title
|
|
191
|
+
if args.status:
|
|
192
|
+
changes["status"] = store.Status(args.status)
|
|
193
|
+
if args.area:
|
|
194
|
+
changes["area"] = args.area
|
|
195
|
+
if args.clear_blocked_by:
|
|
196
|
+
changes["blocked_by"] = ()
|
|
197
|
+
elif args.blocked_by is not None:
|
|
198
|
+
changes["blocked_by"] = tuple(args.blocked_by)
|
|
199
|
+
if args.field:
|
|
200
|
+
changes["extra"] = _fields(args.field)
|
|
201
|
+
if not changes:
|
|
202
|
+
print("prodc: nothing to change", file=sys.stderr)
|
|
203
|
+
return 2
|
|
204
|
+
updated = store.update(_folder(), args.name, **changes)
|
|
205
|
+
_emit(args.json, _row(updated), f"updated {updated.path.name}\n{_line(updated)}")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@_guard
|
|
210
|
+
def cmd_issue_close(args: argparse.Namespace) -> int:
|
|
211
|
+
"""Mark done with ``fixed_in``; report what it unblocked."""
|
|
212
|
+
folder = _folder()
|
|
213
|
+
updated = store.close(folder, args.name, fixed_in=args.fixed_in)
|
|
214
|
+
unblocked = [i.slug for i in store.newly_ready(folder, updated.slug)]
|
|
215
|
+
text = f"closed {updated.path.name} (fixed_in {updated.fixed_in})"
|
|
216
|
+
if unblocked:
|
|
217
|
+
text += "; unblocked: " + ", ".join(unblocked)
|
|
218
|
+
_emit(args.json, {**_row(updated), "unblocked": unblocked}, text)
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@_guard
|
|
223
|
+
def cmd_issue_check(args: argparse.Namespace) -> int:
|
|
224
|
+
"""Grade the folder; exit 1 when it has problems."""
|
|
225
|
+
folder = _folder()
|
|
226
|
+
problems = store.check(folder)
|
|
227
|
+
data = [{"file": p.path.name, "problem": p.message} for p in problems]
|
|
228
|
+
_emit(args.json, data, "\n".join(str(p) for p in problems) or f"{folder}: clean")
|
|
229
|
+
return 1 if problems else 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@_guard
|
|
233
|
+
def cmd_issue_reindex(args: argparse.Namespace) -> int:
|
|
234
|
+
"""Rewrite the README index table."""
|
|
235
|
+
folder = _folder()
|
|
236
|
+
store.reindex(folder)
|
|
237
|
+
_emit(args.json, {"folder": str(folder)}, f"reindexed {folder}")
|
|
238
|
+
return 0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``prodc mcp``: run the MCP server, or register it in an MCP client's project config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from ..mcp_config import (
|
|
13
|
+
CLIENTS,
|
|
14
|
+
DEFAULT_COMMAND,
|
|
15
|
+
ClientName,
|
|
16
|
+
McpConfigError,
|
|
17
|
+
apply,
|
|
18
|
+
install,
|
|
19
|
+
manual_instructions,
|
|
20
|
+
uninstall,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_mcp_parser(mp: argparse.ArgumentParser) -> None:
|
|
25
|
+
"""Fill ``prodc mcp {serve,install,uninstall}``. A bare ``prodc mcp`` prints help, exit 2."""
|
|
26
|
+
|
|
27
|
+
def bare(_args: argparse.Namespace) -> int:
|
|
28
|
+
mp.print_help(sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
mp.set_defaults(func=bare)
|
|
32
|
+
msub = mp.add_subparsers(dest="mcp_command")
|
|
33
|
+
|
|
34
|
+
sv = msub.add_parser("serve", help="run the MCP server on stdio (the client starts this)")
|
|
35
|
+
sv.set_defaults(func=cmd_mcp_serve)
|
|
36
|
+
|
|
37
|
+
for name, func, verb in (
|
|
38
|
+
("install", cmd_mcp_install, "add the prodc server to"),
|
|
39
|
+
("uninstall", cmd_mcp_uninstall, "remove the prodc server from"),
|
|
40
|
+
):
|
|
41
|
+
p = msub.add_parser(name, help=f"{verb} an MCP client's project config")
|
|
42
|
+
p.add_argument(
|
|
43
|
+
"--client",
|
|
44
|
+
choices=["claude", "cursor", "vscode", "codex"],
|
|
45
|
+
default="claude",
|
|
46
|
+
help="which client's config (default: claude, .mcp.json)",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--scope",
|
|
50
|
+
choices=["project", "user"],
|
|
51
|
+
default="project",
|
|
52
|
+
help="project writes the repo's config file; user prints the client's own command",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--dry-run", action="store_true", help="show the diff, write nothing")
|
|
55
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
56
|
+
if name == "install":
|
|
57
|
+
p.add_argument(
|
|
58
|
+
"--command",
|
|
59
|
+
default=None,
|
|
60
|
+
metavar="CMD",
|
|
61
|
+
help='the server command to write (default: uvx --from "prodc[mcp]" prodc mcp '
|
|
62
|
+
"serve). Use 'uv run prodc mcp serve' when the project pins prodc",
|
|
63
|
+
)
|
|
64
|
+
p.set_defaults(func=func)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_mcp_serve(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Run the stdio server. Needs the ``mcp`` extra."""
|
|
69
|
+
try:
|
|
70
|
+
from ..mcp_server import serve
|
|
71
|
+
except ImportError:
|
|
72
|
+
print("prodc: install prodc[mcp] to run the MCP server", file=sys.stderr)
|
|
73
|
+
return 2
|
|
74
|
+
serve(args.chdir)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _report(edit_path: Path, old: str, new: str, dry_run: bool, as_json: bool) -> None:
|
|
79
|
+
changed = old != new
|
|
80
|
+
diff = "".join(
|
|
81
|
+
difflib.unified_diff(
|
|
82
|
+
old.splitlines(keepends=True),
|
|
83
|
+
new.splitlines(keepends=True),
|
|
84
|
+
fromfile=f"a/{edit_path}",
|
|
85
|
+
tofile=f"b/{edit_path}",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
if as_json:
|
|
89
|
+
print(
|
|
90
|
+
json.dumps(
|
|
91
|
+
{"file": str(edit_path), "changed": changed, "dry_run": dry_run, "diff": diff},
|
|
92
|
+
indent=2,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
if not changed:
|
|
97
|
+
print(f"{edit_path}: unchanged")
|
|
98
|
+
return
|
|
99
|
+
print(f"{'would edit' if dry_run else 'edited'} {edit_path}")
|
|
100
|
+
print(diff, end="")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run(args: argparse.Namespace, installing: bool) -> int:
|
|
104
|
+
client = cast(ClientName, args.client)
|
|
105
|
+
command: str = getattr(args, "command", None) or DEFAULT_COMMAND
|
|
106
|
+
if client not in CLIENTS or args.scope == "user":
|
|
107
|
+
# prodc does not write user-scope files (~/.claude.json is live client state) or TOML.
|
|
108
|
+
if installing:
|
|
109
|
+
print(manual_instructions(client, args.scope, command), end="")
|
|
110
|
+
else:
|
|
111
|
+
print(f"remove the 'prodc' server with {client}'s own MCP command or config")
|
|
112
|
+
return 0
|
|
113
|
+
root = Path.cwd()
|
|
114
|
+
try:
|
|
115
|
+
edit = install(root, client, command) if installing else uninstall(root, client)
|
|
116
|
+
except McpConfigError as exc:
|
|
117
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
if not args.dry_run:
|
|
120
|
+
apply(edit)
|
|
121
|
+
_report(edit.path.relative_to(root), edit.old_text, edit.new_text, args.dry_run, args.json)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_mcp_install(args: argparse.Namespace) -> int:
|
|
126
|
+
"""Add or update the ``prodc`` entry in the client's project config."""
|
|
127
|
+
return _run(args, installing=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_mcp_uninstall(args: argparse.Namespace) -> int:
|
|
131
|
+
"""Remove the ``prodc`` entry from the client's project config."""
|
|
132
|
+
return _run(args, installing=False)
|