prodc 0.3.82__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. prodc-0.4.0/CHANGELOG.md +78 -0
  2. {prodc-0.3.82 → prodc-0.4.0}/PKG-INFO +1 -1
  3. {prodc-0.3.82 → prodc-0.4.0}/pyproject.toml +1 -1
  4. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/agent.md +1 -1
  5. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/board.py +2 -1
  6. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/cli/app.py +33 -10
  7. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/cli/commands.py +75 -52
  8. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/cli/render.py +13 -21
  9. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/cli/serialize.py +1 -22
  10. prodc-0.4.0/src/prodc/guide.md +95 -0
  11. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/loader.py +53 -60
  12. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/model.py +13 -31
  13. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/report.py +34 -3
  14. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/report_html.py +52 -1
  15. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/status.py +9 -29
  16. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/writer.py +60 -1
  17. {prodc-0.3.82 → prodc-0.4.0}/tests/test_cli.py +20 -0
  18. {prodc-0.3.82 → prodc-0.4.0}/tests/test_contradicts.py +11 -0
  19. {prodc-0.3.82 → prodc-0.4.0}/tests/test_decisions.py +41 -0
  20. {prodc-0.3.82 → prodc-0.4.0}/tests/test_loader.py +15 -41
  21. {prodc-0.3.82 → prodc-0.4.0}/tests/test_models.py +91 -3
  22. {prodc-0.3.82 → prodc-0.4.0}/tests/test_report.py +33 -2
  23. {prodc-0.3.82 → prodc-0.4.0}/tests/test_report_html.py +21 -1
  24. {prodc-0.3.82 → prodc-0.4.0}/tests/test_source_role.py +4 -4
  25. {prodc-0.3.82 → prodc-0.4.0}/tests/test_status.py +27 -19
  26. {prodc-0.3.82 → prodc-0.4.0}/tests/test_withdrawn.py +58 -0
  27. prodc-0.3.82/CHANGELOG.md +0 -7
  28. {prodc-0.3.82 → prodc-0.4.0}/.gitignore +0 -0
  29. {prodc-0.3.82 → prodc-0.4.0}/LICENSE +0 -0
  30. {prodc-0.3.82 → prodc-0.4.0}/README.md +0 -0
  31. {prodc-0.3.82 → prodc-0.4.0}/examples/quickstart/features/orders.feature +0 -0
  32. {prodc-0.3.82 → prodc-0.4.0}/examples/quickstart/needs/jordan.yaml +0 -0
  33. {prodc-0.3.82 → prodc-0.4.0}/examples/quickstart/prodc.toml +0 -0
  34. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/__init__.py +0 -0
  35. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/adapters.py +0 -0
  36. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/callbacks.py +0 -0
  37. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/cli/__init__.py +0 -0
  38. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/cli/common.py +0 -0
  39. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/config.py +0 -0
  40. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/dedup.py +0 -0
  41. {prodc-0.3.82 → prodc-0.4.0}/src/prodc/py.typed +0 -0
  42. {prodc-0.3.82 → prodc-0.4.0}/tests/test_adapters.py +0 -0
  43. {prodc-0.3.82 → prodc-0.4.0}/tests/test_answer.py +0 -0
  44. {prodc-0.3.82 → prodc-0.4.0}/tests/test_callbacks.py +0 -0
  45. {prodc-0.3.82 → prodc-0.4.0}/tests/test_citation.py +0 -0
  46. {prodc-0.3.82 → prodc-0.4.0}/tests/test_claim_gating.py +0 -0
  47. {prodc-0.3.82 → prodc-0.4.0}/tests/test_command_hook.py +0 -0
  48. {prodc-0.3.82 → prodc-0.4.0}/tests/test_dedup.py +0 -0
  49. {prodc-0.3.82 → prodc-0.4.0}/tests/test_description.py +0 -0
  50. {prodc-0.3.82 → prodc-0.4.0}/tests/test_drafts.py +0 -0
  51. {prodc-0.3.82 → prodc-0.4.0}/tests/test_evalgate.py +0 -0
  52. {prodc-0.3.82 → prodc-0.4.0}/tests/test_external_adapter.py +0 -0
  53. {prodc-0.3.82 → prodc-0.4.0}/tests/test_gitlab_source.py +0 -0
  54. {prodc-0.3.82 → prodc-0.4.0}/tests/test_importance.py +0 -0
  55. {prodc-0.3.82 → prodc-0.4.0}/tests/test_needref.py +0 -0
  56. {prodc-0.3.82 → prodc-0.4.0}/tests/test_principles.py +0 -0
  57. {prodc-0.3.82 → prodc-0.4.0}/tests/test_relink.py +0 -0
  58. {prodc-0.3.82 → prodc-0.4.0}/tests/test_review.py +0 -0
  59. {prodc-0.3.82 → prodc-0.4.0}/tests/test_roles.py +0 -0
  60. {prodc-0.3.82 → prodc-0.4.0}/tests/test_sources.py +0 -0
  61. {prodc-0.3.82 → prodc-0.4.0}/tests/test_staleness.py +0 -0
  62. {prodc-0.3.82 → prodc-0.4.0}/tests/test_suggest.py +0 -0
  63. {prodc-0.3.82 → prodc-0.4.0}/tests/test_variants.py +0 -0
  64. {prodc-0.3.82 → prodc-0.4.0}/tests/test_who_variants.py +0 -0
  65. {prodc-0.3.82 → prodc-0.4.0}/tests/test_writer.py +0 -0
  66. {prodc-0.3.82 → prodc-0.4.0}/tests/test_writer_comments.py +0 -0
