prodc 0.3.78__tar.gz → 0.3.80__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 (63) hide show
  1. {prodc-0.3.78 → prodc-0.3.80}/PKG-INFO +1 -1
  2. {prodc-0.3.78 → prodc-0.3.80}/pyproject.toml +1 -1
  3. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/agent.md +3 -0
  4. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/board.py +10 -1
  5. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/app.py +42 -4
  6. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/commands.py +74 -0
  7. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/render.py +24 -2
  8. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/serialize.py +12 -1
  9. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/loader.py +90 -17
  10. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/model.py +30 -0
  11. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/status.py +94 -0
  12. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/writer.py +121 -21
  13. prodc-0.3.80/tests/test_contradicts.py +170 -0
  14. prodc-0.3.80/tests/test_principles.py +332 -0
  15. {prodc-0.3.78 → prodc-0.3.80}/tests/test_withdrawn.py +1 -0
  16. {prodc-0.3.78 → prodc-0.3.80}/.gitignore +0 -0
  17. {prodc-0.3.78 → prodc-0.3.80}/CHANGELOG.md +0 -0
  18. {prodc-0.3.78 → prodc-0.3.80}/LICENSE +0 -0
  19. {prodc-0.3.78 → prodc-0.3.80}/README.md +0 -0
  20. {prodc-0.3.78 → prodc-0.3.80}/examples/quickstart/features/orders.feature +0 -0
  21. {prodc-0.3.78 → prodc-0.3.80}/examples/quickstart/needs/jordan.yaml +0 -0
  22. {prodc-0.3.78 → prodc-0.3.80}/examples/quickstart/prodc.toml +0 -0
  23. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/__init__.py +0 -0
  24. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/adapters.py +0 -0
  25. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/callbacks.py +0 -0
  26. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/__init__.py +0 -0
  27. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/common.py +0 -0
  28. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/config.py +0 -0
  29. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/dedup.py +0 -0
  30. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/py.typed +0 -0
  31. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/report.py +0 -0
  32. {prodc-0.3.78 → prodc-0.3.80}/src/prodc/report_html.py +0 -0
  33. {prodc-0.3.78 → prodc-0.3.80}/tests/test_adapters.py +0 -0
  34. {prodc-0.3.78 → prodc-0.3.80}/tests/test_answer.py +0 -0
  35. {prodc-0.3.78 → prodc-0.3.80}/tests/test_callbacks.py +0 -0
  36. {prodc-0.3.78 → prodc-0.3.80}/tests/test_citation.py +0 -0
  37. {prodc-0.3.78 → prodc-0.3.80}/tests/test_claim_gating.py +0 -0
  38. {prodc-0.3.78 → prodc-0.3.80}/tests/test_cli.py +0 -0
  39. {prodc-0.3.78 → prodc-0.3.80}/tests/test_command_hook.py +0 -0
  40. {prodc-0.3.78 → prodc-0.3.80}/tests/test_dedup.py +0 -0
  41. {prodc-0.3.78 → prodc-0.3.80}/tests/test_description.py +0 -0
  42. {prodc-0.3.78 → prodc-0.3.80}/tests/test_drafts.py +0 -0
  43. {prodc-0.3.78 → prodc-0.3.80}/tests/test_evalgate.py +0 -0
  44. {prodc-0.3.78 → prodc-0.3.80}/tests/test_external_adapter.py +0 -0
  45. {prodc-0.3.78 → prodc-0.3.80}/tests/test_gitlab_source.py +0 -0
  46. {prodc-0.3.78 → prodc-0.3.80}/tests/test_importance.py +0 -0
  47. {prodc-0.3.78 → prodc-0.3.80}/tests/test_loader.py +0 -0
  48. {prodc-0.3.78 → prodc-0.3.80}/tests/test_models.py +0 -0
  49. {prodc-0.3.78 → prodc-0.3.80}/tests/test_needref.py +0 -0
  50. {prodc-0.3.78 → prodc-0.3.80}/tests/test_relink.py +0 -0
  51. {prodc-0.3.78 → prodc-0.3.80}/tests/test_report.py +0 -0
  52. {prodc-0.3.78 → prodc-0.3.80}/tests/test_report_html.py +0 -0
  53. {prodc-0.3.78 → prodc-0.3.80}/tests/test_review.py +0 -0
  54. {prodc-0.3.78 → prodc-0.3.80}/tests/test_roles.py +0 -0
  55. {prodc-0.3.78 → prodc-0.3.80}/tests/test_source_role.py +0 -0
  56. {prodc-0.3.78 → prodc-0.3.80}/tests/test_sources.py +0 -0
  57. {prodc-0.3.78 → prodc-0.3.80}/tests/test_staleness.py +0 -0
  58. {prodc-0.3.78 → prodc-0.3.80}/tests/test_status.py +0 -0
  59. {prodc-0.3.78 → prodc-0.3.80}/tests/test_suggest.py +0 -0
  60. {prodc-0.3.78 → prodc-0.3.80}/tests/test_variants.py +0 -0
  61. {prodc-0.3.78 → prodc-0.3.80}/tests/test_who_variants.py +0 -0
  62. {prodc-0.3.78 → prodc-0.3.80}/tests/test_writer.py +0 -0
  63. {prodc-0.3.78 → prodc-0.3.80}/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.78
