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.
Files changed (122) hide show
  1. {prodc-0.7.4 → prodc-0.9.0}/CHANGELOG.md +20 -0
  2. {prodc-0.7.4 → prodc-0.9.0}/PKG-INFO +6 -1
  3. {prodc-0.7.4 → prodc-0.9.0}/README.md +3 -0
  4. {prodc-0.7.4 → prodc-0.9.0}/pyproject.toml +3 -1
  5. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/__init__.py +1 -1
  6. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/agent.md +10 -2
  7. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/app.py +15 -2
  8. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/common.py +8 -1
  9. prodc-0.9.0/src/prodc/cli/issue.py +238 -0
  10. prodc-0.9.0/src/prodc/cli/mcp.py +132 -0
  11. prodc-0.9.0/src/prodc/issue_store.py +1516 -0
  12. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/issues.py +2 -1
  13. prodc-0.9.0/src/prodc/mcp_config.py +147 -0
  14. prodc-0.9.0/src/prodc/mcp_server.py +348 -0
  15. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/writer.py +10 -0
  16. {prodc-0.7.4 → prodc-0.9.0}/tests/test_cli.py +1 -1
  17. prodc-0.9.0/tests/test_edit_sha.py +46 -0
  18. prodc-0.9.0/tests/test_issue_cli.py +83 -0
  19. prodc-0.9.0/tests/test_issue_store.py +817 -0
  20. prodc-0.9.0/tests/test_mcp_install.py +115 -0
  21. prodc-0.9.0/tests/test_mcp_server.py +118 -0
  22. {prodc-0.7.4 → prodc-0.9.0}/.gitignore +0 -0
  23. {prodc-0.7.4 → prodc-0.9.0}/LICENSE +0 -0
  24. {prodc-0.7.4 → prodc-0.9.0}/examples/quickstart/features/orders.feature +0 -0
  25. {prodc-0.7.4 → prodc-0.9.0}/examples/quickstart/needs/jordan.yaml +0 -0
  26. {prodc-0.7.4 → prodc-0.9.0}/examples/quickstart/prodc.toml +0 -0
  27. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/adapters.py +0 -0
  28. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/anchor.py +0 -0
  29. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/board.py +0 -0
  30. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/callbacks.py +0 -0
  31. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/__init__.py +0 -0
  32. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/model.py +0 -0
  33. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/ux.py +0 -0
  34. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/catalog/visual.py +0 -0
  35. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/__init__.py +0 -0
  36. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/__main__.py +0 -0
  37. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/commands.py +0 -0
  38. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/render.py +0 -0
  39. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/cli/serialize.py +0 -0
  40. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/config.py +0 -0
  41. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/dedup.py +0 -0
  42. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/glossary.py +0 -0
  43. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/guide.md +0 -0
  44. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/inbox.py +0 -0
  45. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/loader.py +0 -0
  46. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/model.py +0 -0
  47. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/py.typed +0 -0
  48. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/report.py +0 -0
  49. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/report_export.py +0 -0
  50. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/report_html.py +0 -0
  51. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/status.py +0 -0
  52. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/storybook.py +0 -0
  53. {prodc-0.7.4 → prodc-0.9.0}/src/prodc/views.py +0 -0
  54. {prodc-0.7.4 → prodc-0.9.0}/tests/fixtures/storybook/index.json +0 -0
  55. {prodc-0.7.4 → prodc-0.9.0}/tests/test_adapters.py +0 -0
  56. {prodc-0.7.4 → prodc-0.9.0}/tests/test_anchor.py +0 -0
  57. {prodc-0.7.4 → prodc-0.9.0}/tests/test_answer.py +0 -0
  58. {prodc-0.7.4 → prodc-0.9.0}/tests/test_attest_policy.py +0 -0
  59. {prodc-0.7.4 → prodc-0.9.0}/tests/test_awaits.py +0 -0
  60. {prodc-0.7.4 → prodc-0.9.0}/tests/test_callbacks.py +0 -0
  61. {prodc-0.7.4 → prodc-0.9.0}/tests/test_capture_block_scalar.py +0 -0
  62. {prodc-0.7.4 → prodc-0.9.0}/tests/test_catalog_add.py +0 -0
  63. {prodc-0.7.4 → prodc-0.9.0}/tests/test_catalog_lint.py +0 -0
  64. {prodc-0.7.4 → prodc-0.9.0}/tests/test_catalog_registry.py +0 -0
  65. {prodc-0.7.4 → prodc-0.9.0}/tests/test_citation.py +0 -0
  66. {prodc-0.7.4 → prodc-0.9.0}/tests/test_claim_gating.py +0 -0
  67. {prodc-0.7.4 → prodc-0.9.0}/tests/test_command_hook.py +0 -0
  68. {prodc-0.7.4 → prodc-0.9.0}/tests/test_confidence.py +0 -0
  69. {prodc-0.7.4 → prodc-0.9.0}/tests/test_config_keys.py +0 -0
  70. {prodc-0.7.4 → prodc-0.9.0}/tests/test_contradicts.py +0 -0
  71. {prodc-0.7.4 → prodc-0.9.0}/tests/test_decisions.py +0 -0
  72. {prodc-0.7.4 → prodc-0.9.0}/tests/test_declared_only.py +0 -0
  73. {prodc-0.7.4 → prodc-0.9.0}/tests/test_dedup.py +0 -0
  74. {prodc-0.7.4 → prodc-0.9.0}/tests/test_dedup_candidates.py +0 -0
  75. {prodc-0.7.4 → prodc-0.9.0}/tests/test_description.py +0 -0
  76. {prodc-0.7.4 → prodc-0.9.0}/tests/test_drafts.py +0 -0
  77. {prodc-0.7.4 → prodc-0.9.0}/tests/test_evalgate.py +0 -0
  78. {prodc-0.7.4 → prodc-0.9.0}/tests/test_expects.py +0 -0
  79. {prodc-0.7.4 → prodc-0.9.0}/tests/test_external_adapter.py +0 -0
  80. {prodc-0.7.4 → prodc-0.9.0}/tests/test_files_key.py +0 -0
  81. {prodc-0.7.4 → prodc-0.9.0}/tests/test_flowsteps.py +0 -0
  82. {prodc-0.7.4 → prodc-0.9.0}/tests/test_gitlab_source.py +0 -0
  83. {prodc-0.7.4 → prodc-0.9.0}/tests/test_glossary.py +0 -0
  84. {prodc-0.7.4 → prodc-0.9.0}/tests/test_importance.py +0 -0
  85. {prodc-0.7.4 → prodc-0.9.0}/tests/test_inbox.py +0 -0
  86. {prodc-0.7.4 → prodc-0.9.0}/tests/test_inventory.py +0 -0
  87. {prodc-0.7.4 → prodc-0.9.0}/tests/test_issues.py +0 -0
  88. {prodc-0.7.4 → prodc-0.9.0}/tests/test_lint_decision.py +0 -0
  89. {prodc-0.7.4 → prodc-0.9.0}/tests/test_loader.py +0 -0
  90. {prodc-0.7.4 → prodc-0.9.0}/tests/test_models.py +0 -0
  91. {prodc-0.7.4 → prodc-0.9.0}/tests/test_needref.py +0 -0
  92. {prodc-0.7.4 → prodc-0.9.0}/tests/test_path.py +0 -0
  93. {prodc-0.7.4 → prodc-0.9.0}/tests/test_path_views.py +0 -0
  94. {prodc-0.7.4 → prodc-0.9.0}/tests/test_playwright.py +0 -0
  95. {prodc-0.7.4 → prodc-0.9.0}/tests/test_principle_withdrawal.py +0 -0
  96. {prodc-0.7.4 → prodc-0.9.0}/tests/test_principles.py +0 -0
  97. {prodc-0.7.4 → prodc-0.9.0}/tests/test_relink.py +0 -0
  98. {prodc-0.7.4 → prodc-0.9.0}/tests/test_report.py +0 -0
  99. {prodc-0.7.4 → prodc-0.9.0}/tests/test_report_export.py +0 -0
  100. {prodc-0.7.4 → prodc-0.9.0}/tests/test_report_html.py +0 -0
  101. {prodc-0.7.4 → prodc-0.9.0}/tests/test_results_reports.py +0 -0
  102. {prodc-0.7.4 → prodc-0.9.0}/tests/test_review.py +0 -0
  103. {prodc-0.7.4 → prodc-0.9.0}/tests/test_roles.py +0 -0
  104. {prodc-0.7.4 → prodc-0.9.0}/tests/test_source_role.py +0 -0
  105. {prodc-0.7.4 → prodc-0.9.0}/tests/test_sources.py +0 -0
  106. {prodc-0.7.4 → prodc-0.9.0}/tests/test_stage.py +0 -0
  107. {prodc-0.7.4 → prodc-0.9.0}/tests/test_staleness.py +0 -0
  108. {prodc-0.7.4 → prodc-0.9.0}/tests/test_status.py +0 -0
  109. {prodc-0.7.4 → prodc-0.9.0}/tests/test_storybook.py +0 -0
  110. {prodc-0.7.4 → prodc-0.9.0}/tests/test_suggest.py +0 -0
  111. {prodc-0.7.4 → prodc-0.9.0}/tests/test_suggest_eval.py +0 -0
  112. {prodc-0.7.4 → prodc-0.9.0}/tests/test_ux_catalog.py +0 -0
  113. {prodc-0.7.4 → prodc-0.9.0}/tests/test_variants.py +0 -0
  114. {prodc-0.7.4 → prodc-0.9.0}/tests/test_view_links.py +0 -0
  115. {prodc-0.7.4 → prodc-0.9.0}/tests/test_views.py +0 -0
  116. {prodc-0.7.4 → prodc-0.9.0}/tests/test_views_report.py +0 -0
  117. {prodc-0.7.4 → prodc-0.9.0}/tests/test_visual_catalog.py +0 -0
  118. {prodc-0.7.4 → prodc-0.9.0}/tests/test_vitest.py +0 -0
  119. {prodc-0.7.4 → prodc-0.9.0}/tests/test_who_variants.py +0 -0
  120. {prodc-0.7.4 → prodc-0.9.0}/tests/test_withdrawn.py +0 -0
  121. {prodc-0.7.4 → prodc-0.9.0}/tests/test_writer.py +0 -0
  122. {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.7.4
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
@@ -1,3 +1,3 @@
1
1
  """prodc: product management as code."""
2
2
 
3
- __version__ = "0.7.4" # the single source of truth; pyproject reads it via [tool.hatch.version]
3
+ __version__ = "0.9.0" # the single source of truth; pyproject reads it via [tool.hatch.version]
@@ -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, not prodc | `the view generator drops links from shared components` |
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 there.
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 main(argv: list[str] | None = None) -> int:
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
- args = parser.parse_args(argv)
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
- print(json.dumps({"file": edit.path.name, "dry_run": dry_run, "diff": edit.diff}, indent=2))
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)