prodc 0.3.65__tar.gz → 0.3.72__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 (59) hide show
  1. {prodc-0.3.65 → prodc-0.3.72}/PKG-INFO +1 -1
  2. prodc-0.3.72/examples/quickstart/needs/jordan.yaml +40 -0
  3. {prodc-0.3.65 → prodc-0.3.72}/examples/quickstart/prodc.toml +5 -1
  4. {prodc-0.3.65 → prodc-0.3.72}/pyproject.toml +1 -1
  5. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/adapters.py +111 -0
  6. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/agent.md +1 -0
  7. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/board.py +12 -0
  8. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/cli/app.py +40 -0
  9. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/cli/commands.py +48 -1
  10. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/cli/render.py +4 -0
  11. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/config.py +9 -1
  12. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/loader.py +45 -0
  13. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/model.py +64 -14
  14. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/report.py +8 -3
  15. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/report_html.py +5 -1
  16. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/status.py +101 -6
  17. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/writer.py +66 -0
  18. prodc-0.3.72/tests/test_answer.py +154 -0
  19. prodc-0.3.72/tests/test_citation.py +39 -0
  20. prodc-0.3.72/tests/test_claim_gating.py +66 -0
  21. prodc-0.3.72/tests/test_evalgate.py +100 -0
  22. prodc-0.3.72/tests/test_needref.py +175 -0
  23. prodc-0.3.72/tests/test_withdrawn.py +256 -0
  24. prodc-0.3.65/examples/quickstart/needs/jordan.yaml +0 -24
  25. {prodc-0.3.65 → prodc-0.3.72}/.gitignore +0 -0
  26. {prodc-0.3.65 → prodc-0.3.72}/CHANGELOG.md +0 -0
  27. {prodc-0.3.65 → prodc-0.3.72}/LICENSE +0 -0
  28. {prodc-0.3.65 → prodc-0.3.72}/README.md +0 -0
  29. {prodc-0.3.65 → prodc-0.3.72}/examples/quickstart/features/orders.feature +0 -0
  30. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/__init__.py +0 -0
  31. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/callbacks.py +0 -0
  32. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/cli/__init__.py +0 -0
  33. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/cli/common.py +0 -0
  34. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/cli/serialize.py +0 -0
  35. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/dedup.py +0 -0
  36. {prodc-0.3.65 → prodc-0.3.72}/src/prodc/py.typed +0 -0
  37. {prodc-0.3.65 → prodc-0.3.72}/tests/test_adapters.py +0 -0
  38. {prodc-0.3.65 → prodc-0.3.72}/tests/test_callbacks.py +0 -0
  39. {prodc-0.3.65 → prodc-0.3.72}/tests/test_cli.py +0 -0
  40. {prodc-0.3.65 → prodc-0.3.72}/tests/test_command_hook.py +0 -0
  41. {prodc-0.3.65 → prodc-0.3.72}/tests/test_dedup.py +0 -0
  42. {prodc-0.3.65 → prodc-0.3.72}/tests/test_description.py +0 -0
  43. {prodc-0.3.65 → prodc-0.3.72}/tests/test_drafts.py +0 -0
  44. {prodc-0.3.65 → prodc-0.3.72}/tests/test_external_adapter.py +0 -0
  45. {prodc-0.3.65 → prodc-0.3.72}/tests/test_gitlab_source.py +0 -0
  46. {prodc-0.3.65 → prodc-0.3.72}/tests/test_importance.py +0 -0
  47. {prodc-0.3.65 → prodc-0.3.72}/tests/test_loader.py +0 -0
  48. {prodc-0.3.65 → prodc-0.3.72}/tests/test_models.py +0 -0
  49. {prodc-0.3.65 → prodc-0.3.72}/tests/test_report.py +0 -0
  50. {prodc-0.3.65 → prodc-0.3.72}/tests/test_report_html.py +0 -0
  51. {prodc-0.3.65 → prodc-0.3.72}/tests/test_roles.py +0 -0
  52. {prodc-0.3.65 → prodc-0.3.72}/tests/test_sources.py +0 -0
  53. {prodc-0.3.65 → prodc-0.3.72}/tests/test_staleness.py +0 -0
  54. {prodc-0.3.65 → prodc-0.3.72}/tests/test_status.py +0 -0
  55. {prodc-0.3.65 → prodc-0.3.72}/tests/test_suggest.py +0 -0
  56. {prodc-0.3.65 → prodc-0.3.72}/tests/test_variants.py +0 -0
  57. {prodc-0.3.65 → prodc-0.3.72}/tests/test_who_variants.py +0 -0
  58. {prodc-0.3.65 → prodc-0.3.72}/tests/test_writer.py +0 -0
  59. {prodc-0.3.65 → prodc-0.3.72}/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.65