3
+ Version: 0.3.80
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.78"
3
+ version = "0.3.80"
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"
@@ -72,8 +72,11 @@ Read first, then act, then check.
72
72
  | record a new need (with honest provenance) | `prodc add need <id> --text "…" --who <you> --kind assumption --file needs/<f>.yaml` |
73
73
  | triage staged drafts | `prodc review` — list them; `--promote <id>…`/`--promote-all` onto the board, `--drop <id>…` to delete |
74
74
  | declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
75
+ | declare a product principle (the vision, held as given) | `prodc add principle <id> --text "…" --file needs/<f>.yaml` — the frame that decides between clients who want opposite things; what an `out_of_scope` decision cites |
76
+ | 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 |
75
77
  | 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) |
76
78
  | set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
79
+ | 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 |
77
80
  | propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
78
81
  | relink a BROKEN proof (renamed test) | `prodc suggest --relink [<id>] [--apply] [--dry-run]` — proposes the nearest live inventory entry to replace the broken locator |
79
82
  | link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
@@ -28,6 +28,7 @@ from .model import (
28
28
  )
29
29
  from .status import (
30
30
  Handoff,
31
+ PrincipleReport,
31
32
  Recurrence,
32
33
  Summary,
33
34
  evidence_age_days,
@@ -36,6 +37,7 @@ from .status import (
36
37
  handoffs,
37
38
  last_observed,
38
39
  orphans,
40
+ principle_report,
39
41
  recurrence,
40
42
  release_report,
41
43
  resolve,
@@ -106,11 +108,13 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
106
108
  "reason": need.withdrawn.reason,
107
109
  "date": need.withdrawn.date,
108
110
  "kind": need.withdrawn.kind.value,
111
+ "principle": need.withdrawn.principle,
109
112
  }
110
113
  if need.withdrawn
111
114
  else None
112
115
  ),
113
116
  "proofs": list(need.proofs),
117
+ "contradicts": list(need.contradicts),
114
118
  "detail": list(r.detail),
115
119
  "warnings": list(r.warnings),
116
120
  "attestation": (
@@ -152,12 +156,14 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
152
156
  "release": need.release,
153
157
  "product": need.product,
154
158
  "draft": need.draft,
159
+ "contradicts": list(need.contradicts),
155
160
  "answer": need.answer.value if need.answer else None,
156
161
  "withdrawn": (
157
162
  {
158
163
  "reason": need.withdrawn.reason,
159
164
  "date": need.withdrawn.date,
160
165
  "kind": need.withdrawn.kind.value,
166
+ "principle": need.withdrawn.principle,
161
167
  }
162
168
  if need.withdrawn
163
169
  else None
@@ -202,6 +208,7 @@ class ResolvedBoard:
202
208
  handoffs: list[Handoff]
203
209
  recurrence: list[Recurrence]
204
210
  orphans: dict[str, list[str]]
211
+ principles: list[PrincipleReport] # over the whole model — declined needs are off the board
205
212
 
206
213
 
207
214
  def gather(
@@ -218,7 +225,8 @@ def gather(
218
225
  appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
219
226
  the markdown/HTML reports leave it off, exactly as they did before this was factored out.
220
227
  The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
221
- are computed over the resolved ``needs``.
228
+ are computed over the resolved ``needs``. The principle report reads the whole ``model.needs``:
229
+ the needs declined under a principle are withdrawn, so the filtered ``needs`` never holds them.
222
230
  """
223
231
  use_needs = list(model.needs) if needs is None else needs
224
232
  results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
@@ -239,4 +247,5 @@ def gather(
239
247
  handoffs=handoffs(use_needs),
240
248
  recurrence=recurrence(use_needs),
241
249
  orphans=orphans(use_needs, cfg.adapters),
250
+ principles=principle_report(list(model.needs), list(model.principles)),
242
251
  )
@@ -29,6 +29,7 @@ from .commands import (
29
29
  cmd_reinstate,
30
30
  cmd_report,
31
31
  cmd_review,
32
+ cmd_revise,
32
33
  cmd_set,
33
34
  cmd_show,
34
35
  cmd_status,
@@ -165,11 +166,15 @@ def main(argv: list[str] | None = None) -> int:
165
166
  st2.set_defaults(func=cmd_set)
166
167
 
167
168
  ad = sub.add_parser(
168
- "add", help="create a new agent-authored need, or declare a role (writes YAML)"
169
+ "add",
170
+ help="create a new agent-authored need, or declare a role or a principle (writes YAML)",
169
171
  )
170
- ad.add_argument("noun", choices=["need", "role"], help="what to add (need or role)")
171
- ad.add_argument("need_id", help="the new need's id (or role id for `add role`), e.g. N-9")
172
- ad.add_argument("--text", required=True, help="the need text (or the role description)")
172
+ ad.add_argument("noun", choices=["need", "role", "principle"], help="what to add")
173
+ ad.add_argument(
174
+ "need_id",
175
+ help="the new need's id (or role id for `add role`, principle id for `add principle`), e.g. N-9",
176
+ )
177
+ ad.add_argument("--text", required=True, help="the need text (or the role / principle text)")
173
178
  ad.add_argument(
174
179
  "--who",
175
180
  default=None,
@@ -315,6 +320,13 @@ def main(argv: list[str] | None = None) -> int:
315
320
  "or duplicate (#74)",
316
321
  )
317
322
  wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
323
+ wd.add_argument(
324
+ "--principle",
325
+ default=None,
326
+ metavar="ID",
327
+ help="the product principle this decision rests on (validated against principles:) — revising it "
328
+ "later flags this decision for re-review",
329
+ )
318
330
  wd.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
319
331
  wd.add_argument("--json", action="store_true", help="machine-readable output")
320
332
  wd.add_argument(
@@ -345,6 +357,32 @@ def main(argv: list[str] | None = None) -> int:
345
357
  )
346
358
  ri.set_defaults(func=cmd_reinstate)
347
359
 
360
+ rvz = sub.add_parser(
361
+ "revise", help="revise a product principle in place — new text and/or a fresh revised_at"
362
+ )
363
+ rvz.add_argument("noun", choices=["principle"], help="what to revise (principle)")
364
+ rvz.add_argument("principle_id", help="the principle id to revise, e.g. P-1")
365
+ rvz.add_argument(
366
+ "--text", default=None, help="new principle text (optional; omit to only re-date it)"
367
+ )
368
+ rvz.add_argument(
369
+ "--date",
370
+ default=None,
371
+ help="the new revised_at (ISO, e.g. 2026-10-15; default: today) — fires the ripple",
372
+ )
373
+ rvz.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
374
+ rvz.add_argument("--json", action="store_true", help="machine-readable output")
375
+ rvz.add_argument(
376
+ "--dry-run", action="store_true", help="validate and show the diff, don't write"
377
+ )
378
+ rvz.add_argument(
379
+ "--expect-sha",
380
+ default=None,
381
+ metavar="SHA256",
382
+ help="refuse if the file's content hash differs (optimistic lock)",
383
+ )
384
+ rvz.set_defaults(func=cmd_revise)
385
+
348
386
  rv = sub.add_parser(
349
387
  "review", help="triage staged drafts: list them, or --promote / --drop a batch (#60)"
350
388
  )
@@ -36,8 +36,10 @@ from ..status import (
36
36
  RelinkSuggestion,
37
37
  applicable_needs,
38
38
  argument_defects,
39
+ contradictions,
39
40
  evidence_age_days,
40
41
  importance_revisit_due,
42
+ principle_report,
41
43
  proof_suggestions,
42
44
  recurrence,
43
45
  relink_suggestions,
@@ -49,6 +51,7 @@ from ..writer import (
49
51
  WriteError,
50
52
  accept,
51
53
  add_need,
54
+ add_principle,
52
55
  add_proof,
53
56
  add_role,
54
57
  attest,
@@ -57,6 +60,7 @@ from ..writer import (
57
60
  link_variant_group,
58
61
  reinstate,
59
62
  relink_proof,
63
+ revise_principle,
60
64
  set_field,
61
65
  set_importance,
62
66
  withdraw,
@@ -83,6 +87,7 @@ from .render import (
83
87
  render_out_of_scope,
84
88
  render_plan,
85
89
  render_portfolio,
90
+ render_principles,
86
91
  render_recurrence,
87
92
  render_releases,
88
93
  render_roles,
@@ -232,6 +237,28 @@ def cmd_lint(args: argparse.Namespace) -> int:
232
237
  _annotate_importance_warnings(results, cfg)
233
238
  _annotate_draft_age_warnings(results, cfg)
234
239
  _annotate_label_deprecation(results)
240
+ # The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
241
+ # comes back up — the frame it was judged against has changed since. A warning, never a hard
242
+ # error; prodc flags, the PM re-decides.
243
+ for rep in principle_report(needs, list(model.principles)):
244
+ for n in rep.stale:
245
+ if n.id in by_id and n.withdrawn is not None:
246
+ by_id[n.id].warnings.append(
247
+ f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
248
+ f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
249
+ )
250
+ # Unresolved contradiction: two needs oppose each other yet BOTH are still live — the product
251
+ # vision has not decided between them. A warning on both sides (withdraw one out_of_scope citing
252
+ # the principle that decides, or reconcile them).
253
+ for c in contradictions(needs):
254
+ if not c.unresolved:
255
+ continue
256
+ for near, far in ((c.a, c.b), (c.b, c.a)):
257
+ if near.id in by_id:
258
+ by_id[near.id].warnings.append(
259
+ f"unresolved contradiction with {far.id} — both live, the vision has not "
260
+ "decided yet (withdraw one out_of_scope citing the principle, or reconcile)"
261
+ )
235
262
  errors = _lint_errors(results)
236
263
  warnings = [
237
264
  (r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
@@ -318,6 +345,7 @@ def cmd_status(args: argparse.Namespace) -> int:
318
345
  role_cov,
319
346
  unroled,
320
347
  hos,
348
+ principles=board.principles,
321
349
  )
322
350
  )
323
351
  else:
@@ -350,6 +378,8 @@ def cmd_status(args: argparse.Namespace) -> int:
350
378
  print(render_handoffs(hos))
351
379
  if recs:
352
380
  print(render_recurrence(recs))
381
+ if model.principles:
382
+ print(render_principles(board.principles))
353
383
  if orphan_map:
354
384
  print(render_orphans(orphan_map))
355
385
 
@@ -479,6 +509,22 @@ def cmd_add(args: argparse.Namespace) -> int:
479
509
  print(f"prodc: {exc}", file=sys.stderr)
480
510
  return 2
481
511
  return emit_edit(edit, args.dry_run, args.json)
512
+ if args.noun == "principle":
513
+ try:
514
+ cfg = Config.load(cfg_path)
515
+ edit = add_principle(
516
+ cfg,
517
+ args.need_id,
518
+ args.text,
519
+ file=args.file,
520
+ revised_at=date.today().isoformat(),
521
+ expect_sha=args.expect_sha,
522
+ dry_run=args.dry_run,
523
+ )
524
+ except (ConfigError, LoaderError, WriteError) as exc:
525
+ print(f"prodc: {exc}", file=sys.stderr)
526
+ return 2
527
+ return emit_edit(edit, args.dry_run, args.json)
482
528
  if args.who is None:
483
529
  print("prodc: add need requires --who (the author recorded on the source)", file=sys.stderr)
484
530
  return 2
@@ -578,6 +624,7 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
578
624
  reason=args.reason,
579
625
  date=args.date or date.today().isoformat(),
580
626
  kind=args.kind,
627
+ principle=args.principle,
581
628
  expect_sha=args.expect_sha,
582
629
  dry_run=args.dry_run,
583
630
  )
@@ -607,6 +654,33 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
607
654
  return emit_edit(edit, args.dry_run, args.json)
608
655
 
609
656
 
657
+ def cmd_revise(args: argparse.Namespace) -> int:
658
+ """Revise a product principle in place: optional new text, always a fresh revised_at (the ripple).
659
+
660
+ Re-stamping ``revised_at`` is the point — every need withdrawn citing this principle with an
661
+ earlier date comes back up for re-review (surfaced by `lint`). Comment-safe: only the principle's
662
+ own block is spliced, the rest of the file is untouched.
663
+ """
664
+ cfg_path = Path(args.config)
665
+ if not cfg_path.exists():
666
+ print(f"prodc: no config at {cfg_path}", file=sys.stderr)
667
+ return 2
668
+ try:
669
+ cfg = Config.load(cfg_path)
670
+ edit = revise_principle(
671
+ cfg,
672
+ args.principle_id,
673
+ text=args.text,
674
+ revised_at=args.date or date.today().isoformat(),
675
+ expect_sha=args.expect_sha,
676
+ dry_run=args.dry_run,
677
+ )
678
+ except (ConfigError, LoaderError, WriteError) as exc:
679
+ print(f"prodc: {exc}", file=sys.stderr)
680
+ return 2
681
+ return emit_edit(edit, args.dry_run, args.json)
682
+
683
+
610
684
  def cmd_review(args: argparse.Namespace) -> int:
611
685
  """Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
612
686
 
@@ -21,7 +21,7 @@ from ..model import (
21
21
  Role,
22
22
  ears_shape,
23
23
  )
24
- from ..status import Handoff, Recurrence, Summary, rank, state_priority
24
+ from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank, state_priority
25
25
  from .common import PortfolioTotals, RepoRow, brief_open
26
26
 
27
27
  _LABEL = {
@@ -261,6 +261,25 @@ def render_recurrence(recs: list[Recurrence]) -> str:
261
261
  return "\n".join(out)
262
262
 
263
263
 
264
+ def render_principles(reports: list[PrincipleReport]) -> str:
265
+ """The product vision as principles, with the decisions resting on each and which have gone stale.
266
+
267
+ An principle is held as given, not validated; ``declined`` are the needs withdrawn citing it, and
268
+ ``stale`` (the ripple) are those decided before its latest ``revised_at`` — flagged to re-review.
269
+ """
270
+ out = ["\nprinciples (the product vision — held as given):"]
271
+ for rep in reports:
272
+ a = rep.principle
273
+ rev = f"revised {a.revised_at}" if a.revised_at else "undated"
274
+ out.append(f" {a.id:<6} {rev} {a.text}")
275
+ if rep.declined:
276
+ out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
277
+ if rep.stale:
278
+ ids = ", ".join(n.id for n in rep.stale)
279
+ out.append(f" ⚠ decided before the latest revision: {ids} — re-review")
280
+ return "\n".join(out)
281
+
282
+
264
283
  def render_show(r: NeedResult, importance_label: str | None = None) -> str:
265
284
  """One need as a citable answer — the artifact you drop into a thread."""
266
285
  need = r.need
@@ -278,7 +297,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
278
297
  if need.withdrawn:
279
298
  w = need.withdrawn
280
299
  tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}]"
281
- lines.append(f" withdrawn{tag}: {w.reason} ({w.date})")
300
+ per = f" per {w.principle}" if w.principle else ""
301
+ lines.append(f" withdrawn{tag}{per}: {w.reason} ({w.date})")
282
302
  if need.description:
283
303
  lines.append(f" description: {need.description}")
284
304
  if need.importance is not None:
@@ -320,6 +340,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
320
340
  lines.append(" sources: (none — opinion)")
321
341
  if need.children:
322
342
  lines.append(f" children: {', '.join(need.children)}")
343
+ if need.contradicts:
344
+ lines.append(f" contradicts: {', '.join(need.contradicts)}")
323
345
  if need.seam:
324
346
  lines.append(f" seam: {', '.join(need.seam)}")
325
347
  if need.proofs:
@@ -13,7 +13,7 @@ from ..board import need_payload
13
13
  from ..board import warrant as _warrant
14
14
  from ..dedup import DupPair, VariantGroup
15
15
  from ..model import Goal, Model, NeedResult, NeedState, Release, Role
16
- from ..status import Handoff, Recurrence, Summary, rank
16
+ from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
17
17
  from .common import brief_open
18
18
 
19
19
 
@@ -43,6 +43,7 @@ def to_json(
43
43
  role_cov: list[tuple[Role, Summary]],
44
44
  unroled: list[NeedResult],
45
45
  hos: list[Handoff],
46
+ principles: list[PrincipleReport] | None = None,
46
47
  ) -> str:
47
48
  payload = {
48
49
  "summary": _summary_payload(summary, orphan_map),
@@ -100,6 +101,16 @@ def to_json(
100
101
  for goal, gsum in goal_cov
101
102
  ],
102
103
  "ungoaled": [r.need.id for r in ungoaled],
104
+ "principles": [
105
+ {
106
+ "id": rep.principle.id,
107
+ "text": rep.principle.text,
108
+ "revised_at": rep.principle.revised_at,
109
+ "declined": [n.id for n in rep.declined],
110
+ "stale": [n.id for n in rep.stale],
111
+ }
112
+ for rep in (principles or [])
113
+ ],
103
114
  "needs": [need_payload(r) for r in results],
104
115
  }
105
116
  return json.dumps(payload, indent=2)
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import datetime
11
11
  from collections.abc import Callable
12
- from dataclasses import dataclass, field
12
+ from dataclasses import dataclass, field, replace
13
13
  from pathlib import Path
14
14
  from typing import cast
15
15
 
@@ -25,6 +25,7 @@ from .model import (
25
25
  Goal,
26
26
  Model,
27
27
  Need,
28
+ Principle,
28
29
  Release,
29
30
  Role,
30
31
  Source,
@@ -131,12 +132,13 @@ def _answer(raw: object, where: str) -> Answer | None:
131
132
 
132
133
 
133
134
  def _withdrawal(raw: object, where: str) -> Withdrawal | None:
134
- """Parse ``withdrawn: {reason, date, kind?}`` into a :class:`Withdrawal` (#74).
135
+ """Parse ``withdrawn: {reason, date, kind?, principle?}`` into a :class:`Withdrawal` (#74).
135
136
 
136
137
  ``reason`` and ``date`` are required when the key is present — a decision against a need must
137
138
  say why and when, the same way an attestation needs who and when. ``kind`` is optional and
138
139
  defaults to ``wont_do``; it is validated with the same ``Enum(name) except ValueError`` pattern
139
- as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns.
140
+ as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns. ``principle`` is
141
+ an optional Principle id, checked against the declared principles once every file is loaded.
140
142
  """
141
143
  if raw is None:
142
144
  return None
@@ -153,7 +155,8 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
153
155
  raise LoaderError(
154
156
  f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})"
155
157
  ) from None
156
- return Withdrawal(reason=reason, date=date, kind=kind)
158
+ principle = _opt_str(tbl.get("principle"), f"{where}.principle")
159
+ return Withdrawal(reason=reason, date=date, kind=kind, principle=principle)
157
160
 
158
161
 
159
162
  # Old source-kind names (pre-0.3.58) mapped to the primary/secondary/assumption ladder, so
@@ -241,6 +244,10 @@ def _need(
241
244
  _as_str(d, f"{where}.not_variant_of[{i}]")
242
245
  for i, d in enumerate(_as_list(tbl.get("not_variant_of"), f"{where}.not_variant_of"))
243
246
  )
247
+ contradicts = tuple(
248
+ _as_str(c, f"{where}.contradicts[{i}]")
249
+ for i, c in enumerate(_as_list(tbl.get("contradicts"), f"{where}.contradicts"))
250
+ )
244
251
  if children and proofs:
245
252
  raise LoaderError(
246
253
  f"{where} ({nid}): a flow (need with children) proves its steps via the "
@@ -271,6 +278,7 @@ def _need(
271
278
  product=product,
272
279
  not_duplicate_of=not_duplicate_of,
273
280
  not_variant_of=not_variant_of,
281
+ contradicts=contradicts,
274
282
  importance_set_at=importance_set_at,
275
283
  draft=draft,
276
284
  draft_added=draft_added,
@@ -304,6 +312,20 @@ def _role(raw: object, where: str) -> Role:
304
312
  )
305
313
 
306
314
 
315
+ def _principle(raw: object, where: str) -> Principle:
316
+ """Parse one ``principles:`` entry — a product commitment with its provenance."""
317
+ tbl = _as_mapping(raw, where)
318
+ return Principle(
319
+ id=_as_str(tbl.get("id"), f"{where}.id"),
320
+ text=_as_str(tbl.get("text"), f"{where}.text"),
321
+ revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
322
+ sources=tuple(
323
+ _source(s, f"{where}.sources[{i}]")
324
+ for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
325
+ ),
326
+ )
327
+
328
+
307
329
  def _arg_node(raw: object, where: str) -> ArgNode:
308
330
  tbl = _as_mapping(raw, where)
309
331
  kind_name = _as_str(tbl.get("kind"), f"{where}.kind")
@@ -336,11 +358,12 @@ class _Loaded:
336
358
  goals: list[Goal] = field(default_factory=list[Goal])
337
359
  releases: list[Release] = field(default_factory=list[Release])
338
360
  roles: list[Role] = field(default_factory=list[Role])
361
+ principles: list[Principle] = field(default_factory=list[Principle])
339
362
  argument: list[ArgNode] = field(default_factory=list[ArgNode])
340
363
 
341
364
 
342
365
  def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
343
- """Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``releases:``)."""
366
+ """Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``principles:``/…)."""
344
367
  where = path.name
345
368
  try:
346
369
  doc = yaml.safe_load(path.read_text(encoding="utf-8"))
@@ -376,6 +399,10 @@ def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
376
399
  role = _role(raw, f"{where}.roles[{i}]")
377
400
  _unique("role", role.id)
378
401
  out.roles.append(role)
402
+ for i, raw in enumerate(_as_list(tbl.get("principles"), f"{where}.principles")):
403
+ principle = _principle(raw, f"{where}.principles[{i}]")
404
+ _unique("principle", principle.id)
405
+ out.principles.append(principle)
379
406
  for i, raw in enumerate(_as_list(tbl.get("argument"), f"{where}.argument")):
380
407
  node = _arg_node(raw, f"{where}.argument[{i}]")
381
408
  _unique("argument node", node.id)
@@ -387,10 +414,11 @@ def load(config: Config) -> Model:
387
414
  """Read every prodc file once into a :class:`~prodc.model.Model`, then validate cross-refs.
388
415
 
389
416
  One loader for every noun: ids are unique within their kind, and each string reference
390
- (``variant_of``, ``children``, ``goal``, ``release``, ``role``) is checked here as a hard
391
- error — a dangling reference to something declared in these files is a typo you own, not a
392
- runtime unknown. ``role`` is checked unconditionally like ``goal``/``release``: it is a new
393
- field, so (unlike free-text ``who``) there is no migration reason to make it lax.
417
+ (``variant_of``, ``children``, ``goal``, ``release``, ``role``, a withdrawal's ``principle``) is
418
+ checked here as a hard error — a dangling reference to something declared in these files is a
419
+ typo you own, not a runtime unknown. ``role`` is checked unconditionally like
420
+ ``goal``/``release``: it is a new field, so (unlike free-text ``who``) there is no migration
421
+ reason to make it lax.
394
422
  """
395
423
  acc = _Loaded()
396
424
  seen: dict[str, dict[str, Path]] = {
@@ -398,6 +426,7 @@ def load(config: Config) -> Model:
398
426
  "goal": {},
399
427
  "release": {},
400
428
  "role": {},
429
+ "principle": {},
401
430
  "argument node": {},
402
431
  }
403
432
  for pattern in config.need_globs:
@@ -407,19 +436,29 @@ def load(config: Config) -> Model:
407
436
  acc.goals.extend(f.goals)
408
437
  acc.releases.extend(f.releases)
409
438
  acc.roles.extend(f.roles)
439
+ acc.principles.extend(f.principles)
410
440
  acc.argument.extend(f.argument)
411
441
  _check_variants(acc.needs)
412
442
  _check_children(acc.needs)
443
+ _check_contradicts(acc.needs)
413
444
  _check_ref(acc.needs, "goal", lambda n: n.goal, {g.id for g in acc.goals})
414
445
  _check_ref(acc.needs, "release", lambda n: n.release, {r.id for r in acc.releases})
415
446
  _check_ref(acc.needs, "role", lambda n: n.role, {p.id for p in acc.roles})
416
- _check_source_roles(acc.needs, {p.id for p in acc.roles})
447
+ _check_ref(
448
+ acc.needs,
449
+ "principle",
450
+ lambda n: n.withdrawn.principle if n.withdrawn else None,
451
+ {a.id for a in acc.principles},
452
+ )
453
+ _check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
417
454
  _check_argument(acc.argument)
455
+ acc.needs = _symmetrize_contradicts(acc.needs)
418
456
  return Model(
419
457
  needs=tuple(acc.needs),
420
458
  goals=tuple(acc.goals),
421
459
  releases=tuple(acc.releases),
422
460
  roles=tuple(acc.roles),
461
+ principles=tuple(acc.principles),
423
462
  argument=tuple(acc.argument),
424
463
  )
425
464
 
@@ -439,18 +478,21 @@ def _check_ref(
439
478
  raise LoaderError(f"need {need.id!r}: {role} {ref!r} is not a known {role} id")
440
479
 
441
480
 
442
- def _check_source_roles(needs: list[Need], known: set[str]) -> None:
481
+ def _check_source_roles(needs: list[Need], principles: list[Principle], known: set[str]) -> None:
443
482
  """Every ``source.role`` that is set must name a declared role id (like ``need.role``, #52).
444
483
 
445
- Validated unconditionally: ``role`` is a new, opt-in field, so there is no migration reason to
446
- be lax — a dangling role on a source is a typo the author owns, surfaced at load, not a silent
447
- unknown that only shows as missing provenance later.
484
+ Validated unconditionally, on a need's sources and a principle's alike: ``role`` is a new, opt-in
485
+ field, so there is no migration reason to be lax — a dangling role on a source is a typo the
486
+ author owns, surfaced at load, not a silent unknown that only shows as missing provenance later.
448
487
  """
449
- for need in needs:
450
- for i, s in enumerate(need.sources):
488
+ owners = [("need", n.id, n.sources) for n in needs] + [
489
+ ("principle", a.id, a.sources) for a in principles
490
+ ]
491
+ for noun, oid, sources in owners:
492
+ for i, s in enumerate(sources):
451
493
  if s.role is not None and s.role not in known:
452
494
  raise LoaderError(
453
- f"need {need.id!r}: source[{i}] role {s.role!r} is not a known role id"
495
+ f"{noun} {oid!r}: source[{i}] role {s.role!r} is not a known role id"
454
496
  )
455
497
 
456
498
 
@@ -479,6 +521,37 @@ def _check_children(needs: list[Need]) -> None:
479
521
  raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
480
522
 
481
523
 
524
+ def _check_contradicts(needs: list[Need]) -> None:
525
+ """Every id in a need's ``contradicts`` must name a known need, and never the need itself."""
526
+ known = {n.id for n in needs}
527
+ for need in needs:
528
+ for cid in need.contradicts:
529
+ if cid == need.id:
530
+ raise LoaderError(f"need {need.id!r}: contradicts itself")
531
+ if cid not in known:
532
+ raise LoaderError(f"need {need.id!r}: contradicts {cid!r} is not a known need id")
533
+
534
+
535
+ def _symmetrize_contradicts(needs: list[Need]) -> list[Need]:
536
+ """Close the ``contradicts`` relation symmetrically: if A declares B, B contradicts A too.
537
+
538
+ The file stays single-declaration — declare the edge once, so the comment-safe writer never has
539
+ to touch two needs — but the loaded model carries the full symmetric neighbour set on both
540
+ needs, so every reader (show, lint, the JSON payload) sees the one relation. Run after
541
+ :func:`_check_contradicts`, so every target id is known.
542
+ """
543
+ edges: dict[str, set[str]] = {n.id: set(n.contradicts) for n in needs}
544
+ for n in needs:
545
+ for t in n.contradicts:
546
+ edges[t].add(n.id)
547
+ return [
548
+ replace(n, contradicts=tuple(sorted(edges[n.id])))
549
+ if edges[n.id] != set(n.contradicts)
550
+ else n
551
+ for n in needs
552
+ ]
553
+
554
+
482
555
  def _check_variants(needs: list[Need]) -> None:
483
556
  """Every ``variant_of`` must point at a known baseline need (one that is not itself a variant)."""
484
557
  by_id = {n.id: n for n in needs}