prodc 0.4.7__tar.gz → 0.4.8__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 (68) hide show
  1. {prodc-0.4.7 → prodc-0.4.8}/PKG-INFO +62 -40
  2. {prodc-0.4.7 → prodc-0.4.8}/README.md +59 -39
  3. {prodc-0.4.7 → prodc-0.4.8}/pyproject.toml +21 -2
  4. prodc-0.4.8/src/prodc/__init__.py +3 -0
  5. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/agent.md +9 -5
  6. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/app.py +3 -3
  7. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/commands.py +21 -4
  8. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/render.py +34 -7
  9. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/serialize.py +7 -0
  10. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/config.py +25 -3
  11. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/guide.md +1 -1
  12. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/loader.py +14 -11
  13. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/writer.py +13 -34
  14. prodc-0.4.8/tests/test_files_key.py +103 -0
  15. prodc-0.4.7/src/prodc/__init__.py +0 -8
  16. {prodc-0.4.7 → prodc-0.4.8}/.gitignore +0 -0
  17. {prodc-0.4.7 → prodc-0.4.8}/CHANGELOG.md +0 -0
  18. {prodc-0.4.7 → prodc-0.4.8}/LICENSE +0 -0
  19. {prodc-0.4.7 → prodc-0.4.8}/examples/quickstart/features/orders.feature +0 -0
  20. {prodc-0.4.7 → prodc-0.4.8}/examples/quickstart/needs/jordan.yaml +0 -0
  21. {prodc-0.4.7 → prodc-0.4.8}/examples/quickstart/prodc.toml +0 -0
  22. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/adapters.py +0 -0
  23. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/board.py +0 -0
  24. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/callbacks.py +0 -0
  25. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/__init__.py +0 -0
  26. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/common.py +0 -0
  27. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/dedup.py +0 -0
  28. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/model.py +0 -0
  29. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/py.typed +0 -0
  30. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/report.py +0 -0
  31. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/report_html.py +0 -0
  32. {prodc-0.4.7 → prodc-0.4.8}/src/prodc/status.py +0 -0
  33. {prodc-0.4.7 → prodc-0.4.8}/tests/test_adapters.py +0 -0
  34. {prodc-0.4.7 → prodc-0.4.8}/tests/test_answer.py +0 -0
  35. {prodc-0.4.7 → prodc-0.4.8}/tests/test_callbacks.py +0 -0
  36. {prodc-0.4.7 → prodc-0.4.8}/tests/test_citation.py +0 -0
  37. {prodc-0.4.7 → prodc-0.4.8}/tests/test_claim_gating.py +0 -0
  38. {prodc-0.4.7 → prodc-0.4.8}/tests/test_cli.py +0 -0
  39. {prodc-0.4.7 → prodc-0.4.8}/tests/test_command_hook.py +0 -0
  40. {prodc-0.4.7 → prodc-0.4.8}/tests/test_contradicts.py +0 -0
  41. {prodc-0.4.7 → prodc-0.4.8}/tests/test_decisions.py +0 -0
  42. {prodc-0.4.7 → prodc-0.4.8}/tests/test_dedup.py +0 -0
  43. {prodc-0.4.7 → prodc-0.4.8}/tests/test_description.py +0 -0
  44. {prodc-0.4.7 → prodc-0.4.8}/tests/test_drafts.py +0 -0
  45. {prodc-0.4.7 → prodc-0.4.8}/tests/test_evalgate.py +0 -0
  46. {prodc-0.4.7 → prodc-0.4.8}/tests/test_external_adapter.py +0 -0
  47. {prodc-0.4.7 → prodc-0.4.8}/tests/test_flowsteps.py +0 -0
  48. {prodc-0.4.7 → prodc-0.4.8}/tests/test_gitlab_source.py +0 -0
  49. {prodc-0.4.7 → prodc-0.4.8}/tests/test_importance.py +0 -0
  50. {prodc-0.4.7 → prodc-0.4.8}/tests/test_loader.py +0 -0
  51. {prodc-0.4.7 → prodc-0.4.8}/tests/test_models.py +0 -0
  52. {prodc-0.4.7 → prodc-0.4.8}/tests/test_needref.py +0 -0
  53. {prodc-0.4.7 → prodc-0.4.8}/tests/test_principles.py +0 -0
  54. {prodc-0.4.7 → prodc-0.4.8}/tests/test_relink.py +0 -0
  55. {prodc-0.4.7 → prodc-0.4.8}/tests/test_report.py +0 -0
  56. {prodc-0.4.7 → prodc-0.4.8}/tests/test_report_html.py +0 -0
  57. {prodc-0.4.7 → prodc-0.4.8}/tests/test_review.py +0 -0
  58. {prodc-0.4.7 → prodc-0.4.8}/tests/test_roles.py +0 -0
  59. {prodc-0.4.7 → prodc-0.4.8}/tests/test_source_role.py +0 -0
  60. {prodc-0.4.7 → prodc-0.4.8}/tests/test_sources.py +0 -0
  61. {prodc-0.4.7 → prodc-0.4.8}/tests/test_staleness.py +0 -0
  62. {prodc-0.4.7 → prodc-0.4.8}/tests/test_status.py +0 -0
  63. {prodc-0.4.7 → prodc-0.4.8}/tests/test_suggest.py +0 -0
  64. {prodc-0.4.7 → prodc-0.4.8}/tests/test_variants.py +0 -0
  65. {prodc-0.4.7 → prodc-0.4.8}/tests/test_who_variants.py +0 -0
  66. {prodc-0.4.7 → prodc-0.4.8}/tests/test_withdrawn.py +0 -0
  67. {prodc-0.4.7 → prodc-0.4.8}/tests/test_writer.py +0 -0
  68. {prodc-0.4.7 → prodc-0.4.8}/tests/test_writer_comments.py +0 -0
@@ -1,14 +1,16 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.4.7
3
+ Version: 0.4.8
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
7
+ Project-URL: Issues, https://gitlab.com/cardona-oss/prodc/-/issues
7
8
  Project-URL: Changelog, https://gitlab.com/cardona-oss/prodc/-/blob/main/CHANGELOG.md
8
9
  Project-URL: Documentation, https://cardona-oss.gitlab.io/prodc/
9
10
  Author-email: Jorge Cardona <jorgeecardona@gmail.com>
10
11
  License-Expression: MIT
11
12
  License-File: LICENSE
13
+ Keywords: evidence,product-management,requirements,testing,traceability,yaml
12
14
  Classifier: Development Status :: 4 - Beta
13
15
  Classifier: Intended Audience :: Developers
14
16
  Classifier: Programming Language :: Python :: 3
@@ -27,15 +29,42 @@ Description-Content-Type: text/markdown
27
29
 
28
30
  # prodc
29
31
 
