prodc 0.3.82__tar.gz → 0.3.83__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 (65) hide show
  1. {prodc-0.3.82 → prodc-0.3.83}/PKG-INFO +1 -1
  2. {prodc-0.3.82 → prodc-0.3.83}/pyproject.toml +1 -1
  3. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/cli/app.py +13 -0
  4. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/cli/commands.py +21 -0
  5. prodc-0.3.83/src/prodc/guide.md +95 -0
  6. {prodc-0.3.82 → prodc-0.3.83}/tests/test_cli.py +20 -0
  7. {prodc-0.3.82 → prodc-0.3.83}/tests/test_models.py +91 -3
  8. {prodc-0.3.82 → prodc-0.3.83}/.gitignore +0 -0
  9. {prodc-0.3.82 → prodc-0.3.83}/CHANGELOG.md +0 -0
  10. {prodc-0.3.82 → prodc-0.3.83}/LICENSE +0 -0
  11. {prodc-0.3.82 → prodc-0.3.83}/README.md +0 -0
  12. {prodc-0.3.82 → prodc-0.3.83}/examples/quickstart/features/orders.feature +0 -0
  13. {prodc-0.3.82 → prodc-0.3.83}/examples/quickstart/needs/jordan.yaml +0 -0
  14. {prodc-0.3.82 → prodc-0.3.83}/examples/quickstart/prodc.toml +0 -0
  15. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/__init__.py +0 -0
  16. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/adapters.py +0 -0
  17. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/agent.md +0 -0
  18. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/board.py +0 -0
  19. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/callbacks.py +0 -0
  20. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/cli/__init__.py +0 -0
  21. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/cli/common.py +0 -0
  22. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/cli/render.py +0 -0
  23. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/cli/serialize.py +0 -0
  24. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/config.py +0 -0
  25. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/dedup.py +0 -0
  26. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/loader.py +0 -0
  27. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/model.py +0 -0
  28. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/py.typed +0 -0
  29. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/report.py +0 -0
  30. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/report_html.py +0 -0
  31. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/status.py +0 -0
  32. {prodc-0.3.82 → prodc-0.3.83}/src/prodc/writer.py +0 -0
  33. {prodc-0.3.82 → prodc-0.3.83}/tests/test_adapters.py +0 -0
  34. {prodc-0.3.82 → prodc-0.3.83}/tests/test_answer.py +0 -0
  35. {prodc-0.3.82 → prodc-0.3.83}/tests/test_callbacks.py +0 -0
  36. {prodc-0.3.82 → prodc-0.3.83}/tests/test_citation.py +0 -0
  37. {prodc-0.3.82 → prodc-0.3.83}/tests/test_claim_gating.py +0 -0
  38. {prodc-0.3.82 → prodc-0.3.83}/tests/test_command_hook.py +0 -0
  39. {prodc-0.3.82 → prodc-0.3.83}/tests/test_contradicts.py +0 -0
  40. {prodc-0.3.82 → prodc-0.3.83}/tests/test_decisions.py +0 -0
  41. {prodc-0.3.82 → prodc-0.3.83}/tests/test_dedup.py +0 -0
  42. {prodc-0.3.82 → prodc-0.3.83}/tests/test_description.py +0 -0
  43. {prodc-0.3.82 → prodc-0.3.83}/tests/test_drafts.py +0 -0
  44. {prodc-0.3.82 → prodc-0.3.83}/tests/test_evalgate.py +0 -0
  45. {prodc-0.3.82 → prodc-0.3.83}/tests/test_external_adapter.py +0 -0
  46. {prodc-0.3.82 → prodc-0.3.83}/tests/test_gitlab_source.py +0 -0
  47. {prodc-0.3.82 → prodc-0.3.83}/tests/test_importance.py +0 -0
  48. {prodc-0.3.82 → prodc-0.3.83}/tests/test_loader.py +0 -0
  49. {prodc-0.3.82 → prodc-0.3.83}/tests/test_needref.py +0 -0
  50. {prodc-0.3.82 → prodc-0.3.83}/tests/test_principles.py +0 -0
  51. {prodc-0.3.82 → prodc-0.3.83}/tests/test_relink.py +0 -0
  52. {prodc-0.3.82 → prodc-0.3.83}/tests/test_report.py +0 -0
  53. {prodc-0.3.82 → prodc-0.3.83}/tests/test_report_html.py +0 -0
  54. {prodc-0.3.82 → prodc-0.3.83}/tests/test_review.py +0 -0
  55. {prodc-0.3.82 → prodc-0.3.83}/tests/test_roles.py +0 -0
  56. {prodc-0.3.82 → prodc-0.3.83}/tests/test_source_role.py +0 -0
  57. {prodc-0.3.82 → prodc-0.3.83}/tests/test_sources.py +0 -0
  58. {prodc-0.3.82 → prodc-0.3.83}/tests/test_staleness.py +0 -0
  59. {prodc-0.3.82 → prodc-0.3.83}/tests/test_status.py +0 -0
  60. {prodc-0.3.82 → prodc-0.3.83}/tests/test_suggest.py +0 -0
  61. {prodc-0.3.82 → prodc-0.3.83}/tests/test_variants.py +0 -0
  62. {prodc-0.3.82 → prodc-0.3.83}/tests/test_who_variants.py +0 -0
  63. {prodc-0.3.82 → prodc-0.3.83}/tests/test_withdrawn.py +0 -0
  64. {prodc-0.3.82 → prodc-0.3.83}/tests/test_writer.py +0 -0
  65. {prodc-0.3.82 → prodc-0.3.83}/tests/test_writer_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.82