3
+ Version: 0.3.72
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
@@ -0,0 +1,40 @@
1
+ who: jordan
2
+ label: "design partner"
3
+ needs:
4
+ - id: N-1
5
+ text: "see which orders are past due at a glance"
6
+ must: true
7
+ sources:
8
+ - kind: primary
9
+ who: Jordan
10
+ date: 2026-09-20
11
+ quote: "I lose the past-due ones in the list"
12
+ proofs:
13
+ - "web:orders.feature#Past due orders are highlighted"
14
+
15
+ - id: N-2
16
+ text: "export the day's delivery run sheet"
17
+ sources:
18
+ - kind: secondary
19
+ locator: issues.md#N-2
20
+ date: 2026-09-21
21
+ proofs: [] # sourced HOLE: the need is evidenced, but no test proves it yet
22
+
23
+ - id: N-3
24
+ text: "implement a settings page" # opinion (no source) + solution-in-disguise warning
25
+
26
+ # A product need is a hypothesis, same as a research question. Stating the `claim` (the warrant
27
+ # the bet would establish) silences the EARS/solution-verb lint — the honest act, not a mode.
28
+ # The experiment shipped and settled the question NO: DELIVERED + answer: no. It stays on the
29
+ # board and counts as a result — unlike `withdrawn`, which would hide that something was learned.
30
+ - id: N-4
31
+ text: "pin the daily summary to the top of the dashboard"
32
+ claim: "pinning the summary lifts next-day return visits"
33
+ sources:
34
+ - kind: primary
35
+ who: Jordan
36
+ date: 2026-09-22
37
+ quote: "I never scroll back up to the summary"
38
+ proofs:
39
+ - "ship:jordan@2026-10-05" # the A/B experiment ran and concluded
40
+ answer: no # return visits did not move — the bet is settled, and the answer is no
@@ -1,7 +1,8 @@
1
1
  # A minimal, runnable prodc project. From this directory:
2
2
  # uv run prodc status
3
3
  # N-1 shows DELIVERED (a tagged scenario proves it), N-2 is a sourced HOLE
4
- # (evidence exists, no test yet), N-3 is an opinion with a solution-in-disguise warning.
4
+ # (evidence exists, no test yet), N-3 is an opinion with a solution-in-disguise warning,
5
+ # N-4 is a shipped hypothesis that settled NO (DELIVERED + answer: no — a result, not a gap).
5
6
 
6
7
  [prodc]
7
8
  needs = ["needs/*.yaml"]
@@ -11,3 +12,6 @@ kind = "gherkin"
11
12
  paths = ["features/*.feature"]
12
13
  delivered = ["@implemented"]
13
14
  pending = ["@pending"]
15
+
16
+ [proofs.ship]
17
+ kind = "accept" # a human records that an experiment ran and concluded (declared)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.65"
3
+ version = "0.3.72"
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"
@@ -40,6 +40,23 @@ def _steps_passed(steps: list[object]) -> bool:
40
40
  return True
41
41
 
42
42
 
43
+ def _as_score(val: object) -> float | None:
44
+ """A numeric eval score from a results value: a bare number, or ``{"score": N}``.
45
+
46
+ ``bool`` is a subclass of ``int`` but is a pass/fail, not a score, so it is rejected — a
47
+ boolean result belongs to :class:`OutcomeAdapter`, not the gate.
48
+ """
49
+ if isinstance(val, bool):
50
+ return None
51
+ if isinstance(val, (int, float)):
52
+ return float(val)
53
+ if isinstance(val, dict):
54
+ inner = cast("dict[str, object]", val).get("score")
55
+ if isinstance(inner, (int, float)) and not isinstance(inner, bool):
56
+ return float(inner)
57
+ return None
58
+
59
+
43
60
  def _git_head(repo: Path) -> str | None:
44
61
  """Short git rev of a checked-out repo, read from .git/HEAD (no git binary needed)."""
45
62
  head = repo / ".git" / "HEAD"
@@ -486,6 +503,79 @@ class OutcomeAdapter:
486
503
  return ProofResult(ProofState.PENDING, False, f"outcome '{payload}' recorded failing")
487
504
 
488
505
 
506
+ #: Comparators an evalgate locator may use, strongest-looking first so ``>=``/``<=`` match
507
+ #: before the bare ``>``/``<`` when scanning the locator.
508
+ _EVAL_OPS: dict[str, Callable[[float, float], bool]] = {
509
+ ">=": lambda s, t: s >= t,
510
+ "<=": lambda s, t: s <= t,
511
+ ">": lambda s, t: s > t,
512
+ "<": lambda s, t: s < t,
513
+ }
514
+ _EVAL_RE = re.compile(r"^(?P<key>.+?)(?P<op><=|>=|<|>)(?P<thr>[^<>=]+)$")
515
+
516
+
517
+ class EvalGateAdapter:
518
+ """Resolves ``metric>=threshold`` against a recorded eval score (#75).
519
+
520
+ A scored sibling of :class:`OutcomeAdapter`: where that reads a pass/fail bool, this reads a
521
+ numeric score and gates it against a threshold carried in the locator (``accuracy>=0.9``), so
522
+ one adapter serves many metrics and cutoffs (``>=``, ``<=``, ``>``, ``<`` — ``<=`` for a loss
523
+ you want below a bound). The score is produced by an eval harness *outside* prodc and recorded
524
+ in a JSON file (``{"accuracy": {"score": 0.82}}`` or the bare ``{"accuracy": 0.82}``); prodc
525
+ only reads it — records-only, never runs the eval.
526
+
527
+ DELIVERED when the comparison holds; PENDING when the score is recorded but does not meet the
528
+ gate, or the metric is absent from a present file (recorded-below and not-yet-recorded are both
529
+ "not green", as in :class:`OutcomeAdapter`); BROKEN when the results file is absent or the
530
+ locator is malformed. The score, comparator and threshold go in the detail so the board shows
531
+ how far off it is.
532
+ """
533
+
534
+ def __init__(self, root: Path, results: str) -> None:
535
+ self._path = root / results
536
+ self._data: dict[str, float] | None = None
537
+ self._loaded = False
538
+
539
+ def _load(self) -> dict[str, float] | None:
540
+ if not self._loaded:
541
+ self._loaded = True
542
+ if self._path.exists():
543
+ out: dict[str, float] = {}
544
+ raw: object = json.loads(self._path.read_text(encoding="utf-8"))
545
+ if isinstance(raw, dict):
546
+ for key, val in cast("dict[str, object]", raw).items():
547
+ score = _as_score(val)
548
+ if score is not None:
549
+ out[key] = score
550
+ self._data = out
551
+ return self._data
552
+
553
+ def resolve(self, payload: str) -> ProofResult:
554
+ m = _EVAL_RE.match(payload.strip())
555
+ if not m:
556
+ return ProofResult(
557
+ ProofState.BROKEN, False, f"malformed evalgate '{payload}' (want metric>=threshold)"
558
+ )
559
+ key = m.group("key").strip()
560
+ op = m.group("op")
561
+ try:
562
+ threshold = float(m.group("thr").strip())
563
+ except ValueError:
564
+ return ProofResult(
565
+ ProofState.BROKEN, False, f"evalgate threshold not a number '{payload}'"
566
+ )
567
+ data = self._load()
568
+ if data is None:
569
+ return ProofResult(ProofState.BROKEN, False, f"no results file '{self._path.name}'")
570
+ if key not in data:
571
+ return ProofResult(ProofState.PENDING, False, f"metric '{key}' not in results")
572
+ score = data[key]
573
+ passed = _EVAL_OPS[op](score, threshold)
574
+ verdict = "pass" if passed else "below gate"
575
+ detail = f"{key} {score:g} {op} {threshold:g} ({verdict})"
576
+ return ProofResult(ProofState.DELIVERED if passed else ProofState.PENDING, False, detail)
577
+
578
+
489
579
  class CommandProof:
490
580
  """Resolves a locator by running a user-supplied command — a proof hook (#70).
491
581
 
@@ -549,6 +639,27 @@ class PrototypeAdapter:
549
639
  return ProofResult(ProofState.DELIVERED, True, detail)
550
640
 
551
641
 
642
+ class CitationAdapter:
643
+ """Resolves ``ref@author@year`` to a cited published result — a declared DELIVERED (#75).
644
+
645
+ A research question can be settled by citing a result a human vouches for and prodc cannot
646
+ check (a published paper, a textbook theorem). Modelled on :class:`PrototypeAdapter`: a
647
+ *declared* DELIVERED (``*``), never BROKEN, because it is an assertion, like a tag. Kept
648
+ distinct from ``accept`` on purpose — ``accept`` records *who signed off*; a citation records
649
+ *what external result settles it*, and ``ref`` (the thing a reader follows to check the claim)
650
+ is the load-bearing payload ``accept`` has no slot for: ``accept:Smith2019@jorge@...`` would
651
+ read as "jorge accepted it", hiding that the truth is "Smith 2019 shows it".
652
+ """
653
+
654
+ def resolve(self, payload: str) -> ProofResult:
655
+ ref, _, rest = payload.partition("@")
656
+ author, _, year = rest.partition("@")
657
+ detail = "cites " + (ref or "?")
658
+ detail += f" by {author}" if author else ""
659
+ detail += f" ({year})" if year else ""
660
+ return ProofResult(ProofState.DELIVERED, True, detail)
661
+
662
+
552
663
  class External:
553
664
  """Wraps a proof adapter that resolves against a sibling repo checked out at ``repo``.
554
665
 
@@ -75,6 +75,7 @@ Read first, then act, then check.
75
75
  | link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
76
76
  | record a human/AI review of the warrant | `prodc attest <id> --by <who> --date <ISO> [--ai]` |
77
77
  | record a human acceptance pass | `prodc accept <id> --who <person> --date <ISO>` |
78
+ | decide against a need (won't-do) | `prodc withdraw <id> --reason "…"` — off the board + out of coverage; `prodc reinstate <id>` puts it back |
78
79
  | hand a human a readable status file | `prodc report -o status.md` (markdown; `--format html` for an interactive viewer; `--client ID` to scope) |
79
80
  | 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) |
80
81
  | gate in CI | `prodc lint --strict` |
@@ -97,6 +97,12 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
97
97
  "goal": need.goal,
98
98
  "release": need.release,
99
99
  "claim": need.claim,
100
+ "answer": need.answer.value if need.answer else None,
101
+ "withdrawn": (
102
+ {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
103
+ if need.withdrawn
104
+ else None
105
+ ),
100
106
  "proofs": list(need.proofs),
101
107
  "detail": list(r.detail),
102
108
  "warnings": list(r.warnings),
@@ -138,6 +144,12 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
138
144
  "release": need.release,
139
145
  "product": need.product,
140
146
  "draft": need.draft,
147
+ "answer": need.answer.value if need.answer else None,
148
+ "withdrawn": (
149
+ {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
150
+ if need.withdrawn
151
+ else None
152
+ ),
141
153
  "importance": need.importance,
142
154
  "importance_set_at": need.importance_set_at,
143
155
  "claim": need.claim,
@@ -26,12 +26,14 @@ from .commands import (
26
26
  cmd_plan,
27
27
  cmd_portfolio,
28
28
  cmd_recurrence,
29
+ cmd_reinstate,
29
30
  cmd_report,
30
31
  cmd_set,
31
32
  cmd_show,
32
33
  cmd_status,
33
34
  cmd_suggest,
34
35
  cmd_sync,
36
+ cmd_withdraw,
35
37
  )
36
38
 
37
39
 
@@ -284,6 +286,44 @@ def main(argv: list[str] | None = None) -> int:
284
286
  )
285
287
  ac.set_defaults(func=cmd_accept)
286
288
 
289
+ wd = sub.add_parser(
290
+ "withdraw", help="record a decision against a need — withdrawn / won't-do (#74)"
291
+ )
292
+ wd.add_argument("need_id", help="the need id to withdraw")
293
+ wd.add_argument(
294
+ "--reason", required=True, help="why it was decided against (kept on the record)"
295
+ )
296
+ wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
297
+ wd.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
298
+ wd.add_argument("--json", action="store_true", help="machine-readable output")
299
+ wd.add_argument(
300
+ "--dry-run", action="store_true", help="validate and show the diff, don't write"
301
+ )
302
+ wd.add_argument(
303
+ "--expect-sha",
304
+ default=None,
305
+ metavar="SHA256",
306
+ help="refuse if the file's content hash differs (optimistic lock)",
307
+ )
308
+ wd.set_defaults(func=cmd_withdraw)
309
+
310
+ ri = sub.add_parser(
311
+ "reinstate", help="remove a need's withdrawal, putting it back on the board (#74)"
312
+ )
313
+ ri.add_argument("need_id", help="the need id to reinstate")
314
+ ri.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
315
+ ri.add_argument("--json", action="store_true", help="machine-readable output")
316
+ ri.add_argument(
317
+ "--dry-run", action="store_true", help="validate and show the diff, don't write"
318
+ )
319
+ ri.add_argument(
320
+ "--expect-sha",
321
+ default=None,
322
+ metavar="SHA256",
323
+ help="refuse if the file's content hash differs (optimistic lock)",
324
+ )
325
+ ri.set_defaults(func=cmd_reinstate)
326
+
287
327
  pf = sub.add_parser("portfolio", help="roll up many repos into one scoreboard")
288
328
  pf.add_argument("configs", nargs="+", metavar="PRODC_TOML", help="one or more prodc.toml paths")
289
329
  pf.add_argument("--json", action="store_true", help="machine-readable output")
@@ -51,8 +51,10 @@ from ..writer import (
51
51
  attest,
52
52
  capture_source,
53
53
  link_variant_group,
54
+ reinstate,
54
55
  set_field,
55
56
  set_importance,
57
+ withdraw,
56
58
  )
57
59
  from .common import RepoRow, emit_edit, exit_code, git_rev, load_or_report
58
60
  from .render import (
@@ -204,7 +206,8 @@ def cmd_status(args: argparse.Namespace) -> int:
204
206
  needs = list(model.needs)
205
207
  # Draft needs (#60) are staged: kept off the counted board (and coverage, recurrence,
206
208
  # orphans) until promoted with `set <id> draft=false`, unless --include-drafts is passed.
207
- draft_count = sum(1 for n in needs if n.draft)
209
+ draft_count = sum(1 for n in needs if n.draft and not n.is_withdrawn)
210
+ withdrawn_count = sum(1 for n in needs if n.is_withdrawn)
208
211
  needs = board_needs(needs, include_drafts=args.include_drafts)
209
212
  # Scoped coverage (#49): restrict the board + coverage to a subset, so the number people
210
213
  # act on (this client, this goal) isn't diluted by the whole mixed board.
@@ -259,6 +262,8 @@ def cmd_status(args: argparse.Namespace) -> int:
259
262
  print(summary_line(summary))
260
263
  if draft_count and not args.include_drafts:
261
264
  print(f"drafts: {draft_count} staged (hidden — --include-drafts to show) (#60)")
265
+ if withdrawn_count:
266
+ print(f"withdrawn: {withdrawn_count} (decided against) (#74)")
262
267
  visible = [r for r in results if r.need.product]
263
268
  if args.only_must:
264
269
  visible = [r for r in visible if r.need.must]
@@ -476,6 +481,48 @@ def cmd_accept(args: argparse.Namespace) -> int:
476
481
  return emit_edit(edit, args.dry_run, args.json)
477
482
 
478
483
 
484
+ def cmd_withdraw(args: argparse.Namespace) -> int:
485
+ """Record a decision against a need — withdrawn / won't-do (#74)."""
486
+ cfg_path = Path(args.config)
487
+ if not cfg_path.exists():
488
+ print(f"prodc: no config at {cfg_path}", file=sys.stderr)
489
+ return 2
490
+ try:
491
+ cfg = Config.load(cfg_path)
492
+ edit = withdraw(
493
+ cfg,
494
+ args.need_id,
495
+ reason=args.reason,
496
+ date=args.date or date.today().isoformat(),
497
+ expect_sha=args.expect_sha,
498
+ dry_run=args.dry_run,
499
+ )
500
+ except (ConfigError, WriteError) as exc:
501
+ print(f"prodc: {exc}", file=sys.stderr)
502
+ return 2
503
+ return emit_edit(edit, args.dry_run, args.json)
504
+
505
+
506
+ def cmd_reinstate(args: argparse.Namespace) -> int:
507
+ """Remove a need's withdrawal, putting it back on the active board (#74)."""
508
+ cfg_path = Path(args.config)
509
+ if not cfg_path.exists():
510
+ print(f"prodc: no config at {cfg_path}", file=sys.stderr)
511
+ return 2
512
+ try:
513
+ cfg = Config.load(cfg_path)
514
+ edit = reinstate(
515
+ cfg,
516
+ args.need_id,
517
+ expect_sha=args.expect_sha,
518
+ dry_run=args.dry_run,
519
+ )
520
+ except (ConfigError, WriteError) as exc:
521
+ print(f"prodc: {exc}", file=sys.stderr)
522
+ return 2
523
+ return emit_edit(edit, args.dry_run, args.json)
524
+
525
+
479
526
  def cmd_capture(args: argparse.Namespace) -> int:
480
527
  """Append a Source to an existing need (capture evidence)."""
481
528
  cfg_path = Path(args.config)
@@ -273,6 +273,10 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
273
273
  f" grounds: {_LABEL[r.state]}{nm} warrant: {_warrant(need)}",
274
274
  f" shape: {ears_shape(need.text).value}",
275
275
  ]
276
+ if need.answer:
277
+ lines.append(f" answer: {need.answer.value}")
278
+ if need.withdrawn:
279
+ lines.append(f" withdrawn: {need.withdrawn.reason} ({need.withdrawn.date})")
276
280
  if need.description:
277
281
  lines.append(f" description: {need.description}")
278
282
  if need.importance is not None:
@@ -10,9 +10,11 @@ from typing import cast
10
10
  from .adapters import (
11
11
  AcceptAdapter,
12
12
  Adapter,
13
+ CitationAdapter,
13
14
  CommandProof,
14
15
  CommandSource,
15
16
  CoverageAdapter,
17
+ EvalGateAdapter,
16
18
  External,
17
19
  FieldAdapter,
18
20
  FilesAdapter,
@@ -190,6 +192,8 @@ class Config:
190
192
  inner = AcceptAdapter()
191
193
  elif kind == "prototype":
192
194
  inner = PrototypeAdapter()
195
+ elif kind == "citation":
196
+ inner = CitationAdapter()
193
197
  elif kind == "coverage":
194
198
  cat = tbl.get("catalog", "")
195
199
  inner = CoverageAdapter(
@@ -202,13 +206,17 @@ class Config:
202
206
  if not results_s:
203
207
  raise ConfigError(f"[proofs.{name}]: external needs results = <json file>")
204
208
  inner = OutcomeAdapter(root=proof_root, results=results_s)
209
+ elif kind == "evalgate":
210
+ if not results_s:
211
+ raise ConfigError(f"[proofs.{name}]: evalgate needs results = <json file>")
212
+ inner = EvalGateAdapter(root=proof_root, results=results_s)
205
213
  elif kind == "command":
206
214
  command, timeout = _hook_command(tbl, f"[proofs.{name}]")
207
215
  inner = CommandProof(root=proof_root, command=command, timeout=timeout)
208
216
  else:
209
217
  raise ConfigError(
210
218
  f"[proofs.{name}]: unknown kind {kind!r} "
211
- "(gherkin|pytest|files|accept|prototype|coverage|field|external|command)"
219
+ "(gherkin|pytest|files|accept|prototype|citation|coverage|field|external|evalgate|command)"
212
220
  )
213
221
  adapters[name] = (
214
222
  External(inner, root / repo_field) if isinstance(repo_field, str) else inner
@@ -17,6 +17,7 @@ import yaml
17
17
 
18
18
  from .config import Config
19
19
  from .model import (
20
+ Answer,
20
21
  ArgKind,
21
22
  ArgNode,
22
23
  Attestation,
@@ -28,6 +29,7 @@ from .model import (
28
29
  Role,
29
30
  Source,
30
31
  SourceKind,
32
+ Withdrawal,
31
33
  )
32
34
 
33
35
 
@@ -104,6 +106,45 @@ def _attestation(raw: object, where: str) -> Attestation | None:
104
106
  )
105
107
 
106
108
 
109
+ def _answer(raw: object, where: str) -> Answer | None:
110
+ """Parse ``answer: yes|no|inconclusive`` into an :class:`Answer`, validated like other enums.
111
+
112
+ The direction a settled research question resolved; absent on a product need. Same
113
+ ``Enum(name) except ValueError`` pattern as ``SourceKind``/``ArgKind`` so an unknown value is
114
+ a :class:`LoaderError` the author owns, not silent junk.
115
+
116
+ YAML 1.1 reads a bare ``yes``/``no`` as a boolean, so ``answer: no`` arrives as ``False`` —
117
+ coerced back to the string here, the same way ``_opt_str`` accepts a bare ``date:`` that
118
+ parsed as a date. ``inconclusive`` is a plain string and needs no coercion.
119
+ """
120
+ if raw is None:
121
+ return None
122
+ if isinstance(raw, bool):
123
+ raw = "yes" if raw else "no"
124
+ name = _as_str(raw, where)
125
+ try:
126
+ return Answer(name)
127
+ except ValueError:
128
+ answers = "|".join(a.value for a in Answer)
129
+ raise LoaderError(f"{where}: unknown answer {name!r} ({answers})") from None
130
+
131
+
132
+ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
133
+ """Parse ``withdrawn: {reason, date}`` into a :class:`Withdrawal` (#74).
134
+
135
+ Both fields are required when the key is present — a decision against a need must say why
136
+ and when, the same way an attestation needs who and when.
137
+ """
138
+ if raw is None:
139
+ return None
140
+ tbl = _as_mapping(raw, where)
141
+ reason = _opt_str(tbl.get("reason"), f"{where}.reason")
142
+ date = _opt_str(tbl.get("date"), f"{where}.date")
143
+ if not reason or not date:
144
+ raise LoaderError(f"{where}: a withdrawal needs both 'reason' and 'date'")
145
+ return Withdrawal(reason=reason, date=date)
146
+
147
+
107
148
  # Old source-kind names (pre-0.3.58) mapped to the primary/secondary/assumption ladder, so
108
149
  # existing need files keep loading. `interview` was live user contact (primary); `artifact` was a
109
150
  # cited document (secondary); `proxy`/`internal` were both unevidenced guesses (assumption).
@@ -160,6 +201,8 @@ def _need(
160
201
  release = _opt_str(tbl.get("release"), f"{where}.release")
161
202
  product = _as_bool(tbl["product"], f"{where}.product") if "product" in tbl else True
162
203
  draft = _as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False
204
+ withdrawn = _withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn")
205
+ answer = _answer(tbl.get("answer"), f"{where}.answer")
163
206
 
164
207
  sources = tuple(
165
208
  _source(s, f"{where}.sources[{i}]")
@@ -217,6 +260,8 @@ def _need(
217
260
  not_variant_of=not_variant_of,
218
261
  importance_set_at=importance_set_at,
219
262
  draft=draft,
263
+ withdrawn=withdrawn,
264
+ answer=answer,
220
265
  )
221
266
 
222
267
 
@@ -48,6 +48,26 @@ class NeedState(StrEnum):
48
48
  UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
49
49
 
50
50
 
51
+ class Answer(StrEnum):
52
+ """Which way a settled question resolved — the research board's direction axis.
53
+
54
+ A product need has one axis: is the outcome achieved? A research question has two: is it
55
+ *settled* (DELIVERED, the proof resolved green) and, if so, *which way*. DELIVERED means
56
+ settled; ``answer`` records the direction. A disproven hypothesis is therefore a settled
57
+ result that still counts toward coverage — DELIVERED with ``answer: no`` — not a gap.
58
+
59
+ Direction is always authored, never computed: an adapter can only map a proof to
60
+ delivered/pending/broken/unresolved, so no adapter can emit a settled-NO. The act of
61
+ concluding "this is the answer, and it is no" is the author's decision, so ``answer`` lives
62
+ on the :class:`Need` (authored lifecycle), not on a proof or need result (computed). A product
63
+ board never sets it and behaves exactly as before.
64
+ """
65
+
66
+ YES = "yes" # the question settled affirmatively
67
+ NO = "no" # the question settled negatively — a result, not a gap (e.g. a disproven hypothesis)
68
+ INCONCLUSIVE = "inconclusive" # settled that the evidence cannot decide it either way
69
+
70
+
51
71
  @dataclass(frozen=True)
52
72
  class Source:
53
73
  """Where a need came from. Absence of any Source makes the need an opinion."""
@@ -140,6 +160,19 @@ class Attestation:
140
160
  comment: str | None = None # the review's feedback / rationale, kept on the record
141
161
 
142
162
 
163
+ @dataclass(frozen=True)
164
+ class Withdrawal:
165
+ """A decision not to pursue a need — withdrawn / won't-do (#74).
166
+
167
+ Unlike a draft (staged, not yet decided), a withdrawal is a decision *against*: the need is
168
+ taken off the active board and out of coverage, with its ``reason`` and ``date`` kept on the
169
+ record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
170
+ """
171
+
172
+ reason: str
173
+ date: str
174
+
175
+
143
176
  @dataclass(frozen=True)
144
177
  class Need:
145
178
  """What a named person wants, with its provenance and its proofs."""
@@ -165,6 +198,10 @@ class Need:
165
198
  release: str | None = None # id of the Release slice this need ships in; None = unslotted
166
199
  product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
167
200
  draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
201
+ withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
202
+ answer: Answer | None = (
203
+ None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
204
+ )
168
205
  not_duplicate_of: tuple[str, ...] = () # ids this need is confirmed NOT a duplicate of (#55)
169
206
  not_variant_of: tuple[str, ...] = () # ids this need is confirmed NOT a shared variant of (#56)
170
207
 
@@ -194,28 +231,37 @@ class Need:
194
231
  kinds = {s.kind for s in self.sources}
195
232
  return next((k for k in order if k in kinds), None)
196
233
 
234
+ @property
235
+ def is_withdrawn(self) -> bool:
236
+ """A decision has been recorded against this need — withdrawn / won't-do (#74)."""
237
+ return self.withdrawn is not None
238
+
197
239
  @property
198
240
  def counts_toward_coverage(self) -> bool:
199
241
  """Whether this need is a unit of product coverage — what :func:`~prodc.status.summarize`
200
242
  counts.
201
243
 
202
- A leaf product need. A flow (a need with ``children``) is a roll-up of its leaves, so
203
- counting it too would double-count (#43); a non-product need (infra/NFR/internal) is
204
- excluded from product coverage (#48). Draft staging is a separate, upstream filter
205
- (:func:`board_needs`), not part of this rule — so a draft still counts once promoted onto
206
- the board (e.g. under ``--include-drafts``).
244
+ A leaf product need that has not been withdrawn. A flow (a need with ``children``) is a
245
+ roll-up of its leaves, so counting it too would double-count (#43); a non-product need
246
+ (infra/NFR/internal) is excluded from product coverage (#48); a withdrawn need is a
247
+ decision against, so it is out of coverage entirely (#74). Draft staging is a separate,
248
+ upstream filter (:func:`board_needs`), not part of this rule — so a draft still counts
249
+ once promoted onto the board (e.g. under ``--include-drafts``).
207
250
  """
208
- return not self.children and self.product
251
+ return not self.children and self.product and not self.is_withdrawn
209
252
 
210
253
  @property
211
254
  def excluded_reason(self) -> str | None:
212
255
  """Why this need is off the counted board, or ``None`` when it is on it.
213
256
 
214
- The single home for the lifecycle exclusions, so a new state lands here once: ``"draft"``
215
- (staged, not yet promoted, #60) and ``"nonproduct"`` (infra/NFR/internal, #48). A flow is
216
- not excluded — it is a roll-up row on the board — so ``children`` is not a reason here
217
- (that axis is :attr:`counts_toward_coverage`).
257
+ The single home for the lifecycle exclusions, so a new state lands here once: ``"withdrawn"``
258
+ (a decision against, #74), ``"draft"`` (staged, not yet promoted, #60), and ``"nonproduct"``
259
+ (infra/NFR/internal, #48). Withdrawal is checked first — a decision against overrides
260
+ staging or scope. A flow is not excluded — it is a roll-up row on the board — so
261
+ ``children`` is not a reason here (that axis is :attr:`counts_toward_coverage`).
218
262
  """
263
+ if self.is_withdrawn:
264
+ return "withdrawn"
219
265
  if self.draft:
220
266
  return "draft"
221
267
  if not self.product:
@@ -224,12 +270,16 @@ class Need:
224
270
 
225
271
 
226
272
  def board_needs(needs: list[Need], *, include_drafts: bool) -> list[Need]:
227
- """The needs on the counted board: a draft (#60) is staged off it until promoted.
273
+ """The needs on the active board: a withdrawn need (#74) is always off it, and a draft (#60)
274
+ is staged off until promoted.
228
275
 
229
- ``include_drafts`` keeps them on (the ``--include-drafts`` flag). The one place the
230
- draft-staging rule lives, shared by ``status`` and ``plan`` so the two never diverge.
276
+ A withdrawn need is a decision against, so it is dropped unconditionally — there is no
277
+ ``--include`` flag for it (its durable trace is the YAML record and ``prodc show``). Drafts
278
+ are kept when ``include_drafts`` (the ``--include-drafts`` flag). The one place the
279
+ board-membership rule lives, shared by ``status`` and ``plan`` so the two never diverge.
231
280
  """
232
- return list(needs) if include_drafts else [n for n in needs if not n.draft]
281
+ kept = [n for n in needs if not n.is_withdrawn]
282
+ return kept if include_drafts else [n for n in kept if not n.draft]
233
283
 
234
284
 
235
285
  class ArgKind(StrEnum):
@@ -75,10 +75,11 @@ def render_markdown(
75
75
  "proves yet — not a failure.",
76
76
  ]
77
77
 
78
- # Needs grouped by who, like the board.
78
+ # Needs grouped by who, like the board. A withdrawn need (#74) is off the active board, so
79
+ # it is dropped here (its trace is the YAML record and `prodc show`), never shown as a gap.
79
80
  groups: dict[str, list[NeedResult]] = {}
80
81
  for r in results:
81
- if not r.need.product:
82
+ if not r.need.product or r.need.is_withdrawn:
82
83
  continue
83
84
  groups.setdefault(r.need.who or "(unassigned)", []).append(r)
84
85
  if groups:
@@ -153,7 +154,11 @@ def render_markdown(
153
154
 
154
155
  # Open questions: the brief — opinions (no evidence) or HOLEs (no proof), must-haves first.
155
156
  open_ = [
156
- r for r in results if r.need.product and (r.need.is_opinion or r.state is NeedState.HOLE)
157
+ r
158
+ for r in results
159
+ if r.need.product
160
+ and not r.need.is_withdrawn
161
+ and (r.need.is_opinion or r.state is NeedState.HOLE)
157
162
  ]
158
163
  open_.sort(key=lambda r: (not r.need.must, r.need.id))
159
164
  out += ["## Open questions", ""]