30
- [![PyPI](https://img.shields.io/pypi/v/prodc)](https://pypi.org/project/prodc/)
32
+ [![pipeline status](https://gitlab.com/cardona-oss/prodc/badges/main/pipeline.svg)](https://gitlab.com/cardona-oss/prodc/-/pipelines)
33
+ [![PyPI version](https://img.shields.io/pypi/v/prodc.svg)](https://pypi.org/project/prodc/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/prodc.svg)](https://pypi.org/project/prodc/)
31
35
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE)
32
36
 
33
- Product management as code: the *needs* your product exists to meet, each traced to the evidence a real user gave for it and the test that proves it is delivered. For teams that keep product work in the repo, next to the code, and want a tool to check it holds together.
37
+ Product management as code: user *needs* in YAML, each traced to the evidence a real user gave
38
+ and the test that proves it is delivered. For teams that keep product work in the repo, next to
39
+ the code.
34
40
 
35
- prodc reads plain YAML files and your test suite and shows you the gaps. It does not call a model, host a UI, or replace your tracker. Two rules carry it:
41
+ prodc reads plain YAML files and your test suite and shows you the gaps. It does not call a model,
42
+ host a UI, or replace your tracker. Two rules carry it:
36
43
 
37
- 1. **A need with no evidence is an opinion.** Write down who asked — an interview quote, a ticket, an email — or prodc flags the need as an opinion, because that is what it is until someone outside the room has asked.
38
- 2. **"Done" is a claim your tests back.** You never mark a need delivered. You attach a *proof* (a locator into your test suite), and `prodc status` resolves it: DELIVERED, PENDING, HOLE (an honest gap), or BROKEN (points at nothing). The state is read from the repo, not remembered from a standup.
44
+ 1. **A need with no evidence is an opinion.** Write down who asked (an interview quote, a ticket,
45
+ an email) or prodc flags the need as an opinion.
46
+ 2. **"Done" is a claim your tests back.** You never mark a need delivered. You attach a *proof*
47
+ (a locator into your test suite) and `prodc status` resolves it: DELIVERED, PENDING, HOLE, or
48
+ BROKEN. The state is read from the repo, not remembered from a standup.
49
+
50
+ ## Why prodc
51
+
52
+ - **Versus Jira, Linear, other trackers.** A tracker records work items and a status someone set
53
+ by hand. prodc records *needs* and computes their status from the tests. No assignees, sprints,
54
+ comments, or notifications. Keep the tracker for the work; keep prodc for "did a real user ask
55
+ for this, and does a test prove it?" They connect at the edges: a source can be a ticket
56
+ locator (checked read-only against GitLab with `[sources.<name>] kind = "gitlab"`), a proof can
57
+ be any script (`kind = "command"`), and `prodc sync` fires a hook when a need changes state.
58
+ - **Versus a PRD in Notion or a wiki.** A document says what should be true; nothing checks it
59
+ later. prodc's files are validated on every load, linted in CI, and reviewed in merge requests
60
+ like code. "Is this done?" is answered by `prodc show N-1`.
61
+ - **Versus plain YAML in the repo.** You could keep this structure by hand. prodc adds the schema,
62
+ proof resolution against real test suites, the evidence ladder, the lint, and write-back
63
+ commands that keep the files consistent.
64
+
65
+ **What it does not do.** It does not run your tests, call an LLM, or ping a tracker on its own.
66
+ It does not model the full discovery loop (opportunity-solution trees, parallel solution
67
+ testing); it records needs, the evidence under them, and the decisions taken on them.
39
68
 
40
69
  ## Install
41
70
 
@@ -47,13 +76,13 @@ Python 3.12+. The only runtime dependency is PyYAML.
47
76
 
48
77
  ## Quickstart
49
78
 
50
- Three files. The first tells prodc where to look, the second holds what people need, the third is a test you have probably already written.
79
+ Three files: where prodc looks, what people need, and a test you have probably already written.
51
80
 
52
81
  `prodc.toml`
53
82
 
54
83
  ```toml
55
84
  [prodc]
56
- needs = ["needs/*.yaml"]
85
+ files = ["needs/*.yaml"]
57
86
 
58
87
  [proofs.web]
59
88
  kind = "gherkin"
@@ -117,9 +146,13 @@ jordan 1 DELIVERED 2 HOLE
117
146
  ⚠ DELIVERED on an unattested proof link — warrant not reviewed
118
147
  ```
119
148
 
120
- N-1 is DELIVERED because a tagged scenario proves it (the `*` means declared by tag, no test results loaded yet). N-2 has evidence but no proof: a HOLE. N-3 has neither: an opinion.
149
+ - **N-1 is DELIVERED** because a tagged scenario proves it (`*` = declared by tag, no test results
150
+ loaded yet).
151
+ - **N-2 is a HOLE**: evidence, but no proof.
152
+ - **N-3 is an opinion**: neither.
121
153
 
122
- Now close the loop from the command line. Each write command rewrites one YAML file, re-validates the whole model, and prints the diff.
154
+ Close the loop from the command line. Each write command rewrites one YAML file, re-validates the
155
+ whole model, and prints the diff.
123
156
 
124
157
  ```bash
125
158
  # trace the opinion to evidence
@@ -137,17 +170,8 @@ prodc status # gaps: 1 PENDING · 1 HOLE — the scenario exists, so N-2
137
170
  prodc lint # CI gate: exit 1 on a BROKEN proof or a must-have with no evidence
138
171
  ```
139
172
 
140
- Change a scenario title without updating the need and the proof turns BROKEN; `prodc lint` exits 1. That is the whole idea: the board cannot drift from the code.
141
-
142
- ## Why prodc
143
-
144
- **Versus Jira, Linear, and other trackers.** A tracker records work items and a status someone set by hand. prodc records *needs* and computes their status from the tests. It has no assignees, sprints, comments, or notifications, and it is not trying to. Keep the tracker for the work; keep prodc for the question "did a real user ask for this, and does a test prove it?" The two connect at the edges: a need's source can be a ticket locator (checked read-only against GitLab with `[sources.<name>] kind = "gitlab"`), a proof can be resolved by any script you point at a tracker (`kind = "command"`), and `prodc sync` fires a hook when a need changes state.
145
-
146
- **Versus a PRD in Notion or a wiki.** A document says what should be true; nothing checks it later. prodc's files are validated on every load (ids, cross-references, source hygiene), linted in CI, and reviewed in merge requests like code. "Is this done?" is answered by `prodc show N-1`, not by finding the right page.
147
-
148
- **Versus plain YAML in the repo.** You could keep this structure by hand. prodc adds the schema, the proof resolution against real test suites, the evidence ladder, the lint, and the write-back commands that keep the files consistent.
149
-
150
- **What it does not do.** It does not run your tests, call an LLM, or ping a tracker on its own. It does not model the full discovery loop (opportunity-solution-tree restructuring, parallel solution testing); it records needs, the evidence under them, and the decisions taken on them.
173
+ Rename a scenario without updating the need and the proof turns BROKEN; `prodc lint` exits 1.
174
+ That is the whole idea: the board cannot drift from the code.
151
175
 
152
176
  ## Key features
153
177
 
@@ -162,39 +186,37 @@ Change a scenario title without updating the need and the proof turns BROKEN; `p
162
186
  - **Flows.** A need with `children` rolls them up, with `seam` proofs for the behaviour between
163
187
  steps. Any BROKEN child breaks the flow; a flow is DELIVERED only when every child and seam is
164
188
  green.
165
- - **The roll-up is machine-checked.** `tests/test_models.py` builds a
189
+ - **Machine-checked roll-up.** `tests/test_models.py` builds a
166
190
  [musil](https://pypi.org/project/musil/) model of the lifecycle and explores every reachable
167
191
  state against the real resolver, so the documented invariants cannot drift from the code.
168
192
  - **Write-back that respects your files.** `set`, `capture`, `accept`, `attest`, `withdraw`, `add`
169
193
  splice one need's block back into the original YAML, keeping other comments. All take `--dry-run`
170
194
  and `--expect-sha`.
171
195
  - **Boards for humans.** `status`, `show`, `brief`, `plan`, `report --format html` (a self-contained
172
- interactive viewer), `portfolio`, and `diff` against a snapshot. The read commands (`status`,
173
- `lint`, `show`, `brief`, `plan`, `diff`, `portfolio`, `dupes`, `recurrence`, `suggest`, `review`)
174
- take `--json`; exit codes are fixed (0 ok, 1 gate failed, 2 config or load error).
196
+ interactive viewer), `portfolio`, and `diff` against a snapshot. The read commands take `--json`;
197
+ exit codes are fixed (0 ok, 1 gate failed, 2 config or load error).
198
+ - **Coding-agent playbook.** `prodc agent >> AGENTS.md` teaches an agent to act as the project's
199
+ product manager: read the board, capture evidence with honest provenance, find proofs, gate
200
+ before a handoff. An agent may record `assumption` or `secondary` sources; `primary` is reserved
201
+ for a real user's own words. `prodc guide` is the short human onboarding guide.
175
202
 
176
203
  prodc also models goals, releases, roles, principles, decisions, attestations, withdrawals and
177
204
  settled answers, and ships hygiene commands (`dupes`, `recurrence`, `suggest`); the
178
205
  [docs](https://cardona-oss.gitlab.io/prodc/) have the full surface.
179
206
 
180
- ## Use it with a coding agent
181
-
182
- prodc ships a playbook that teaches an agent to act as the project's product manager: read the board, capture evidence with honest provenance, find proofs, gate before a handoff. An agent may record `assumption` or `secondary` sources; `primary` is reserved for a real user's own words.
183
-
184
- ```bash
185
- prodc agent >> AGENTS.md # or: prodc agent --write AGENTS.md
186
- prodc guide # the short human onboarding guide
187
- ```
188
-
189
207
  ## Links
190
208
 
191
- - Repository: <https://gitlab.com/cardona-oss/prodc>
192
- - PyPI: <https://pypi.org/project/prodc/>
193
- - Docs: <https://cardona-oss.gitlab.io/prodc/> — [Quickstart](https://cardona-oss.gitlab.io/prodc/quickstart/), [Commands](https://cardona-oss.gitlab.io/prodc/commands/), [Model](https://cardona-oss.gitlab.io/prodc/model/), [Config](https://cardona-oss.gitlab.io/prodc/config/), [Lifecycle](https://cardona-oss.gitlab.io/prodc/lifecycle/)
194
- - Changelog: [CHANGELOG.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/CHANGELOG.md)
209
+ - **Repository:** https://gitlab.com/cardona-oss/prodc
210
+ - **PyPI:** https://pypi.org/project/prodc/
211
+ - **Changelog:** [CHANGELOG.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/CHANGELOG.md)
212
+ - **Documentation:** <https://cardona-oss.gitlab.io/prodc/> ([Quickstart](https://cardona-oss.gitlab.io/prodc/quickstart/), [Commands](https://cardona-oss.gitlab.io/prodc/commands/), [Model](https://cardona-oss.gitlab.io/prodc/model/), [Config](https://cardona-oss.gitlab.io/prodc/config/), [Lifecycle](https://cardona-oss.gitlab.io/prodc/lifecycle/))
213
+
214
+ ## Contributing
195
215
 
196
- `AGENTS.md` holds build and test instructions for coding agents working on prodc itself.
216
+ Toolchain, CI, and the release process live in [CONTRIBUTING.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/CONTRIBUTING.md); build and test
217
+ instructions for coding agents are in [AGENTS.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/AGENTS.md). In short: `make setup`
218
+ then `make check` (lint + typecheck + test); `moon run :ci` is what CI runs.
197
219
 
198
220
  ## License
199
221
 
200
- MIT. See [LICENSE](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE).
222
+ MIT © Jorge Cardona. See [LICENSE](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE).
@@ -1,14 +1,41 @@
1
1
  # prodc
2
2
 
3
- [![PyPI](https://img.shields.io/pypi/v/prodc)](https://pypi.org/project/prodc/)
3
+ [![pipeline status](https://gitlab.com/cardona-oss/prodc/badges/main/pipeline.svg)](https://gitlab.com/cardona-oss/prodc/-/pipelines)
4
+ [![PyPI version](https://img.shields.io/pypi/v/prodc.svg)](https://pypi.org/project/prodc/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/prodc.svg)](https://pypi.org/project/prodc/)
4
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE)
5
7
 
6
- Product management as code: the *needs* your product exists to meet, each traced to the evidence a real user gave for it and the test that proves it is delivered. For teams that keep product work in the repo, next to the code, and want a tool to check it holds together.
8
+ Product management as code: user *needs* in YAML, each traced to the evidence a real user gave
9
+ and the test that proves it is delivered. For teams that keep product work in the repo, next to
10
+ the code.
7
11
 
8
- prodc reads plain YAML files and your test suite and shows you the gaps. It does not call a model, host a UI, or replace your tracker. Two rules carry it:
12
+ prodc reads plain YAML files and your test suite and shows you the gaps. It does not call a model,
13
+ host a UI, or replace your tracker. Two rules carry it:
9
14
 
10
- 1. **A need with no evidence is an opinion.** Write down who asked — an interview quote, a ticket, an email — or prodc flags the need as an opinion, because that is what it is until someone outside the room has asked.
11
- 2. **"Done" is a claim your tests back.** You never mark a need delivered. You attach a *proof* (a locator into your test suite), and `prodc status` resolves it: DELIVERED, PENDING, HOLE (an honest gap), or BROKEN (points at nothing). The state is read from the repo, not remembered from a standup.
15
+ 1. **A need with no evidence is an opinion.** Write down who asked (an interview quote, a ticket,
16
+ an email) or prodc flags the need as an opinion.
17
+ 2. **"Done" is a claim your tests back.** You never mark a need delivered. You attach a *proof*
18
+ (a locator into your test suite) and `prodc status` resolves it: DELIVERED, PENDING, HOLE, or
19
+ BROKEN. The state is read from the repo, not remembered from a standup.
20
+
21
+ ## Why prodc
22
+
23
+ - **Versus Jira, Linear, other trackers.** A tracker records work items and a status someone set
24
+ by hand. prodc records *needs* and computes their status from the tests. No assignees, sprints,
25
+ comments, or notifications. Keep the tracker for the work; keep prodc for "did a real user ask
26
+ for this, and does a test prove it?" They connect at the edges: a source can be a ticket
27
+ locator (checked read-only against GitLab with `[sources.<name>] kind = "gitlab"`), a proof can
28
+ be any script (`kind = "command"`), and `prodc sync` fires a hook when a need changes state.
29
+ - **Versus a PRD in Notion or a wiki.** A document says what should be true; nothing checks it
30
+ later. prodc's files are validated on every load, linted in CI, and reviewed in merge requests
31
+ like code. "Is this done?" is answered by `prodc show N-1`.
32
+ - **Versus plain YAML in the repo.** You could keep this structure by hand. prodc adds the schema,
33
+ proof resolution against real test suites, the evidence ladder, the lint, and write-back
34
+ commands that keep the files consistent.
35
+
36
+ **What it does not do.** It does not run your tests, call an LLM, or ping a tracker on its own.
37
+ It does not model the full discovery loop (opportunity-solution trees, parallel solution
38
+ testing); it records needs, the evidence under them, and the decisions taken on them.
12
39
 
13
40
  ## Install
14
41
 
@@ -20,13 +47,13 @@ Python 3.12+. The only runtime dependency is PyYAML.
20
47
 
21
48
  ## Quickstart
22
49
 
23
- Three files. The first tells prodc where to look, the second holds what people need, the third is a test you have probably already written.
50
+ Three files: where prodc looks, what people need, and a test you have probably already written.
24
51
 
25
52
  `prodc.toml`
26
53
 
27
54
  ```toml
28
55
  [prodc]
29
- needs = ["needs/*.yaml"]
56
+ files = ["needs/*.yaml"]
30
57
 
31
58
  [proofs.web]
32
59
  kind = "gherkin"
@@ -90,9 +117,13 @@ jordan 1 DELIVERED 2 HOLE
90
117
  ⚠ DELIVERED on an unattested proof link — warrant not reviewed
91
118
  ```
92
119
 
93
- N-1 is DELIVERED because a tagged scenario proves it (the `*` means declared by tag, no test results loaded yet). N-2 has evidence but no proof: a HOLE. N-3 has neither: an opinion.
120
+ - **N-1 is DELIVERED** because a tagged scenario proves it (`*` = declared by tag, no test results
121
+ loaded yet).
122
+ - **N-2 is a HOLE**: evidence, but no proof.
123
+ - **N-3 is an opinion**: neither.
94
124
 
95
- Now close the loop from the command line. Each write command rewrites one YAML file, re-validates the whole model, and prints the diff.
125
+ Close the loop from the command line. Each write command rewrites one YAML file, re-validates the
126
+ whole model, and prints the diff.
96
127
 
97
128
  ```bash
98
129
  # trace the opinion to evidence
@@ -110,17 +141,8 @@ prodc status # gaps: 1 PENDING · 1 HOLE — the scenario exists, so N-2
110
141
  prodc lint # CI gate: exit 1 on a BROKEN proof or a must-have with no evidence
111
142
  ```
112
143
 
113
- Change a scenario title without updating the need and the proof turns BROKEN; `prodc lint` exits 1. That is the whole idea: the board cannot drift from the code.
114
-
115
- ## Why prodc
116
-
117
- **Versus Jira, Linear, and other trackers.** A tracker records work items and a status someone set by hand. prodc records *needs* and computes their status from the tests. It has no assignees, sprints, comments, or notifications, and it is not trying to. Keep the tracker for the work; keep prodc for the question "did a real user ask for this, and does a test prove it?" The two connect at the edges: a need's source can be a ticket locator (checked read-only against GitLab with `[sources.<name>] kind = "gitlab"`), a proof can be resolved by any script you point at a tracker (`kind = "command"`), and `prodc sync` fires a hook when a need changes state.
118
-
119
- **Versus a PRD in Notion or a wiki.** A document says what should be true; nothing checks it later. prodc's files are validated on every load (ids, cross-references, source hygiene), linted in CI, and reviewed in merge requests like code. "Is this done?" is answered by `prodc show N-1`, not by finding the right page.
120
-
121
- **Versus plain YAML in the repo.** You could keep this structure by hand. prodc adds the schema, the proof resolution against real test suites, the evidence ladder, the lint, and the write-back commands that keep the files consistent.
122
-
123
- **What it does not do.** It does not run your tests, call an LLM, or ping a tracker on its own. It does not model the full discovery loop (opportunity-solution-tree restructuring, parallel solution testing); it records needs, the evidence under them, and the decisions taken on them.
144
+ Rename a scenario without updating the need and the proof turns BROKEN; `prodc lint` exits 1.
145
+ That is the whole idea: the board cannot drift from the code.
124
146
 
125
147
  ## Key features
126
148
 
@@ -135,39 +157,37 @@ Change a scenario title without updating the need and the proof turns BROKEN; `p
135
157
  - **Flows.** A need with `children` rolls them up, with `seam` proofs for the behaviour between
136
158
  steps. Any BROKEN child breaks the flow; a flow is DELIVERED only when every child and seam is
137
159
  green.
138
- - **The roll-up is machine-checked.** `tests/test_models.py` builds a
160
+ - **Machine-checked roll-up.** `tests/test_models.py` builds a
139
161
  [musil](https://pypi.org/project/musil/) model of the lifecycle and explores every reachable
140
162
  state against the real resolver, so the documented invariants cannot drift from the code.
141
163
  - **Write-back that respects your files.** `set`, `capture`, `accept`, `attest`, `withdraw`, `add`
142
164
  splice one need's block back into the original YAML, keeping other comments. All take `--dry-run`
143
165
  and `--expect-sha`.
144
166
  - **Boards for humans.** `status`, `show`, `brief`, `plan`, `report --format html` (a self-contained
145
- interactive viewer), `portfolio`, and `diff` against a snapshot. The read commands (`status`,
146
- `lint`, `show`, `brief`, `plan`, `diff`, `portfolio`, `dupes`, `recurrence`, `suggest`, `review`)
147
- take `--json`; exit codes are fixed (0 ok, 1 gate failed, 2 config or load error).
167
+ interactive viewer), `portfolio`, and `diff` against a snapshot. The read commands take `--json`;
168
+ exit codes are fixed (0 ok, 1 gate failed, 2 config or load error).
169
+ - **Coding-agent playbook.** `prodc agent >> AGENTS.md` teaches an agent to act as the project's
170
+ product manager: read the board, capture evidence with honest provenance, find proofs, gate
171
+ before a handoff. An agent may record `assumption` or `secondary` sources; `primary` is reserved
172
+ for a real user's own words. `prodc guide` is the short human onboarding guide.
148
173
 
149
174
  prodc also models goals, releases, roles, principles, decisions, attestations, withdrawals and
150
175
  settled answers, and ships hygiene commands (`dupes`, `recurrence`, `suggest`); the
151
176
  [docs](https://cardona-oss.gitlab.io/prodc/) have the full surface.
152
177
 
153
- ## Use it with a coding agent
154
-
155
- prodc ships a playbook that teaches an agent to act as the project's product manager: read the board, capture evidence with honest provenance, find proofs, gate before a handoff. An agent may record `assumption` or `secondary` sources; `primary` is reserved for a real user's own words.
156
-
157
- ```bash
158
- prodc agent >> AGENTS.md # or: prodc agent --write AGENTS.md
159
- prodc guide # the short human onboarding guide
160
- ```
161
-
162
178
  ## Links
163
179
 
164
- - Repository: <https://gitlab.com/cardona-oss/prodc>
165
- - PyPI: <https://pypi.org/project/prodc/>
166
- - Docs: <https://cardona-oss.gitlab.io/prodc/> — [Quickstart](https://cardona-oss.gitlab.io/prodc/quickstart/), [Commands](https://cardona-oss.gitlab.io/prodc/commands/), [Model](https://cardona-oss.gitlab.io/prodc/model/), [Config](https://cardona-oss.gitlab.io/prodc/config/), [Lifecycle](https://cardona-oss.gitlab.io/prodc/lifecycle/)
167
- - Changelog: [CHANGELOG.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/CHANGELOG.md)
180
+ - **Repository:** https://gitlab.com/cardona-oss/prodc
181
+ - **PyPI:** https://pypi.org/project/prodc/
182
+ - **Changelog:** [CHANGELOG.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/CHANGELOG.md)
183
+ - **Documentation:** <https://cardona-oss.gitlab.io/prodc/> ([Quickstart](https://cardona-oss.gitlab.io/prodc/quickstart/), [Commands](https://cardona-oss.gitlab.io/prodc/commands/), [Model](https://cardona-oss.gitlab.io/prodc/model/), [Config](https://cardona-oss.gitlab.io/prodc/config/), [Lifecycle](https://cardona-oss.gitlab.io/prodc/lifecycle/))
184
+
185
+ ## Contributing
168
186
 
169
- `AGENTS.md` holds build and test instructions for coding agents working on prodc itself.
187
+ Toolchain, CI, and the release process live in [CONTRIBUTING.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/CONTRIBUTING.md); build and test
188
+ instructions for coding agents are in [AGENTS.md](https://gitlab.com/cardona-oss/prodc/-/blob/main/AGENTS.md). In short: `make setup`
189
+ then `make check` (lint + typecheck + test); `moon run :ci` is what CI runs.
170
190
 
171
191
  ## License
172
192
 
173
- MIT. See [LICENSE](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE).
193
+ MIT © Jorge Cardona. See [LICENSE](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE).
@@ -1,13 +1,20 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.4.7"
3
+ dynamic = ["version"] # the single source of truth is __version__ in src/prodc/__init__.py (see [tool.hatch.version])
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = "MIT"
8
8
  license-files = ["LICENSE"]
9
9
  authors = [{ name = "Jorge Cardona", email = "jorgeecardona@gmail.com" }]
10
- keywords = []
10
+ keywords = [
11
+ "product-management",
12
+ "requirements",
13
+ "traceability",
14
+ "evidence",
15
+ "yaml",
16
+ "testing",
17
+ ]
11
18
  classifiers = [
12
19
  "Development Status :: 4 - Beta",
13
20
  "Intended Audience :: Developers",
@@ -34,13 +41,25 @@ docs = [
34
41
  [project.urls]
35
42
  Homepage = "https://gitlab.com/cardona-oss/prodc"
36
43
  Repository = "https://gitlab.com/cardona-oss/prodc"
44
+ Issues = "https://gitlab.com/cardona-oss/prodc/-/issues"
37
45
  Changelog = "https://gitlab.com/cardona-oss/prodc/-/blob/main/CHANGELOG.md"
38
46
  Documentation = "https://cardona-oss.gitlab.io/prodc/"
39
47
 
48
+ [tool.uv]
49
+ # After the pre-commit hook auto-bumps __version__ in the package __init__, uv must treat the
50
+ # editable install as stale and reinstall it; without the __init__ key the next `make check`
51
+ # runs against the old version. (pyproject is already an implicit key; __init__ is the version source.)
52
+ cache-keys = [{ file = "pyproject.toml" }, { file = "src/prodc/__init__.py" }]
53
+
40
54
  [build-system]
41
55
  requires = ["hatchling"]
42
56
  build-backend = "hatchling.build"
43
57
 
58
+ [tool.hatch.version]
59
+ # The version lives as __version__ in the package itself; hatchling reads it (by regex, no import) so
60
+ # pyproject and the runtime never disagree. scripts/bump-version rewrites that literal.
61
+ path = "src/prodc/__init__.py"
62
+
44
63
  [tool.hatch.build.targets.wheel]
45
64
  packages = ["src/prodc"]
46
65
 
@@ -0,0 +1,3 @@
1
+ """prodc: product management as code."""
2
+
3
+ __version__ = "0.4.8" # the single source of truth; pyproject reads it via [tool.hatch.version]
@@ -63,19 +63,23 @@ Read first, then act, then check.
63
63
 
64
64
  ## The commands you will use most
65
65
 
66
+ `--file` takes any file matched by `[prodc].files`; every noun may live in any of them, so roles,
67
+ principles and decisions can have their own files (say `product/decisions.yaml`) instead of
68
+ sharing a needs file.
69
+
66
70
  | To… | Run |
67
71
  |---|---|
68
72
  | see the board | `prodc status [--client ID] [--json]` |
69
- | read one need | `prodc show <id>` |
73
+ | read one need or decision | `prodc show <id>` |
70
74
  | rank the backlog | `prodc plan` |
71
75
  | prep a client meeting | `prodc brief <client>` |
72
76
  | record a new need (with honest provenance) | `prodc add need <id> --text "…" --who <you> --kind assumption --file needs/<f>.yaml` |
73
77
  | triage staged drafts | `prodc review` — list them; `--promote <id>…`/`--promote-all` onto the board, `--drop <id>…` to delete |
74
- | declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
75
- | declare a product principle (the vision, held as given) | `prodc add principle <id> --text "…" --file needs/<f>.yaml` — the frame that decides between clients who want opposite things; what an `out_of_scope` decision cites |
78
+ | declare a role (the roster) | `prodc add role <id> --text "…" --file product/roles.yaml` |
79
+ | declare a product principle (the vision, held as given) | `prodc add principle <id> --text "…" --file product/principles.yaml` — the frame that decides between clients who want opposite things; what an `out_of_scope` decision cites |
76
80
  | revise a principle (fires the re-review ripple) | `prodc revise principle <id> [--text "…"] [--date ISO]` — re-stamps `revised_at`, so every need withdrawn under it with an earlier date comes back up |
77
- | promote a need you commit to into a principle | `prodc add principle <id> --from-need <need-id> --file needs/<f>.yaml` — copies the need's text (unless `--text` overrides) and its sources (the client signals that surfaced it); the need stays on the board, so a need and the principle it inspired coexist |
78
- | record how a need gets built (the chosen solution) | `prodc add decision <id> --text "<the solution>" --needs <ids> [--alternative "<rejected>" …] [--consequences "…"] [--principles <ids>] --file needs/<f>.yaml` — the OST node: which solution you picked for a need, over what, resting on which principle. `--needs` is required (a decision with no need is refused); `lint` flags one left serving only withdrawn needs, or decided before a principle it cites was revised |
81
+ | promote a need you commit to into a principle | `prodc add principle <id> --from-need <need-id> --file product/principles.yaml` — copies the need's text (unless `--text` overrides) and its sources (the client signals that surfaced it); the need stays on the board, so a need and the principle it inspired coexist |
82
+ | record how a need gets built (the chosen solution) | `prodc add decision <id> --text "<the solution>" --needs <ids> [--alternative "<rejected>" …] [--consequences "…"] [--principles <ids>] --file product/decisions.yaml` — the OST node: which solution you picked for a need, over what, resting on which principle. `--needs` is required (a decision with no need is refused); `lint` flags one left serving only withdrawn needs, or decided before a principle it cites was revised |
79
83
  | attach evidence after a user said something | `prodc capture <id> --kind primary --who <person> [--role <id>] --date <ISO> --quote "…"` (`--role` records which declared role vouched for it) |
80
84
  | set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
81
85
  | mark two needs as opposing each other | `prodc set <id> contradicts+=<other-id>` — symmetric (declare once); `lint` flags a pair left both-live (clients want opposite things and the vision has not decided), and `withdraw … --kind out_of_scope --principle <id>` resolves it |
@@ -122,8 +122,8 @@ def main(argv: list[str] | None = None) -> int:
122
122
  ln.add_argument("--strict", action="store_true", help="also fail on any hygiene warning")
123
123
  ln.set_defaults(func=cmd_lint)
124
124
 
125
- sh = sub.add_parser("show", help="show one need as a citable answer")
126
- sh.add_argument("need_id", help="the need id to show, e.g. N-1")
125
+ sh = sub.add_parser("show", help="show one need or decision as a citable answer")
126
+ sh.add_argument("need_id", help="the need or decision id to show, e.g. N-1 or D-1")
127
127
  sh.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
128
128
  sh.add_argument("--json", action="store_true", help="machine-readable output")
129
129
  sh.set_defaults(func=cmd_show)
@@ -546,7 +546,7 @@ def main(argv: list[str] | None = None) -> int:
546
546
  "--file",
547
547
  default=None,
548
548
  metavar="PATH",
549
- help="file to write a created baseline into (must match a [prodc].needs glob)",
549
+ help="file to write a created baseline into (must match a [prodc].files glob)",
550
550
  )
551
551
  rc.add_argument(
552
552
  "--dry-run", action="store_true", help="with --apply: validate and show diffs, don't write"
@@ -88,6 +88,7 @@ from .render import (
88
88
  render_brief,
89
89
  render_columns,
90
90
  render_contradictions,
91
+ render_decision_show,
91
92
  render_decisions,
92
93
  render_goals,
93
94
  render_handoffs,
@@ -104,6 +105,7 @@ from .render import (
104
105
  )
105
106
  from .serialize import (
106
107
  brief_json,
108
+ decision_show_json,
107
109
  dup_payload,
108
110
  plan_json,
109
111
  show_json,
@@ -442,8 +444,25 @@ def cmd_show(args: argparse.Namespace) -> int:
442
444
  by_id = {r.need.id: r for r in resolve(needs, cfg.adapters, cfg.source_adapters)}
443
445
  r = by_id.get(args.need_id)
444
446
  if r is None:
445
- print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
446
- return 2
447
+ decision = next((d for d in model.decisions if d.id == args.need_id), None)
448
+ if decision is None:
449
+ print(f"prodc: no need or decision '{args.need_id}'", file=sys.stderr)
450
+ return 2
451
+ # A decision is citable too (#95): the same warnings lint raises for it, shown inline.
452
+ warnings: list[str] = []
453
+ if decision in orphaned_decisions(list(model.decisions), needs):
454
+ warnings.append("serves only withdrawn/non-product needs — re-tie it or withdraw it")
455
+ warnings += [
456
+ f"decided {decision.decided_at} under principle {pid}, revised later — re-review"
457
+ for d, pid in stale_decisions(list(model.decisions), list(model.principles))
458
+ if d is decision
459
+ ]
460
+ print(
461
+ decision_show_json(decision, warnings)
462
+ if args.json
463
+ else render_decision_show(decision, needs, warnings)
464
+ )
465
+ return 0
447
466
  decisions = decisions_for(args.need_id, list(model.decisions)) # the 'how' behind this need
448
467
  print(
449
468
  show_json(r, decisions=decisions)
@@ -972,8 +991,6 @@ def cmd_capture(args: argparse.Namespace) -> int:
972
991
  return emit_edit(edit, args.dry_run, args.json)
973
992
 
974
993
 
975
-
976
-
977
994
  def _repo_row(config_path: str) -> RepoRow:
978
995
  p = Path(config_path)
979
996
  name = p.parent.name or str(p)
@@ -219,7 +219,9 @@ def render_goals(goal_cov: list[tuple[Goal, Summary]], ungoaled: list[NeedResult
219
219
  state = group_state(gsum)
220
220
  metric = f" [{goal.metric}]" if goal.metric else ""
221
221
  pct = round(gsum.coverage * 100)
222
- overdue = goal.target is not None and goal.target < today and state is not NeedState.DELIVERED
222
+ overdue = (
223
+ goal.target is not None and goal.target < today and state is not NeedState.DELIVERED
224
+ )
223
225
  target = ""
224
226
  if goal.target:
225
227
  target = f" ⚠ overdue {goal.target}" if overdue else f" target {goal.target}"
@@ -314,9 +316,7 @@ def _principle_tally(reports: list[PrincipleReport]) -> str:
314
316
  counts[rep.state] = counts.get(rep.state, 0) + 1
315
317
  parts: list[str] = []
316
318
  for scope, counts in by_scope.items():
317
- inner = ", ".join(
318
- f"{counts[s]} {s.value}" for s in PrincipleState if counts.get(s)
319
- )
319
+ inner = ", ".join(f"{counts[s]} {s.value}" for s in PrincipleState if counts.get(s))
320
320
  parts.append(f"{scope}: {inner}")
321
321
  return " · ".join(parts)
322
322
 
@@ -352,6 +352,35 @@ def render_decisions(decisions: list[Decision], needs: list[Need]) -> str:
352
352
  return "\n".join(out)
353
353
 
354
354
 
355
+ def render_decision_show(d: Decision, needs: list[Need], warnings: list[str]) -> str:
356
+ """One decision as a citable answer (#95): the chosen solution, what it serves and rejected.
357
+
358
+ The decision counterpart of :func:`render_show` — ``prodc show D-1`` answers "how is this
359
+ built, over what, and why" the way ``prodc show N-1`` answers "is this delivered".
360
+ """
361
+ by_id = {n.id: n for n in needs}
362
+ tag = " (proposed)" if d.draft else ""
363
+ lines = [f"{d.id}{tag} {d.text}"]
364
+ lines.append(" serves:")
365
+ for nid in d.needs:
366
+ n = by_id.get(nid)
367
+ lines.append(f" - {nid}: {n.text}" if n else f" - {nid}")
368
+ if d.alternatives:
369
+ lines.append(f" over: {', '.join(d.alternatives)}")
370
+ if d.consequences:
371
+ lines.append(f" consequences: {d.consequences}")
372
+ if d.principles:
373
+ lines.append(f" rests on: {', '.join(d.principles)}")
374
+ if d.decided_at:
375
+ lines.append(f" decided: {d.decided_at}")
376
+ if d.withdrawn:
377
+ lines.append(
378
+ f" withdrawn ({d.withdrawn.kind.value}, {d.withdrawn.date}): {d.withdrawn.reason}"
379
+ )
380
+ lines += [f" ⚠ {w}" for w in warnings]
381
+ return "\n".join(lines)
382
+
383
+
355
384
  def render_show(
356
385
  r: NeedResult,
357
386
  importance_label: str | None = None,
@@ -416,9 +445,7 @@ def render_show(
416
445
  else:
417
446
  lines.append(" sources: (none — opinion)")
418
447
  if need.children:
419
- rendered = ", ".join(
420
- c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children
421
- )
448
+ rendered = ", ".join(c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children)
422
449
  lines.append(f" children: {rendered}")
423
450
  if need.contradicts:
424
451
  lines.append(f" contradicts: {', '.join(need.contradicts)}")
@@ -145,6 +145,13 @@ def to_json(
145
145
  return json.dumps(payload, indent=2)
146
146
 
147
147
 
148
+ def decision_show_json(d: Decision, warnings: list[str]) -> str:
149
+ """``prodc show D-1 --json`` (#95): the decision record plus its lint-style warnings."""
150
+ payload = _decision_payload(d)
151
+ payload["warnings"] = warnings
152
+ return json.dumps(payload, indent=2)
153
+
154
+
148
155
  def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
149
156
  need = r.need
150
157
  payload = need_payload(r) # the shared core — never drifts from the board
@@ -106,7 +106,9 @@ def _callbacks(raw: dict[str, object]) -> tuple[tuple[Callback, ...], str]:
106
106
  @dataclass
107
107
  class Config:
108
108
  root: Path
109
- need_globs: list[str] # the prodc files; each may carry any noun list (needs:, goals:, …)
109
+ # [prodc].files (+ the older alias [prodc].needs): the prodc files. Every matched file may
110
+ # carry any noun list (needs:, goals:, roles:, principles:, decisions:, …) — #95.
111
+ need_globs: list[str]
110
112
  adapters: dict[str, Adapter] = field(default_factory=dict[str, Adapter])
111
113
  source_adapters: dict[str, SourceAdapter] = field(default_factory=dict[str, SourceAdapter])
112
114
  duplicate_threshold: float = 0.8 # [lint].duplicate_threshold — dupe score to flag (#55)
@@ -131,6 +133,21 @@ class Config:
131
133
  """
132
134
  return self.flow_steps[-1] if self.flow_steps else None
133
135
 
136
+ def prodc_files(self) -> list[Path]:
137
+ """Every file matched by the configured globs, each once, in glob-then-name order.
138
+
139
+ A path matched by two globs (say ``files`` and ``needs`` overlap) is read once, so
140
+ its ids are not reported as duplicates of themselves.
141
+ """
142
+ seen: set[Path] = set()
143
+ out: list[Path] = []
144
+ for pattern in self.need_globs:
145
+ for path in sorted(self.root.glob(pattern)):
146
+ if path not in seen:
147
+ seen.add(path)
148
+ out.append(path)
149
+ return out
150
+
134
151
  def importance_label(self, level: int | None) -> str | None:
135
152
  """The declared label for an importance level, or None if unset/undeclared (#59)."""
136
153
  if level is None:
@@ -166,9 +183,14 @@ class Config:
166
183
  raw = _as_dict(tomllib.loads(path.read_text(encoding="utf-8")), "prodc.toml")
167
184
 
168
185
  prodc_tbl = _as_dict(raw.get("prodc", {}), "[prodc]")
169
- need_globs = _as_str_list(prodc_tbl.get("needs", []), "[prodc].needs")
186
+ # `files` is the neutral name (#95); `needs` stays as an alias and the two are merged, so
187
+ # an existing config keeps working and a repo can split decisions/roles into their own
188
+ # folder without listing them under a key that says "needs".
189
+ files = _as_str_list(prodc_tbl.get("files", []), "[prodc].files")
190
+ needs = _as_str_list(prodc_tbl.get("needs", []), "[prodc].needs")
191
+ need_globs = files + [g for g in needs if g not in files]
170
192
  if not need_globs:
171
- raise ConfigError("[prodc].needs must list at least one glob")
193
+ raise ConfigError("[prodc].files must list at least one glob")
172
194
 
173
195
  adapters: dict[str, Adapter] = {}
174
196
  proofs = _as_dict(raw.get("proofs", {}), "[proofs]")
@@ -17,7 +17,7 @@ Three files make a board.
17
17
 
18
18
  ```toml
19
19
  [prodc]
20
- needs = ["needs/*.yaml"]
20
+ files = ["needs/*.yaml"]
21
21
  ```
22
22
 
23
23
  `needs/needs.yaml`:
@@ -529,15 +529,14 @@ def load(config: Config) -> Model:
529
529
  "principle": {},
530
530
  "decision": {},
531
531
  }
532
- for pattern in config.need_globs:
533
- for path in sorted(config.root.glob(pattern)):
534
- f = _load_file(path, seen)
535
- acc.needs.extend(f.needs)
536
- acc.goals.extend(f.goals)
537
- acc.releases.extend(f.releases)
538
- acc.roles.extend(f.roles)
539
- acc.principles.extend(f.principles)
540
- acc.decisions.extend(f.decisions)
532
+ for path in config.prodc_files():
533
+ f = _load_file(path, seen)
534
+ acc.needs.extend(f.needs)
535
+ acc.goals.extend(f.goals)
536
+ acc.releases.extend(f.releases)
537
+ acc.roles.extend(f.roles)
538
+ acc.principles.extend(f.principles)
539
+ acc.decisions.extend(f.decisions)
541
540
  _check_variants(acc.needs)
542
541
  _check_children(acc.needs)
543
542
  _check_contradicts(acc.needs)
@@ -652,12 +651,16 @@ def _check_withdrawal_of(needs: list[Need]) -> None:
652
651
  if w is None:
653
652
  continue
654
653
  if w.kind is WithdrawalKind.DUPLICATE and w.of is None:
655
- raise LoaderError(f"need {need.id!r}: a duplicate withdrawal must name 'of' (the original)")
654
+ raise LoaderError(
655
+ f"need {need.id!r}: a duplicate withdrawal must name 'of' (the original)"
656
+ )
656
657
  if w.of is not None:
657
658
  if w.of == need.id:
658
659
  raise LoaderError(f"need {need.id!r}: withdrawn 'of' cannot be itself")
659
660
  if w.of not in known:
660
- raise LoaderError(f"need {need.id!r}: withdrawn 'of' {w.of!r} is not a known need id")
661
+ raise LoaderError(
662
+ f"need {need.id!r}: withdrawn 'of' {w.of!r} is not a known need id"
663
+ )
661
664
 
662
665
 
663
666
  def _check_decisions(
@@ -336,20 +336,9 @@ def _proof_list(need: dict[str, object], path: Path) -> list[object]:
336
336
  return list(cast("list[object]", current))
337
337
 
338
338
 
339
- def _config_files(config: Config) -> list[Path]:
340
- seen: set[Path] = set()
341
- out: list[Path] = []
342
- for pattern in config.need_globs:
343
- for path in sorted(config.root.glob(pattern)):
344
- if path not in seen:
345
- seen.add(path)
346
- out.append(path)
347
- return out
348
-
349
-
350
339
  def _locate(config: Config, need_id: str) -> tuple[Path, dict[str, object]]:
351
340
  """Find the file and the raw need mapping for ``need_id``."""
352
- for path in _config_files(config):
341
+ for path in config.prodc_files():
353
342
  doc, _ = _read_doc(path)
354
343
  for raw in _need_list(doc, path):
355
344
  if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
@@ -411,7 +400,7 @@ def _guard_target(config: Config, path: Path) -> None:
411
400
  except ValueError:
412
401
  raise WriteError(f"{path} is outside the project root") from None
413
402
  if not any(rel.match(pattern) for pattern in config.need_globs):
414
- raise WriteError(f"{path.name} does not match any [prodc].needs glob")
403
+ raise WriteError(f"{path.name} does not match any [prodc].files glob")
415
404
 
416
405
 
417
406
  def _validated(config: Config, path: Path, old_text: str, new_text: str, dry_run: bool) -> Edit:
@@ -496,9 +485,7 @@ def set_importance(
496
485
  need["importance"] = level
497
486
  need["importance_set_at"] = when
498
487
 
499
- return _edit_one_need(
500
- config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
501
- )
488
+ return _edit_one_need(config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate)
502
489
 
503
490
 
504
491
  def capture_source(
@@ -544,9 +531,7 @@ def capture_source(
544
531
  sources.append(source)
545
532
  need["sources"] = sources
546
533
 
547
- return _edit_one_need(
548
- config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
549
- )
534
+ return _edit_one_need(config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate)
550
535
 
551
536
 
552
537
  def attest(
@@ -632,14 +617,12 @@ def reinstate(config: Config, need_id: str, *, expect_sha: str | None, dry_run:
632
617
  raise WriteError(f"need {need_id!r} is not withdrawn")
633
618
  del need["withdrawn"]
634
619
 
635
- return _edit_one_need(
636
- config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
637
- )
620
+ return _edit_one_need(config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate)
638
621
 
639
622
 
640
623
  def _locate_decision(config: Config, decision_id: str) -> tuple[Path, dict[str, object], str]:
641
624
  """The file holding ``decision_id``, its parsed doc, and its raw text."""
642
- for path in _config_files(config):
625
+ for path in config.prodc_files():
643
626
  doc, text = _read_doc(path)
644
627
  raw = doc.get("decisions")
645
628
  if not isinstance(raw, list):
@@ -745,9 +728,7 @@ def accept(
745
728
  proofs.append(locator)
746
729
  need["proofs"] = proofs
747
730
 
748
- return _edit_one_need(
749
- config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
750
- )
731
+ return _edit_one_need(config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate)
751
732
 
752
733
 
753
734
  def add_proof(
@@ -759,15 +740,14 @@ def add_proof(
759
740
  batch apply can call it per need and show each need's diff. Re-adding a locator already on the
760
741
  need is a no-op, so applying the same suggestion twice does not duplicate it (#54).
761
742
  """
743
+
762
744
  def _mutate(need: dict[str, object], path: Path) -> None:
763
745
  proofs = _proof_list(need, path)
764
746
  if locator not in proofs:
765
747
  proofs.append(locator)
766
748
  need["proofs"] = proofs
767
749
 
768
- return _edit_one_need(
769
- config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
770
- )
750
+ return _edit_one_need(config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate)
771
751
 
772
752
 
773
753
  def relink_proof(
@@ -787,6 +767,7 @@ def relink_proof(
787
767
  old locator is not actually on the need. One reload-gated single-file splice, like
788
768
  :func:`add_proof`.
789
769
  """
770
+
790
771
  def _mutate(need: dict[str, object], path: Path) -> None:
791
772
  proofs = _proof_list(need, path)
792
773
  if old_locator not in proofs:
@@ -798,9 +779,7 @@ def relink_proof(
798
779
  deduped.append(p)
799
780
  need["proofs"] = deduped
800
781
 
801
- return _edit_one_need(
802
- config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
803
- )
782
+ return _edit_one_need(config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate)
804
783
 
805
784
 
806
785
  def add_need(
@@ -963,7 +942,7 @@ def add_principle(
963
942
 
964
943
  def _locate_principle(config: Config, principle_id: str) -> tuple[Path, dict[str, object], str]:
965
944
  """The file holding ``principle_id``, its parsed doc, and its raw text."""
966
- for path in _config_files(config):
945
+ for path in config.prodc_files():
967
946
  doc, text = _read_doc(path)
968
947
  raw = doc.get("principles")
969
948
  if not isinstance(raw, list):
@@ -1110,7 +1089,7 @@ def link_variant_group(
1110
1089
  snapshotted up front; any rejection — or ``dry_run`` — restores every one, so the group is
1111
1090
  linked all-or-nothing.
1112
1091
  """
1113
- touched = _config_files(config)
1092
+ touched = config.prodc_files()
1114
1093
  if create_baseline:
1115
1094
  if not baseline_file or baseline_text is None:
1116
1095
  raise WriteError("creating a baseline needs both a file and text")
@@ -0,0 +1,103 @@
1
+ """`[prodc].files` (#95): one neutral key for the prodc files, `needs` kept as an alias, and
2
+ `prodc show` answering for a decision as well as a need."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from prodc.config import Config, ConfigError
12
+ from prodc.loader import load
13
+
14
+ _NEEDS = "needs:\n - id: N-1\n text: see past-due orders\n"
15
+ _DECISIONS = """decisions:
16
+ - id: D-1
17
+ text: highlight rows in red
18
+ needs: [N-1]
19
+ alternatives: [a separate past-due tab]
20
+ consequences: colour carries meaning
21
+ """
22
+
23
+
24
+ def _project(tmp_path: Path, prodc_tbl: str) -> Path:
25
+ (tmp_path / "product" / "needs").mkdir(parents=True)
26
+ (tmp_path / "product" / "decisions").mkdir(parents=True)
27
+ (tmp_path / "product" / "needs" / "a.yaml").write_text(_NEEDS, encoding="utf-8")
28
+ (tmp_path / "product" / "decisions" / "d.yaml").write_text(_DECISIONS, encoding="utf-8")
29
+ (tmp_path / "prodc.toml").write_text(f"[prodc]\n{prodc_tbl}\n", encoding="utf-8")
30
+ return tmp_path / "prodc.toml"
31
+
32
+
33
+ def test_files_key_reads_every_noun_from_its_own_folder(tmp_path: Path) -> None:
34
+ cfg = Config.load(
35
+ _project(tmp_path, 'files = ["product/needs/*.yaml", "product/decisions/*.yaml"]')
36
+ )
37
+ model = load(cfg)
38
+ assert [n.id for n in model.needs] == ["N-1"]
39
+ assert [d.id for d in model.decisions] == ["D-1"]
40
+
41
+
42
+ def test_needs_key_still_works_as_alias(tmp_path: Path) -> None:
43
+ cfg = Config.load(
44
+ _project(tmp_path, 'needs = ["product/needs/*.yaml", "product/decisions/*.yaml"]')
45
+ )
46
+ assert [d.id for d in load(cfg).decisions] == ["D-1"]
47
+
48
+
49
+ def test_files_and_needs_merge_and_overlap_is_read_once(tmp_path: Path) -> None:
50
+ # product/needs/*.yaml is matched by both keys: it must load once, not as a duplicate id.
51
+ toml = 'files = ["product/**/*.yaml"]\nneeds = ["product/needs/*.yaml"]'
52
+ cfg = Config.load(_project(tmp_path, toml))
53
+ assert cfg.need_globs == ["product/**/*.yaml", "product/needs/*.yaml"]
54
+ model = load(cfg)
55
+ assert [n.id for n in model.needs] == ["N-1"]
56
+ assert [d.id for d in model.decisions] == ["D-1"]
57
+
58
+
59
+ def test_no_glob_is_a_config_error_naming_files(tmp_path: Path) -> None:
60
+ with pytest.raises(ConfigError, match=r"\[prodc\]\.files must list at least one glob"):
61
+ Config.load(_project(tmp_path, ""))
62
+
63
+
64
+ def test_write_outside_globs_names_files_key(tmp_path: Path) -> None:
65
+ from prodc.writer import WriteError, add_principle
66
+
67
+ cfg = Config.load(_project(tmp_path, 'files = ["product/needs/*.yaml"]'))
68
+ with pytest.raises(WriteError, match=r"\[prodc\]\.files glob"):
69
+ add_principle(
70
+ cfg, "P-1", "local-first", file="elsewhere/p.yaml", revised_at="2026-10-20", dry_run=True
71
+ )
72
+
73
+
74
+ def test_show_prints_a_decision(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
75
+ from prodc.cli import main
76
+
77
+ cfg = _project(tmp_path, 'files = ["product/**/*.yaml"]')
78
+ assert main(["show", "D-1", "--config", str(cfg)]) == 0
79
+ out = capsys.readouterr().out
80
+ assert "D-1 highlight rows in red" in out
81
+ assert "N-1: see past-due orders" in out # what it serves, with the need's text
82
+ assert "over: a separate past-due tab" in out
83
+ assert "consequences: colour carries meaning" in out
84
+
85
+
86
+ def test_show_decision_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
87
+ from prodc.cli import main
88
+
89
+ cfg = _project(tmp_path, 'files = ["product/**/*.yaml"]')
90
+ assert main(["show", "D-1", "--json", "--config", str(cfg)]) == 0
91
+ payload = json.loads(capsys.readouterr().out)
92
+ assert payload["id"] == "D-1" and payload["needs"] == ["N-1"]
93
+ assert payload["warnings"] == []
94
+
95
+
96
+ def test_show_unknown_id_names_both_kinds(
97
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
98
+ ) -> None:
99
+ from prodc.cli import main
100
+
101
+ cfg = _project(tmp_path, 'files = ["product/**/*.yaml"]')
102
+ assert main(["show", "X-9", "--config", str(cfg)]) == 2
103
+ assert "no need or decision 'X-9'" in capsys.readouterr().err
@@ -1,8 +0,0 @@
1
- """prodc: product management as code."""
2
-
3
- from importlib.metadata import PackageNotFoundError, version
4
-
5
- try:
6
- __version__ = version("prodc")
7
- except PackageNotFoundError: # running from source without installed metadata
8
- __version__ = "0+unknown"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes