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.
- {prodc-0.4.7 → prodc-0.4.8}/PKG-INFO +62 -40
- {prodc-0.4.7 → prodc-0.4.8}/README.md +59 -39
- {prodc-0.4.7 → prodc-0.4.8}/pyproject.toml +21 -2
- prodc-0.4.8/src/prodc/__init__.py +3 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/agent.md +9 -5
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/app.py +3 -3
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/commands.py +21 -4
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/render.py +34 -7
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/serialize.py +7 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/config.py +25 -3
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/guide.md +1 -1
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/loader.py +14 -11
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/writer.py +13 -34
- prodc-0.4.8/tests/test_files_key.py +103 -0
- prodc-0.4.7/src/prodc/__init__.py +0 -8
- {prodc-0.4.7 → prodc-0.4.8}/.gitignore +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/CHANGELOG.md +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/LICENSE +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/adapters.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/board.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/dedup.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/model.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/py.typed +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/report.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/report_html.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/src/prodc/status.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_adapters.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_answer.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_callbacks.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_citation.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_cli.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_command_hook.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_contradicts.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_decisions.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_dedup.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_description.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_drafts.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_evalgate.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_flowsteps.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_importance.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_loader.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_models.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_needref.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_principles.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_relink.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_report.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_report_html.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_review.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_roles.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_source_role.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_sources.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_staleness.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_status.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_suggest.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_variants.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_who_variants.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.7 → prodc-0.4.8}/tests/test_writer.py +0 -0
- {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.
|
|
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
|
-
[](https://gitlab.com/cardona-oss/prodc/-/pipelines)
|
|
33
|
+
[](https://pypi.org/project/prodc/)
|
|
34
|
+
[](https://pypi.org/project/prodc/)
|
|
31
35
|
[](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE)
|
|
32
36
|
|
|
33
|
-
Product management as code:
|
|
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,
|
|
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
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
192
|
-
- PyPI
|
|
193
|
-
-
|
|
194
|
-
-
|
|
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
|
-
|
|
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
|
-
[](https://gitlab.com/cardona-oss/prodc/-/pipelines)
|
|
4
|
+
[](https://pypi.org/project/prodc/)
|
|
5
|
+
[](https://pypi.org/project/prodc/)
|
|
4
6
|
[](https://gitlab.com/cardona-oss/prodc/-/blob/main/LICENSE)
|
|
5
7
|
|
|
6
|
-
Product management as code:
|
|
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,
|
|
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
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
165
|
-
- PyPI
|
|
166
|
-
-
|
|
167
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
75
|
-
| declare a product principle (the vision, held as given) | `prodc add principle <id> --text "…" --file
|
|
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
|
|
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
|
|
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].
|
|
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
|
-
|
|
446
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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].
|
|
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]")
|
|
@@ -529,15 +529,14 @@ def load(config: Config) -> Model:
|
|
|
529
529
|
"principle": {},
|
|
530
530
|
"decision": {},
|
|
531
531
|
}
|
|
532
|
-
for
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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].
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|