@@ -0,0 +1,78 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. Versioning is [SemVer](https://semver.org/).
4
+
5
+ ## [0.4.0] - 2026-10-16
6
+
7
+ The OST is whole, and the board cannot lie.
8
+
9
+ 0.4 finishes the Opportunity-Solution Tree — a need now carries the solution
10
+ chosen for it and the vision that solution rests on — and closes the gaps that
11
+ let a board read greener than the evidence warranted.
12
+
13
+ ### Added
14
+
15
+ - **Product principles** (`principles:`) — the product vision as a cited, rippling noun.
16
+ `add principle`, `revise principle`, and `add principle --from-need` (promote a need,
17
+ keeping its text and sources). An `out_of_scope` withdrawal must cite the principle it
18
+ rests on (`withdraw --principle`); revising a principle re-opens decisions taken under it.
19
+ - **Decision noun** (`decisions:`) — the solution chosen for a need over the alternatives
20
+ rejected (OST, not an ADR). `add decision --needs … --alternative … --consequences …
21
+ --principles …`; `withdraw decision <id>` / `reinstate decision <id>` supersede and restore.
22
+ Lint flags an orphan decision (built on no need) and a stale one (its principle moved under it).
23
+ - **`contradicts`** — a symmetric need↔need edge for two needs in tension; `lint` flags a pair
24
+ left both-live, because the vision has to decide between them.
25
+ - **`prodc guide`** — a short human CLI onboarding guide, sibling to `prodc agent`;
26
+ `--write PATH` drops it into the repo.
27
+ - **`report` renders Principles and Decisions** — both the markdown document and the
28
+ interactive HTML viewer now carry the two new sections.
29
+ - **`status` shows unresolved contradictions** on the text board.
30
+
31
+ ### Changed (breaking)
32
+
33
+ - **`Need.label` removed** (#52). Express it with a declared `role` + `client`:
34
+ John@Sincerely playing the `owner` role, not a free-text label. A board still carrying
35
+ `label:` fails to load with a message naming the replacement.
36
+ *Migration:* declare the role in `roles:`, set `role:` and `client:` on the need, drop `label:`.
37
+ - **The GSN `argument` graph is cut.** Its job is absorbed by `claim` + `proofs` +
38
+ attestation: state the warrant as `need.claim`, prove it with proofs, review it with
39
+ `prodc attest`. A board carrying an `argument:` key fails to load with that guidance.
40
+ *Migration:* move each argument node's warrant onto the need's `claim`; delete `argument:`.
41
+ - **Attestation `verdict` is an enum** — `pass` / `concerns` / `fail`. Only `pass` clears a
42
+ need; `concerns`, `fail`, or no verdict at all now warns, for a human and an AI reviewer
43
+ alike. This was the false-green bug: a recorded-but-not-passing review read as settled.
44
+ *Migration:* an attestation with no `verdict:` now warns — add `verdict: pass` where the
45
+ review did pass.
46
+ - **A `duplicate` withdrawal must name its original** — `Withdrawal.of` is required when
47
+ `kind: duplicate` (`withdraw --of <id>`), so a duplicate always points at what it duplicates.
48
+
49
+ > The `argument:` and `label:` load errors are tombstones for the 0.4 cutover; they are
50
+ > scheduled for removal in 0.5.
51
+
52
+ ## [0.3.x]
53
+
54
+ The 0.3 line turned prodc from a need-and-proof tracker into a full product-and-research
55
+ board. Highlights, newest first:
56
+
57
+ - **`prodc report`** — the board as a shareable document: a readable markdown file, or a
58
+ single self-contained, interactive, read-only HTML viewer (filter, search, regroup,
59
+ print to PDF) with no external assets.
60
+ - **Research boards, unified** — no profile fork. An `answer` axis (settled questions with a
61
+ direction, including a settled-NO), a `need:` question→question DAG, and `evalgate` /
62
+ `citation` proof adapters, gated by claim-aware lint so one board holds product bets and
63
+ research questions the same way.
64
+ - **Lifecycle callbacks** — `prodc sync` fires a configured command for each need whose state
65
+ changed since the last sync, ledger-tracked and retried on failure.
66
+ - **Portfolio** — roll many boards into one scoreboard with a totals footer; discover boards
67
+ under a directory.
68
+ - **Drafts and review** — staged needs (`draft: true`), `prodc review` to triage them, an
69
+ age gate, and portfolio exclusion.
70
+ - **Withdrawal kinds** — `wont_do` / `out_of_scope` / `duplicate` on a decision-against a need.
71
+ - **Source kinds** — the academic `primary` / `secondary` / `assumption` ladder; a need with
72
+ no primary or secondary source is an opinion. Old kind names still load, mapped on.
73
+ - **Role noun** — a named function in the product's world; `who` + `client` is an instance of it.
74
+ - **Importance rubric**, **free-form `description`**, and the **cli/ package split**.
75
+
76
+ ## [0.1.0]
77
+
78
+ Initial release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.82
3
+ Version: 0.4.0
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.4.0"
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"
@@ -89,7 +89,7 @@ Read first, then act, then check.
89
89
  | act on a need going green (notify a tracker, etc.) | `prodc sync` — runs the `[callbacks]` commands for needs whose state changed since the last sync (`--dry-run` to preview) |
90
90
  | gate in CI | `prodc lint --strict` |
91
91
 
92
- Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <path>…`
92
+ Less often: `prodc portfolio <path>…`
93
93
  (roll up many boards into one scoreboard + totals; a path is a prodc.toml or a dir to discover
94
94
  boards under), and `prodc diff <snapshot.json>` (what changed since a
95
95
  saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
@@ -94,7 +94,6 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
94
94
  "state": r.state.value,
95
95
  "must": need.must,
96
96
  "who": need.who or "(unassigned)",
97
- "label": need.label,
98
97
  "client": need.client,
99
98
  "opinion": need.is_opinion,
100
99
  "warrant": strongest.value if strongest is not None else "opinion",
@@ -109,6 +108,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
109
108
  "date": need.withdrawn.date,
110
109
  "kind": need.withdrawn.kind.value,
111
110
  "principle": need.withdrawn.principle,
111
+ "of": need.withdrawn.of,
112
112
  }
113
113
  if need.withdrawn
114
114
  else None
@@ -164,6 +164,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
164
164
  "date": need.withdrawn.date,
165
165
  "kind": need.withdrawn.kind.value,
166
166
  "principle": need.withdrawn.principle,
167
+ "of": need.withdrawn.of,
167
168
  }
168
169
  if need.withdrawn
169
170
  else None
@@ -16,12 +16,12 @@ from .commands import (
16
16
  cmd_accept,
17
17
  cmd_add,
18
18
  cmd_agent,
19
- cmd_argument,
20
19
  cmd_attest,
21
20
  cmd_brief,
22
21
  cmd_capture,
23
22
  cmd_diff,
24
23
  cmd_dupes,
24
+ cmd_guide,
25
25
  cmd_lint,
26
26
  cmd_plan,
27
27
  cmd_portfolio,
@@ -143,12 +143,6 @@ def main(argv: list[str] | None = None) -> int:
143
143
  )
144
144
  pl.set_defaults(func=cmd_plan)
145
145
 
146
- ar = sub.add_parser("argument", help="show the GSN assurance graph and its defects")
147
- ar.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
148
- ar.add_argument("--json", action="store_true", help="machine-readable output")
149
- ar.add_argument("--strict", action="store_true", help="exit 1 if the graph has any defect")
150
- ar.set_defaults(func=cmd_argument)
151
-
152
146
  st2 = sub.add_parser("set", help="set or append one field on a need (writes YAML)")
153
147
  st2.add_argument("need_id", help="the need id to edit, e.g. N-1")
154
148
  st2.add_argument("assignment", help="key=value, or key+=value to append (proofs/children)")
@@ -316,7 +310,10 @@ def main(argv: list[str] | None = None) -> int:
316
310
  at.add_argument("--date", required=True, help="when (ISO, e.g. 2026-10-05)")
317
311
  at.add_argument("--ai", action="store_true", help="the reviewer is an AI model, not a human")
318
312
  at.add_argument(
319
- "--verdict", default=None, help="the reviewer's conclusion, e.g. pass/concerns/fail"
313
+ "--verdict",
314
+ choices=["pass", "concerns", "fail"],
315
+ default=None,
316
+ help="the reviewer's conclusion; only pass clears the warrant (anything else warns)",
320
317
  )
321
318
  at.add_argument("--comment", default=None, help="the review's feedback, kept on the record")
322
319
  at.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
@@ -353,7 +350,10 @@ def main(argv: list[str] | None = None) -> int:
353
350
  wd = sub.add_parser(
354
351
  "withdraw", help="record a decision against a need — withdrawn / won't-do (#74)"
355
352
  )
356
- wd.add_argument("need_id", help="the need id to withdraw")
353
+ wd.add_argument("target", help="the need id to withdraw, or 'decision' to withdraw a decision")
354
+ wd.add_argument(
355
+ "decision_id", nargs="?", default=None, help="the decision id (when target is 'decision')"
356
+ )
357
357
  wd.add_argument(
358
358
  "--reason", required=True, help="why it was decided against (kept on the record)"
359
359
  )
@@ -365,6 +365,12 @@ def main(argv: list[str] | None = None) -> int:
365
365
  "or duplicate (#74)",
366
366
  )
367
367
  wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
368
+ wd.add_argument(
369
+ "--of",
370
+ default=None,
371
+ metavar="ID",
372
+ help="the need this one duplicates (validated); required with --kind duplicate",
373
+ )
368
374
  wd.add_argument(
369
375
  "--principle",
370
376
  default=None,
@@ -388,7 +394,12 @@ def main(argv: list[str] | None = None) -> int:
388
394
  ri = sub.add_parser(
389
395
  "reinstate", help="remove a need's withdrawal, putting it back on the board (#74)"
390
396
  )
391
- ri.add_argument("need_id", help="the need id to reinstate")
397
+ ri.add_argument(
398
+ "target", help="the need id to reinstate, or 'decision' to reinstate a decision"
399
+ )
400
+ ri.add_argument(
401
+ "decision_id", nargs="?", default=None, help="the decision id (when target is 'decision')"
402
+ )
392
403
  ri.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
393
404
  ri.add_argument("--json", action="store_true", help="machine-readable output")
394
405
  ri.add_argument(
@@ -606,6 +617,18 @@ def main(argv: list[str] | None = None) -> int:
606
617
  ag.add_argument("--force", action="store_true", help="overwrite PATH if it already exists")
607
618
  ag.set_defaults(func=cmd_agent)
608
619
 
620
+ gd = sub.add_parser(
621
+ "guide", help="print prodc's human CLI guide (a short, scannable onboarding path)"
622
+ )
623
+ gd.add_argument(
624
+ "--write",
625
+ default=None,
626
+ metavar="PATH",
627
+ help="write the guide to PATH instead of stdout",
628
+ )
629
+ gd.add_argument("--force", action="store_true", help="overwrite PATH if it already exists")
630
+ gd.set_defaults(func=cmd_guide)
631
+
609
632
  df = sub.add_parser(
610
633
  "diff", help="diff the current board against a prior status --json snapshot"
611
634
  )
@@ -11,7 +11,7 @@ import argparse
11
11
  import importlib.resources
12
12
  import json
13
13
  import sys
14
- from dataclasses import asdict
14
+ from dataclasses import asdict, replace
15
15
  from datetime import date
16
16
  from pathlib import Path
17
17
  from typing import cast
@@ -28,14 +28,13 @@ from ..dedup import (
28
28
  who_variants,
29
29
  )
30
30
  from ..loader import LoaderError, load
31
- from ..model import Model, Need, NeedResult, NeedState, board_needs
31
+ from ..model import Need, NeedResult, NeedState, board_needs
32
32
  from ..report import render_markdown
33
33
  from ..report_html import render_html
34
34
  from ..status import (
35
35
  ProofSuggestion,
36
36
  RelinkSuggestion,
37
37
  applicable_needs,
38
- argument_defects,
39
38
  contradictions,
40
39
  decisions_for,
41
40
  evidence_age_days,
@@ -63,11 +62,13 @@ from ..writer import (
63
62
  delete_need,
64
63
  link_variant_group,
65
64
  reinstate,
65
+ reinstate_decision,
66
66
  relink_proof,
67
67
  revise_principle,
68
68
  set_field,
69
69
  set_importance,
70
70
  withdraw,
71
+ withdraw_decision,
71
72
  )
72
73
  from .common import (
73
74
  RepoRow,
@@ -82,9 +83,9 @@ from .render import (
82
83
  COLUMNS,
83
84
  provenance_counts,
84
85
  render,
85
- render_argument,
86
86
  render_brief,
87
87
  render_columns,
88
+ render_contradictions,
88
89
  render_decisions,
89
90
  render_goals,
90
91
  render_handoffs,
@@ -100,7 +101,6 @@ from .render import (
100
101
  summary_line,
101
102
  )
102
103
  from .serialize import (
103
- argument_json,
104
104
  brief_json,
105
105
  dup_payload,
106
106
  plan_json,
@@ -178,20 +178,6 @@ def _annotate_draft_age_warnings(results: list[NeedResult], cfg: Config) -> None
178
178
  )
179
179
 
180
180
 
181
- def _annotate_label_deprecation(results: list[NeedResult]) -> None:
182
- """Nudge needs still carrying the free-text `label` toward role + client (#52, deprecation).
183
-
184
- Since Role (#52) carries the archetype and `client` the org, the per-need `label` is redundant —
185
- "design partner, Sincerely" is `role` + `client`. Soft only: `label` is still parsed and still
186
- renders; this flags it so a board can migrate off it before it is removed.
187
- """
188
- for r in results:
189
- if r.need.label:
190
- r.warnings.append(
191
- f"'label' ({r.need.label!r}) is deprecated — express it with role + client (#52)"
192
- )
193
-
194
-
195
181
  def _lint_errors(results: list[NeedResult]) -> list[str]:
196
182
  """Hard problems a lint gate fails on: a dangling proof, or a must-have with no evidence."""
197
183
  errors: list[str] = []
@@ -241,7 +227,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
241
227
  )
242
228
  _annotate_importance_warnings(results, cfg)
243
229
  _annotate_draft_age_warnings(results, cfg)
244
- _annotate_label_deprecation(results)
245
230
  # The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
246
231
  # comes back up — the frame it was judged against has changed since. A warning, never a hard
247
232
  # error; prodc flags, the PM re-decides.
@@ -406,6 +391,9 @@ def cmd_status(args: argparse.Namespace) -> int:
406
391
  print(render_principles(board.principles))
407
392
  if model.decisions:
408
393
  print(render_decisions(list(model.decisions), needs=list(model.needs)))
394
+ unresolved = [c for c in contradictions(list(model.needs)) if c.unresolved]
395
+ if unresolved:
396
+ print(render_contradictions(unresolved))
409
397
  if orphan_map:
410
398
  print(render_orphans(orphan_map))
411
399
 
@@ -705,16 +693,32 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
705
693
  return 2
706
694
  try:
707
695
  cfg = Config.load(cfg_path)
708
- edit = withdraw(
709
- cfg,
710
- args.need_id,
711
- reason=args.reason,
712
- date=args.date or date.today().isoformat(),
713
- kind=args.kind,
714
- principle=args.principle,
715
- expect_sha=args.expect_sha,
716
- dry_run=args.dry_run,
717
- )
696
+ if args.target == "decision":
697
+ # noun-first: ids are unique per noun, so a need and a decision may share one — the
698
+ # noun disambiguates (matches `add decision` / `revise principle`).
699
+ if not args.decision_id:
700
+ print("prodc: withdraw decision needs a decision id", file=sys.stderr)
701
+ return 2
702
+ edit = withdraw_decision(
703
+ cfg,
704
+ args.decision_id,
705
+ reason=args.reason,
706
+ date=args.date or date.today().isoformat(),
707
+ expect_sha=args.expect_sha,
708
+ dry_run=args.dry_run,
709
+ )
710
+ else:
711
+ edit = withdraw(
712
+ cfg,
713
+ args.target,
714
+ reason=args.reason,
715
+ date=args.date or date.today().isoformat(),
716
+ kind=args.kind,
717
+ principle=args.principle,
718
+ of=args.of,
719
+ expect_sha=args.expect_sha,
720
+ dry_run=args.dry_run,
721
+ )
718
722
  except (ConfigError, WriteError) as exc:
719
723
  print(f"prodc: {exc}", file=sys.stderr)
720
724
  return 2
@@ -729,12 +733,23 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
729
733
  return 2
730
734
  try:
731
735
  cfg = Config.load(cfg_path)
732
- edit = reinstate(
733
- cfg,
734
- args.need_id,
735
- expect_sha=args.expect_sha,
736
- dry_run=args.dry_run,
737
- )
736
+ if args.target == "decision":
737
+ if not args.decision_id:
738
+ print("prodc: reinstate decision needs a decision id", file=sys.stderr)
739
+ return 2
740
+ edit = reinstate_decision(
741
+ cfg,
742
+ args.decision_id,
743
+ expect_sha=args.expect_sha,
744
+ dry_run=args.dry_run,
745
+ )
746
+ else:
747
+ edit = reinstate(
748
+ cfg,
749
+ args.target,
750
+ expect_sha=args.expect_sha,
751
+ dry_run=args.dry_run,
752
+ )
738
753
  except (ConfigError, WriteError) as exc:
739
754
  print(f"prodc: {exc}", file=sys.stderr)
740
755
  return 2
@@ -921,15 +936,6 @@ def cmd_capture(args: argparse.Namespace) -> int:
921
936
  return emit_edit(edit, args.dry_run, args.json)
922
937
 
923
938
 
924
- def cmd_argument(args: argparse.Namespace) -> int:
925
- """Show the GSN assurance graph and its defects (unsupported claim, dangling assumption)."""
926
- loaded = load_or_report(args)
927
- if loaded is None:
928
- return 2
929
- _cfg, model = loaded
930
- defects = argument_defects(list(model.argument))
931
- print(argument_json(model, defects) if args.json else render_argument(model, defects))
932
- return 1 if (args.strict and defects) else 0
933
939
 
934
940
 
935
941
  def _repo_row(config_path: str) -> RepoRow:
@@ -1041,14 +1047,10 @@ def cmd_report(args: argparse.Namespace) -> int:
1041
1047
  return 2
1042
1048
  cfg, model = loaded
1043
1049
  if args.client:
1050
+ # replace keeps every other noun (principles, decisions, …); a hand-rolled Model(...)
1051
+ # silently dropped them, which breaks the moment the report renders them (#0.4).
1044
1052
  scoped = applicable_needs(list(model.needs), args.client)
1045
- model = Model(
1046
- needs=tuple(scoped),
1047
- goals=model.goals,
1048
- releases=model.releases,
1049
- roles=model.roles,
1050
- argument=model.argument,
1051
- )
1053
+ model = replace(model, needs=tuple(scoped))
1052
1054
  if args.format == "html":
1053
1055
  text = render_html(cfg, model, client=args.client, rev=git_rev(cfg.root))
1054
1056
  else:
@@ -1146,6 +1148,27 @@ def cmd_agent(args: argparse.Namespace) -> int:
1146
1148
  return 0
1147
1149
 
1148
1150
 
1151
+ def cmd_guide(args: argparse.Namespace) -> int:
1152
+ """Print prodc's human CLI guide (a short onboarding path), or write it to a file.
1153
+
1154
+ The human sibling of :func:`cmd_agent`: where `agent` ships an instruction set for a coding
1155
+ agent, `guide` is the 60-second path a person reads to try prodc. Ships versioned with prodc;
1156
+ `--write` drops it into a repo.
1157
+ """
1158
+ text = importlib.resources.files("prodc").joinpath("guide.md").read_text(encoding="utf-8")
1159
+ if args.write:
1160
+ dest = Path(args.write)
1161
+ if dest.exists() and not args.force:
1162
+ print(f"prodc: {dest} already exists (use --force to overwrite)", file=sys.stderr)
1163
+ return 2
1164
+ dest.parent.mkdir(parents=True, exist_ok=True)
1165
+ dest.write_text(text, encoding="utf-8")
1166
+ print(f"wrote prodc guide to {dest}")
1167
+ return 0
1168
+ print(text, end="")
1169
+ return 0
1170
+
1171
+
1149
1172
  def cmd_suggest(args: argparse.Namespace) -> int:
1150
1173
  """Propose candidate proof locators for HOLE needs (or relink BROKEN ones) from the inventories (#54)."""
1151
1174
  loaded = load_or_report(args)
@@ -14,7 +14,6 @@ from ..board import warrant as _warrant
14
14
  from ..model import (
15
15
  Decision,
16
16
  Goal,
17
- Model,
18
17
  Need,
19
18
  NeedResult,
20
19
  NeedState,
@@ -23,6 +22,7 @@ from ..model import (
23
22
  ears_shape,
24
23
  )
25
24
  from ..status import (
25
+ Contradiction,
26
26
  Handoff,
27
27
  PrincipleReport,
28
28
  Recurrence,
@@ -139,11 +139,9 @@ def render(results: list[NeedResult], wide: bool = False) -> str:
139
139
  ]
140
140
  for who in sorted(groups):
141
141
  rs = sorted(groups[who], key=state_priority)
142
- label = next((x.need.label for x in rs if x.need.label), None)
143
142
  counts = {s: sum(1 for r in rs if r.state is s) for s in NeedState}
144
143
  head = " ".join(f"{counts[s]} {_LABEL[s]}" for s in NeedState if counts[s])
145
- title = f"{who}" + (f" ({label})" if label else "")
146
- out.append(f"\n{title} {head}")
144
+ out.append(f"\n{who} {head}")
147
145
  out.append(f" {_provenance_line(rs)}\n")
148
146
  for r in rs:
149
147
  out.append(
@@ -289,6 +287,14 @@ def render_principles(reports: list[PrincipleReport]) -> str:
289
287
  return "\n".join(out)
290
288
 
291
289
 
290
+ def render_contradictions(contras: list[Contradiction]) -> str:
291
+ """Unresolved contradictions on the board: two needs in tension the vision has not decided."""
292
+ out = ["\ncontradictions (two needs in tension — the vision has not decided):"]
293
+ for c in contras:
294
+ out.append(f" {c.a.id} vs {c.b.id} {c.a.text[:32]} / {c.b.text[:32]}")
295
+ return "\n".join(out)
296
+
297
+
292
298
  def render_decisions(decisions: list[Decision], needs: list[Need]) -> str:
293
299
  """The solution chosen for each need (OST), what it rejected, and the needs it serves.
294
300
 
@@ -319,7 +325,7 @@ def render_show(
319
325
  ) -> str:
320
326
  """One need as a citable answer — the artifact you drop into a thread."""
321
327
  need = r.need
322
- who = (need.who + (f" ({need.label})" if need.label else "")) if need.who else "(unassigned)"
328
+ who = need.who or "(unassigned)"
323
329
  who += f" — {need.role}" if need.role else ""
324
330
  nm = f" {r.delivered}/{r.total}" if r.total else ""
325
331
  lines = [
@@ -332,7 +338,8 @@ def render_show(
332
338
  lines.append(f" answer: {need.answer.value}")
333
339
  if need.withdrawn:
334
340
  w = need.withdrawn
335
- tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}]"
341
+ of = f" of {w.of}" if w.of else ""
342
+ tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}{of}]"
336
343
  per = f" per {w.principle}" if w.principle else ""
337
344
  lines.append(f" withdrawn{tag}{per}: {w.reason} ({w.date})")
338
345
  if need.description:
@@ -450,21 +457,6 @@ def render_plan(
450
457
  return "\n".join(out)
451
458
 
452
459
 
453
- def render_argument(model: Model, defects: list[str]) -> str:
454
- out = [f"argument — GSN graph: {len(model.argument)} node(s), {len(defects)} defect(s)"]
455
- for node in model.argument:
456
- links = ""
457
- if node.supported_by:
458
- links += f" ←{','.join(node.supported_by)}"
459
- if node.in_context_of:
460
- links += f" ⊙{','.join(node.in_context_of)}"
461
- about = f" [{node.about}]" if node.about else ""
462
- out.append(f" {node.id:<12} {node.kind.value:<13} {node.text[:44]}{about}{links}")
463
- for d in defects:
464
- out.append(f" ⚠ {d}")
465
- return "\n".join(out)
466
-
467
-
468
460
  def render_portfolio(rows: list[RepoRow], totals: PortfolioTotals | None = None) -> str:
469
461
  if not rows:
470
462
  return " no boards found"
@@ -12,7 +12,7 @@ import json
12
12
  from ..board import need_payload
13
13
  from ..board import warrant as _warrant
14
14
  from ..dedup import DupPair, VariantGroup
15
- from ..model import Decision, Goal, Model, NeedResult, NeedState, Release, Role
15
+ from ..model import Decision, Goal, NeedResult, NeedState, Release, Role
16
16
  from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
17
17
  from .common import brief_open
18
18
 
@@ -147,7 +147,6 @@ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
147
147
  payload = need_payload(r) # the shared core — never drifts from the board
148
148
  payload.update(
149
149
  {
150
- "label": need.label,
151
150
  "decisions": [_decision_payload(d) for d in (decisions or [])],
152
151
  "sources": [
153
152
  {
@@ -223,26 +222,6 @@ def plan_json(
223
222
  )
224
223
 
225
224
 
226
- def argument_json(model: Model, defects: list[str]) -> str:
227
- return json.dumps(
228
- {
229
- "nodes": [
230
- {
231
- "id": n.id,
232
- "kind": n.kind.value,
233
- "text": n.text,
234
- "supported_by": list(n.supported_by),
235
- "in_context_of": list(n.in_context_of),
236
- "about": n.about,
237
- }
238
- for n in model.argument
239
- ],
240
- "defects": defects,
241
- },
242
- indent=2,
243
- )
244
-
245
-
246
225
  def variant_payload(g: VariantGroup) -> dict[str, object]:
247
226
  return {
248
227
  "score": round(g.score, 4),
@@ -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`.