prodc 0.3.83__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.83 → prodc-0.4.0}/PKG-INFO +1 -1
  3. {prodc-0.3.83 → prodc-0.4.0}/pyproject.toml +1 -1
  4. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/agent.md +1 -1
  5. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/board.py +2 -1
  6. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/cli/app.py +20 -10
  7. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/cli/commands.py +54 -52
  8. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/cli/render.py +13 -21
  9. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/cli/serialize.py +1 -22
  10. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/loader.py +53 -60
  11. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/model.py +13 -31
  12. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/report.py +34 -3
  13. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/report_html.py +52 -1
  14. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/status.py +9 -29
  15. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/writer.py +60 -1
  16. {prodc-0.3.83 → prodc-0.4.0}/tests/test_contradicts.py +11 -0
  17. {prodc-0.3.83 → prodc-0.4.0}/tests/test_decisions.py +41 -0
  18. {prodc-0.3.83 → prodc-0.4.0}/tests/test_loader.py +15 -41
  19. {prodc-0.3.83 → prodc-0.4.0}/tests/test_report.py +33 -2
  20. {prodc-0.3.83 → prodc-0.4.0}/tests/test_report_html.py +21 -1
  21. {prodc-0.3.83 → prodc-0.4.0}/tests/test_source_role.py +4 -4
  22. {prodc-0.3.83 → prodc-0.4.0}/tests/test_status.py +27 -19
  23. {prodc-0.3.83 → prodc-0.4.0}/tests/test_withdrawn.py +58 -0
  24. prodc-0.3.83/CHANGELOG.md +0 -7
  25. {prodc-0.3.83 → prodc-0.4.0}/.gitignore +0 -0
  26. {prodc-0.3.83 → prodc-0.4.0}/LICENSE +0 -0
  27. {prodc-0.3.83 → prodc-0.4.0}/README.md +0 -0
  28. {prodc-0.3.83 → prodc-0.4.0}/examples/quickstart/features/orders.feature +0 -0
  29. {prodc-0.3.83 → prodc-0.4.0}/examples/quickstart/needs/jordan.yaml +0 -0
  30. {prodc-0.3.83 → prodc-0.4.0}/examples/quickstart/prodc.toml +0 -0
  31. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/__init__.py +0 -0
  32. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/adapters.py +0 -0
  33. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/callbacks.py +0 -0
  34. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/cli/__init__.py +0 -0
  35. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/cli/common.py +0 -0
  36. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/config.py +0 -0
  37. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/dedup.py +0 -0
  38. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/guide.md +0 -0
  39. {prodc-0.3.83 → prodc-0.4.0}/src/prodc/py.typed +0 -0
  40. {prodc-0.3.83 → prodc-0.4.0}/tests/test_adapters.py +0 -0
  41. {prodc-0.3.83 → prodc-0.4.0}/tests/test_answer.py +0 -0
  42. {prodc-0.3.83 → prodc-0.4.0}/tests/test_callbacks.py +0 -0
  43. {prodc-0.3.83 → prodc-0.4.0}/tests/test_citation.py +0 -0
  44. {prodc-0.3.83 → prodc-0.4.0}/tests/test_claim_gating.py +0 -0
  45. {prodc-0.3.83 → prodc-0.4.0}/tests/test_cli.py +0 -0
  46. {prodc-0.3.83 → prodc-0.4.0}/tests/test_command_hook.py +0 -0
  47. {prodc-0.3.83 → prodc-0.4.0}/tests/test_dedup.py +0 -0
  48. {prodc-0.3.83 → prodc-0.4.0}/tests/test_description.py +0 -0
  49. {prodc-0.3.83 → prodc-0.4.0}/tests/test_drafts.py +0 -0
  50. {prodc-0.3.83 → prodc-0.4.0}/tests/test_evalgate.py +0 -0
  51. {prodc-0.3.83 → prodc-0.4.0}/tests/test_external_adapter.py +0 -0
  52. {prodc-0.3.83 → prodc-0.4.0}/tests/test_gitlab_source.py +0 -0
  53. {prodc-0.3.83 → prodc-0.4.0}/tests/test_importance.py +0 -0
  54. {prodc-0.3.83 → prodc-0.4.0}/tests/test_models.py +0 -0
  55. {prodc-0.3.83 → prodc-0.4.0}/tests/test_needref.py +0 -0
  56. {prodc-0.3.83 → prodc-0.4.0}/tests/test_principles.py +0 -0
  57. {prodc-0.3.83 → prodc-0.4.0}/tests/test_relink.py +0 -0
  58. {prodc-0.3.83 → prodc-0.4.0}/tests/test_review.py +0 -0
  59. {prodc-0.3.83 → prodc-0.4.0}/tests/test_roles.py +0 -0
  60. {prodc-0.3.83 → prodc-0.4.0}/tests/test_sources.py +0 -0
  61. {prodc-0.3.83 → prodc-0.4.0}/tests/test_staleness.py +0 -0
  62. {prodc-0.3.83 → prodc-0.4.0}/tests/test_suggest.py +0 -0
  63. {prodc-0.3.83 → prodc-0.4.0}/tests/test_variants.py +0 -0
  64. {prodc-0.3.83 → prodc-0.4.0}/tests/test_who_variants.py +0 -0
  65. {prodc-0.3.83 → prodc-0.4.0}/tests/test_writer.py +0 -0
  66. {prodc-0.3.83 → 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.83
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.83"
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,7 +16,6 @@ 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,
@@ -144,12 +143,6 @@ def main(argv: list[str] | None = None) -> int:
144
143
  )