3
+ Version: 0.3.83
4
4
  Summary: Product management as code: roles, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.82"
3
+ version = "0.3.83"
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"
@@ -22,6 +22,7 @@ from .commands import (
22
22
  cmd_capture,
23
23
  cmd_diff,
24
24
  cmd_dupes,
25
+ cmd_guide,
25
26
  cmd_lint,
26
27
  cmd_plan,
27
28
  cmd_portfolio,
@@ -606,6 +607,18 @@ def main(argv: list[str] | None = None) -> int:
606
607
  ag.add_argument("--force", action="store_true", help="overwrite PATH if it already exists")
607
608
  ag.set_defaults(func=cmd_agent)
608
609
 
610
+ gd = sub.add_parser(
611
+ "guide", help="print prodc's human CLI guide (a short, scannable onboarding path)"
612
+ )
613
+ gd.add_argument(
614
+ "--write",
615
+ default=None,
616
+ metavar="PATH",
617
+ help="write the guide to PATH instead of stdout",
618
+ )
619
+ gd.add_argument("--force", action="store_true", help="overwrite PATH if it already exists")
620
+ gd.set_defaults(func=cmd_guide)
621
+
609
622
  df = sub.add_parser(
610
623
  "diff", help="diff the current board against a prior status --json snapshot"
611
624
  )
@@ -1146,6 +1146,27 @@ def cmd_agent(args: argparse.Namespace) -> int:
1146
1146
  return 0
1147
1147
 
1148
1148
 
1149
+ def cmd_guide(args: argparse.Namespace) -> int:
1150
+ """Print prodc's human CLI guide (a short onboarding path), or write it to a file.
1151
+
1152
+ The human sibling of :func:`cmd_agent`: where `agent` ships an instruction set for a coding
1153
+ agent, `guide` is the 60-second path a person reads to try prodc. Ships versioned with prodc;
1154
+ `--write` drops it into a repo.
1155
+ """
1156
+ text = importlib.resources.files("prodc").joinpath("guide.md").read_text(encoding="utf-8")
1157
+ if args.write:
1158
+ dest = Path(args.write)
1159
+ if dest.exists() and not args.force:
1160
+ print(f"prodc: {dest} already exists (use --force to overwrite)", file=sys.stderr)
1161
+ return 2
1162
+ dest.parent.mkdir(parents=True, exist_ok=True)
1163
+ dest.write_text(text, encoding="utf-8")
1164
+ print(f"wrote prodc guide to {dest}")
1165
+ return 0
1166
+ print(text, end="")
1167
+ return 0
1168
+
1169
+
1149
1170
  def cmd_suggest(args: argparse.Namespace) -> int:
1150
1171
  """Propose candidate proof locators for HOLE needs (or relink BROKEN ones) from the inventories (#54)."""
1151
1172
  loaded = load_or_report(args)
@@ -0,0 +1,95 @@
1
+ # prodc in 60 seconds
2
+
3
+ prodc keeps product work in the repo, next to the code: who needs what, why, and the test that proves it is done. Plain YAML files, reviewed in pull requests like code.
4
+
5
+ Try it now: `prodc status`
6
+
7
+ ## The two rules
8
+
9
+ - **No evidence means it is an opinion.** A need backed only by your own guess still loads, but prodc marks it an opinion until a real user has said something. Example: "users want dark mode" with nobody quoted is an opinion.
10
+ - **"Done" is a test, not a claim.** You do not mark a need done; you attach a proof that points into the real test suite. `prodc status` reads DELIVERED, HOLE, or BROKEN from it. Example: `proofs: ["pytest:tests/test_checkout.py::test_pays"]`.
11
+
12
+ ## Your first board
13
+
14
+ Three files make a board.
15
+
16
+ `prodc.toml`:
17
+
18
+ ```toml
19
+ [prodc]
20
+ needs = ["needs/*.yaml"]
21
+ ```
22
+
23
+ `needs/needs.yaml`:
24
+
25
+ ```yaml
26
+ needs:
27
+ - id: N-1
28
+ text: a buyer can pay for a cart
29
+ who: Dana
30
+ sources:
31
+ - kind: primary
32
+ who: Dana
33
+ quote: I could not check out on mobile
34
+ proofs: ["pytest:tests/test_checkout.py::test_pays"]
35
+ ```
36
+
37
+ Then run `prodc status`. You get one board line per need, with its state and coverage.
38
+
39
+ ## Read the board
40
+
41
+ | Command | Shows |
42
+ |---|---|
43
+ | `prodc status` | the whole board and coverage |
44
+ | `prodc show <id>` | one need as a citable answer |
45
+ | `prodc plan` | the backlog, ranked |
46
+ | `prodc brief <client>` | what to ask a client before a call |
47
+
48
+ ## Record a need
49
+
50
+ ```
51
+ prodc add need N-2 --text "a buyer can save a cart" --who you --kind assumption --file needs/needs.yaml
52
+ ```
53
+
54
+ Warning: never record a `primary` source you invented. `primary` means a real user actually said it. Your own reasoning is `assumption`.
55
+
56
+ ## Prove a need
57
+
58
+ A need moves HOLE, then PENDING, then DELIVERED as its proof comes true. For a HOLE, let prodc find the test:
59
+
60
+ ```
61
+ prodc suggest N-1 --apply
62
+ ```
63
+
64
+ ## Gate it in CI
65
+
66
+ ```
67
+ prodc lint --strict
68
+ ```
69
+
70
+ Exit codes are the contract: 0 is clean, 1 is a gate failed (a broken proof or a must-have opinion), 2 is a usage or config error.
71
+
72
+ ## Stuck? A gap is a valid answer
73
+
74
+ A HOLE (no proof yet) or an opinion (no evidence yet) is honest output, not a failure. Leave it visible. Never attach a fake proof to turn a row green.
75
+
76
+ ## Cheat sheet
77
+
78
+ | To | Run |
79
+ |---|---|
80
+ | see the board | `prodc status` |
81
+ | read one need | `prodc show <id>` |
82
+ | rank the backlog | `prodc plan` |
83
+ | prep a client call | `prodc brief <client>` |
84
+ | add a need | `prodc add need <id> --text "..." --who you --kind assumption --file <f>` |
85
+ | attach evidence | `prodc capture <id> --kind primary --who <person> --date <ISO> --quote "..."` |
86
+ | attach a proof | `prodc suggest <id> --apply` |
87
+ | decide against a need | `prodc withdraw <id> --reason "..."` |
88
+ | gate in CI | `prodc lint --strict` |
89
+
90
+ ## Going further
91
+
92
+ - The vision, held as given: `prodc add principle <id> --text "..."`
93
+ - How a need gets built (the chosen solution): `prodc add decision <id> --text "..." --needs <ids>`
94
+ - Full command list: `docs/commands.md`. The nouns: `docs/model.md`. Config: `docs/config.md`.
95
+ - Teaching an AI agent to run prodc for you: `prodc agent`.
@@ -513,3 +513,23 @@ def test_agent_write_creates_file_and_refuses_overwrite(
513
513
  assert main(["agent", "--write", str(dest)]) == 2
514
514
  assert "already exists" in capsys.readouterr().err
515
515
  assert main(["agent", "--write", str(dest), "--force"]) == 0
516
+
517
+
518
+ def test_guide_prints_human_guide(capsys: pytest.CaptureFixture[str]) -> None:
519
+ code = main(["guide"])
520
+ out = capsys.readouterr().out
521
+ assert code == 0
522
+ assert "prodc in 60 seconds" in out
523
+ assert "The two rules" in out # the human onboarding path is present
524
+
525
+
526
+ def test_guide_write_creates_file_and_refuses_overwrite(
527
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
528
+ ) -> None:
529
+ dest = tmp_path / "sub" / "GUIDE.md"
530
+ assert main(["guide", "--write", str(dest)]) == 0
531
+ assert dest.exists() and "prodc" in dest.read_text(encoding="utf-8")
532
+ capsys.readouterr()
533
+ assert main(["guide", "--write", str(dest)]) == 2
534
+ assert "already exists" in capsys.readouterr().err
535
+ assert main(["guide", "--write", str(dest), "--force"]) == 0
@@ -11,11 +11,11 @@ from __future__ import annotations
11
11
 
12
12
  from dataclasses import dataclass, replace
13
13
 
14
- from musil import Action, Model, check
14
+ from musil import Action, Model, check, explore, reaches
15
15
 
16
16
  from prodc.adapters import Adapter, ProofResult
17
- from prodc.model import Need, NeedState, ProofState
18
- from prodc.status import Resolver
17
+ from prodc.model import Decision, Need, NeedState, ProofState, Withdrawal, board_needs
18
+ from prodc.status import Resolver, active_decisions
19
19
 
20
20
 
21
21
  class _Stub:
@@ -211,6 +211,79 @@ FLOW_MODEL = Model(
211
211
  )
212
212
 
213
213
 
214
+ # --- draft / withdrawn lifecycle (shared by Need and Decision) -----------------------
215
+ # The board-membership state machine the writer verbs move an item through: a thing is staged
216
+ # (draft), promoted (draft cleared), withdrawn (a decision against — off the board, record kept),
217
+ # or reinstated. Every invariant derives from prodc's REAL rules (counts_toward_coverage /
218
+ # board_needs / active_decisions), so a drift between this model and those rules is a failed check.
219
+ # Note the asymmetry it pins down: coverage/board exclude a *withdrawn* item unconditionally, but a
220
+ # *draft* only until promoted — and active_decisions excludes both.
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class Life:
225
+ draft: bool = True # starts staged (proposed); promote clears it
226
+ withdrawn: bool = False
227
+
228
+
229
+ def _life_actions() -> list[Action[Life]]:
230
+ return [
231
+ Action("promote", lambda s: s.draft and not s.withdrawn, lambda s: replace(s, draft=False)),
232
+ Action("withdraw", lambda s: not s.withdrawn, lambda s: replace(s, withdrawn=True)),
233
+ Action("reinstate", lambda s: s.withdrawn, lambda s: replace(s, withdrawn=False)),
234
+ ]
235
+
236
+
237
+ def _w(s: Life) -> Withdrawal | None:
238
+ return Withdrawal(reason="r", date="2026-01-01") if s.withdrawn else None
239
+
240
+
241
+ def _life_need(s: Life) -> Need:
242
+ return Need(id="n", text="x", draft=s.draft, withdrawn=_w(s))
243
+
244
+
245
+ def _life_decision(s: Life) -> Decision:
246
+ return Decision(id="D", text="x", needs=("n",), draft=s.draft, withdrawn=_w(s))
247
+
248
+
249
+ def _on_board(s: Life) -> bool:
250
+ return not s.draft and not s.withdrawn
251
+
252
+
253
+ def _life_coverage_counts_iff_not_withdrawn(s: Life) -> bool:
254
+ """A leaf product need counts toward coverage exactly when not withdrawn — draft is orthogonal."""
255
+ return _life_need(s).counts_toward_coverage == (not s.withdrawn)
256
+
257
+
258
+ def _life_board_default_excludes_draft_and_withdrawn(s: Life) -> bool:
259
+ """The counted board (no --include-drafts) holds the item iff it is neither draft nor withdrawn."""
260
+ on = _life_need(s).id in {n.id for n in board_needs([_life_need(s)], include_drafts=False)}
261
+ return on == _on_board(s)
262
+
263
+
264
+ def _life_board_with_drafts_excludes_only_withdrawn(s: Life) -> bool:
265
+ """--include-drafts holds the item iff it is not withdrawn (a draft is kept, withdrawn never)."""
266
+ on = _life_need(s).id in {n.id for n in board_needs([_life_need(s)], include_drafts=True)}
267
+ return on == (not s.withdrawn)
268
+
269
+
270
+ def _life_decision_active_iff_on_board(s: Life) -> bool:
271
+ """A decision is in force iff neither a draft nor withdrawn — stricter than need coverage."""
272
+ return (active_decisions([_life_decision(s)]) == [_life_decision(s)]) == _on_board(s)
273
+
274
+
275
+ LIFE_MODEL = Model(
276
+ init=Life(),
277
+ actions=_life_actions(),
278
+ invariants={
279
+ "coverage-counts-iff-not-withdrawn": _life_coverage_counts_iff_not_withdrawn,
280
+ "board-default-excludes-draft-and-withdrawn": _life_board_default_excludes_draft_and_withdrawn,
281
+ "board-with-drafts-excludes-only-withdrawn": _life_board_with_drafts_excludes_only_withdrawn,
282
+ "decision-active-iff-on-board": _life_decision_active_iff_on_board,
283
+ },
284
+ )
285
+
286
+
214
287
  # --- the gate ------------------------------------------------------------------------
215
288
  def test_leaf_lifecycle_safety() -> None:
216
289
  r = check(LEAF_MODEL)
@@ -222,6 +295,21 @@ def test_flow_rollup_safety() -> None:
222
295
  assert r.ok, f"{r.invariant}: {r.trace}"
223
296
 
224
297
 
298
+ def test_lifecycle_safety() -> None:
299
+ r = check(LIFE_MODEL)
300
+ assert r.ok, f"{r.invariant}: {r.trace}"
301
+
302
+
303
+ def test_lifecycle_board_always_recoverable() -> None:
304
+ """No reachable state is permanently stuck off the board: reinstate + promote always lead back.
305
+
306
+ The liveness counterpart to the safety invariants — it proves a withdrawn or staged item can
307
+ never get trapped off the active board (an item is never a one-way trip).
308
+ """
309
+ g = explore(LIFE_MODEL)
310
+ assert reaches(g, _on_board) == set(g.states)
311
+
312
+
225
313
  def test_checker_bites() -> None:
226
314
  """Guard against a vacuous suite: a knowingly false invariant must be caught."""
227
315
  bad = Model(
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