145
144
  pl.set_defaults(func=cmd_plan)
146
145
 
147
- ar = sub.add_parser("argument", help="show the GSN assurance graph and its defects")
148
- ar.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
149
- ar.add_argument("--json", action="store_true", help="machine-readable output")
150
- ar.add_argument("--strict", action="store_true", help="exit 1 if the graph has any defect")
151
- ar.set_defaults(func=cmd_argument)
152
-
153
146
  st2 = sub.add_parser("set", help="set or append one field on a need (writes YAML)")
154
147
  st2.add_argument("need_id", help="the need id to edit, e.g. N-1")
155
148
  st2.add_argument("assignment", help="key=value, or key+=value to append (proofs/children)")
@@ -317,7 +310,10 @@ def main(argv: list[str] | None = None) -> int:
317
310
  at.add_argument("--date", required=True, help="when (ISO, e.g. 2026-10-05)")
318
311
  at.add_argument("--ai", action="store_true", help="the reviewer is an AI model, not a human")
319
312
  at.add_argument(
320
- "--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)",
321
317
  )
322
318
  at.add_argument("--comment", default=None, help="the review's feedback, kept on the record")
323
319
  at.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
@@ -354,7 +350,10 @@ def main(argv: list[str] | None = None) -> int:
354
350
  wd = sub.add_parser(
355
351
  "withdraw", help="record a decision against a need — withdrawn / won't-do (#74)"
356
352
  )
357
- 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
+ )
358
357
  wd.add_argument(
359
358
  "--reason", required=True, help="why it was decided against (kept on the record)"
360
359
  )
@@ -366,6 +365,12 @@ def main(argv: list[str] | None = None) -> int:
366
365
  "or duplicate (#74)",
367
366
  )
368
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
+ )
369
374
  wd.add_argument(
370
375
  "--principle",
371
376
  default=None,
@@ -389,7 +394,12 @@ def main(argv: list[str] | None = None) -> int:
389
394
  ri = sub.add_parser(
390
395
  "reinstate", help="remove a need's withdrawal, putting it back on the board (#74)"
391
396
  )
392
- 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
+ )
393
403
  ri.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
394
404
  ri.add_argument("--json", action="store_true", help="machine-readable output")
395
405
  ri.add_argument(
@@ -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:
@@ -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),