prodc 0.3.66__tar.gz → 0.3.73__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.66 → prodc-0.3.73}/PKG-INFO +1 -1
  2. prodc-0.3.73/examples/quickstart/needs/jordan.yaml +40 -0
  3. {prodc-0.3.66 → prodc-0.3.73}/examples/quickstart/prodc.toml +5 -1
  4. {prodc-0.3.66 → prodc-0.3.73}/pyproject.toml +1 -1
  5. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/adapters.py +111 -0
  6. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/agent.md +3 -2
  7. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/board.py +2 -0
  8. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/app.py +13 -2
  9. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/commands.py +34 -5
  10. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/common.py +77 -0
  11. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/render.py +14 -2
  12. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/config.py +9 -1
  13. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/loader.py +26 -0
  14. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/model.py +23 -0
  15. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/status.py +101 -6
  16. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/writer.py +19 -0
  17. prodc-0.3.73/tests/test_answer.py +154 -0
  18. prodc-0.3.73/tests/test_citation.py +39 -0
  19. prodc-0.3.73/tests/test_claim_gating.py +66 -0
  20. {prodc-0.3.66 → prodc-0.3.73}/tests/test_cli.py +47 -3
  21. prodc-0.3.73/tests/test_evalgate.py +100 -0
  22. prodc-0.3.73/tests/test_needref.py +175 -0
  23. prodc-0.3.66/examples/quickstart/needs/jordan.yaml +0 -24
  24. {prodc-0.3.66 → prodc-0.3.73}/.gitignore +0 -0
  25. {prodc-0.3.66 → prodc-0.3.73}/CHANGELOG.md +0 -0
  26. {prodc-0.3.66 → prodc-0.3.73}/LICENSE +0 -0
  27. {prodc-0.3.66 → prodc-0.3.73}/README.md +0 -0
  28. {prodc-0.3.66 → prodc-0.3.73}/examples/quickstart/features/orders.feature +0 -0
  29. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/__init__.py +0 -0
  30. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/callbacks.py +0 -0
  31. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/__init__.py +0 -0
  32. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/serialize.py +0 -0
  33. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/dedup.py +0 -0
  34. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/py.typed +0 -0
  35. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/report.py +0 -0
  36. {prodc-0.3.66 → prodc-0.3.73}/src/prodc/report_html.py +0 -0
  37. {prodc-0.3.66 → prodc-0.3.73}/tests/test_adapters.py +0 -0
  38. {prodc-0.3.66 → prodc-0.3.73}/tests/test_callbacks.py +0 -0
  39. {prodc-0.3.66 → prodc-0.3.73}/tests/test_command_hook.py +0 -0
  40. {prodc-0.3.66 → prodc-0.3.73}/tests/test_dedup.py +0 -0
  41. {prodc-0.3.66 → prodc-0.3.73}/tests/test_description.py +0 -0
  42. {prodc-0.3.66 → prodc-0.3.73}/tests/test_drafts.py +0 -0
  43. {prodc-0.3.66 → prodc-0.3.73}/tests/test_external_adapter.py +0 -0
  44. {prodc-0.3.66 → prodc-0.3.73}/tests/test_gitlab_source.py +0 -0
  45. {prodc-0.3.66 → prodc-0.3.73}/tests/test_importance.py +0 -0
  46. {prodc-0.3.66 → prodc-0.3.73}/tests/test_loader.py +0 -0
  47. {prodc-0.3.66 → prodc-0.3.73}/tests/test_models.py +0 -0
  48. {prodc-0.3.66 → prodc-0.3.73}/tests/test_report.py +0 -0
  49. {prodc-0.3.66 → prodc-0.3.73}/tests/test_report_html.py +0 -0
  50. {prodc-0.3.66 → prodc-0.3.73}/tests/test_roles.py +0 -0
  51. {prodc-0.3.66 → prodc-0.3.73}/tests/test_sources.py +0 -0
  52. {prodc-0.3.66 → prodc-0.3.73}/tests/test_staleness.py +0 -0
  53. {prodc-0.3.66 → prodc-0.3.73}/tests/test_status.py +0 -0
  54. {prodc-0.3.66 → prodc-0.3.73}/tests/test_suggest.py +0 -0
  55. {prodc-0.3.66 → prodc-0.3.73}/tests/test_variants.py +0 -0
  56. {prodc-0.3.66 → prodc-0.3.73}/tests/test_who_variants.py +0 -0
  57. {prodc-0.3.66 → prodc-0.3.73}/tests/test_withdrawn.py +0 -0
  58. {prodc-0.3.66 → prodc-0.3.73}/tests/test_writer.py +0 -0
  59. {prodc-0.3.66 → prodc-0.3.73}/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.66
3
+ Version: 0.3.73
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.66"
3
+ version = "0.3.73"
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
 
@@ -80,8 +80,9 @@ Read first, then act, then check.
80
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) |
81
81
  | gate in CI | `prodc lint --strict` |
82
82
 
83
- Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <toml>…`
84
- (roll up many repos into one scoreboard), and `prodc diff <snapshot.json>` (what changed since a
83
+ Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <path>…`
84
+ (roll up many boards into one scoreboard + totals; a path is a prodc.toml or a dir to discover
85
+ boards under), and `prodc diff <snapshot.json>` (what changed since a
85
86
  saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
86
87
  `--wide`, `--include-drafts`, and `--stale <days>`.
87
88
 
@@ -97,6 +97,7 @@ 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,
100
101
  "withdrawn": (
101
102
  {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
102
103
  if need.withdrawn
@@ -143,6 +144,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
143
144
  "release": need.release,
144
145
  "product": need.product,
145
146
  "draft": need.draft,
147
+ "answer": need.answer.value if need.answer else None,
146
148
  "withdrawn": (
147
149
  {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
148
150
  if need.withdrawn
@@ -324,8 +324,19 @@ def main(argv: list[str] | None = None) -> int:
324
324
  )
325
325
  ri.set_defaults(func=cmd_reinstate)
326
326
 
327
- pf = sub.add_parser("portfolio", help="roll up many repos into one scoreboard")
328
- pf.add_argument("configs", nargs="+", metavar="PRODC_TOML", help="one or more prodc.toml paths")
327
+ pf = sub.add_parser("portfolio", help="roll up many boards into one scoreboard")
328
+ pf.add_argument(
329
+ "configs",
330
+ nargs="+",
331
+ metavar="PATH",
332
+ help="prodc.toml paths and/or directories to discover boards under",
333
+ )
334
+ pf.add_argument(
335
+ "--max-depth",
336
+ type=int,
337
+ default=4,
338
+ help="how deep to descend when discovering boards under a directory (default 4)",
339
+ )
329
340
  pf.add_argument("--json", action="store_true", help="machine-readable output")
330
341
  pf.set_defaults(func=cmd_portfolio)
331
342
 
@@ -56,7 +56,15 @@ from ..writer import (
56
56
  set_importance,
57
57
  withdraw,
58
58
  )
59
- from .common import RepoRow, emit_edit, exit_code, git_rev, load_or_report
59
+ from .common import (
60
+ RepoRow,
61
+ discover_configs,
62
+ emit_edit,
63
+ exit_code,
64
+ git_rev,
65
+ load_or_report,
66
+ portfolio_totals,
67
+ )
60
68
  from .render import (
61
69
  COLUMNS,
62
70
  provenance_counts,
@@ -582,12 +590,33 @@ def _repo_row(config_path: str) -> RepoRow:
582
590
 
583
591
 
584
592
  def cmd_portfolio(args: argparse.Namespace) -> int:
585
- """Roll up many repos into one scoreboard — coverage, must-HOLEs, provenance per repo."""
586
- rows = [_repo_row(path) for path in args.configs]
593
+ """Roll up many boards into one scoreboard — coverage, must-HOLEs, provenance per board.
594
+
595
+ Each argument is a ``prodc.toml`` path or a directory to discover boards under (every
596
+ ``prodc.toml`` beneath it, pruning VCS/vendor/build and hidden dirs, capped at ``--max-depth``).
597
+ Boards stay separate rows; a totals footer aggregates the clean ones. Never a merged board.
598
+ """
599
+ paths: list[str] = []
600
+ for arg in args.configs:
601
+ p = Path(arg)
602
+ if p.is_dir():
603
+ paths.extend(str(c) for c in discover_configs(p, max_depth=args.max_depth))
604
+ else:
605
+ paths.append(arg)
606
+ seen: set[str] = set()
607
+ unique: list[str] = []
608
+ for path in paths:
609
+ key = str(Path(path).resolve())
610
+ if key not in seen:
611
+ seen.add(key)
612
+ unique.append(path)
613
+ rows = [_repo_row(path) for path in unique]
614
+ totals = portfolio_totals(rows)
587
615
  if args.json:
588
- print(json.dumps([asdict(r) for r in rows], indent=2))
616
+ payload = {"boards": [asdict(r) for r in rows], "totals": asdict(totals)}
617
+ print(json.dumps(payload, indent=2))
589
618
  else:
590
- print(render_portfolio(rows))
619
+ print(render_portfolio(rows, totals))
591
620
  return 2 if any(r.error is not None for r in rows) else 0
592
621
 
593
622
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import json
13
+ import os
13
14
  import sys
14
15
  from dataclasses import dataclass
15
16
  from pathlib import Path
@@ -100,3 +101,79 @@ class RepoRow:
100
101
  coverage: float
101
102
  provenance: dict[str, int]
102
103
  error: str | None = None
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class PortfolioTotals:
108
+ """The aggregate across every board in a portfolio — a footer, not a merged board.
109
+
110
+ Error rows are excluded (a board that would not load contributes no counts). ``coverage`` is
111
+ the pooled ``delivered / total``, which is not the mean of the per-board percentages.
112
+ """
113
+
114
+ boards: int # how many boards loaded cleanly and are summed here
115
+ delivered: int
116
+ total: int
117
+ must_hole: int
118
+ broken: int
119
+ coverage: float
120
+
121
+
122
+ def portfolio_totals(rows: list[RepoRow]) -> PortfolioTotals:
123
+ """Sum the clean rows into a :class:`PortfolioTotals` footer."""
124
+ ok = [r for r in rows if r.error is None]
125
+ delivered = sum(r.delivered for r in ok)
126
+ total = sum(r.total for r in ok)
127
+ return PortfolioTotals(
128
+ boards=len(ok),
129
+ delivered=delivered,
130
+ total=total,
131
+ must_hole=sum(r.must_hole for r in ok),
132
+ broken=sum(r.broken for r in ok),
133
+ coverage=round(delivered / total, 4) if total else 0.0,
134
+ )
135
+
136
+
137
+ # Directory names never descended into during board discovery: VCS, virtualenvs, dependency and
138
+ # build trees. Any name starting with "." is pruned too (handled in the walk), so a vendored or
139
+ # example prodc.toml buried in one of these is never mistaken for a board.
140
+ _PRUNE_DIRS = frozenset(
141
+ {
142
+ ".git",
143
+ "node_modules",
144
+ ".venv",
145
+ "venv",
146
+ "__pycache__",
147
+ "dist",
148
+ "build",
149
+ "target",
150
+ ".tox",
151
+ ".mypy_cache",
152
+ ".ruff_cache",
153
+ "site-packages",
154
+ "vendor",
155
+ }
156
+ )
157
+
158
+
159
+ def discover_configs(root: Path, *, max_depth: int = 4) -> list[Path]:
160
+ """Every ``prodc.toml`` under ``root``, for the portfolio roll-up — bounded so it can't run away.
161
+
162
+ A plain recursive scan is a footgun: it descends into ``.git``/``node_modules``/build trees and
163
+ picks up vendored or example configs. So this prunes those (and every ``.``-prefixed dir) and
164
+ caps the descent at ``max_depth`` directories below ``root``. Returns sorted, de-duplicated
165
+ paths; a board is the directory that holds the ``prodc.toml``.
166
+ """
167
+ root = root.resolve()
168
+ found: set[Path] = set()
169
+ for dirpath, dirnames, filenames in os.walk(root):
170
+ if "prodc.toml" in filenames:
171
+ found.add(Path(dirpath) / "prodc.toml")
172
+ depth = len(Path(dirpath).relative_to(root).parts)
173
+ # Prune in place so os.walk never descends into the skipped dirs, then stop at the depth cap.
174
+ dirnames[:] = (
175
+ []
176
+ if depth >= max_depth
177
+ else [d for d in dirnames if d not in _PRUNE_DIRS and not d.startswith(".")]
178
+ )
179
+ return sorted(found)
@@ -22,7 +22,7 @@ from ..model import (
22
22
  ears_shape,
23
23
  )
24
24
  from ..status import Handoff, Recurrence, Summary, rank, state_priority
25
- from .common import RepoRow, brief_open
25
+ from .common import PortfolioTotals, RepoRow, brief_open
26
26
 
27
27
  _LABEL = {
28
28
  NeedState.DELIVERED: "DELIVERED",
@@ -273,6 +273,8 @@ 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}")
276
278
  if need.withdrawn:
277
279
  lines.append(f" withdrawn: {need.withdrawn.reason} ({need.withdrawn.date})")
278
280
  if need.description:
@@ -378,7 +380,9 @@ def render_argument(model: Model, defects: list[str]) -> str:
378
380
  return "\n".join(out)
379
381
 
380
382
 
381
- def render_portfolio(rows: list[RepoRow]) -> str:
383
+ def render_portfolio(rows: list[RepoRow], totals: PortfolioTotals | None = None) -> str:
384
+ if not rows:
385
+ return " no boards found"
382
386
  out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov prim/sec/asm/op"]
383
387
  for row in rows:
384
388
  if row.error is not None:
@@ -388,4 +392,12 @@ def render_portfolio(rows: list[RepoRow]) -> str:
388
392
  prov = f"{p['primary']}/{p['secondary']}/{p['assumption']}/{p['opinion']}"
389
393
  cov = f"{round(row.coverage * 100)}%"
390
394
  out.append(f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}")
395
+ if totals is not None:
396
+ cov = f"{round(totals.coverage * 100)}%"
397
+ label = f"TOTAL ({totals.boards} boards)"
398
+ out.append(f" {'─' * 48}")
399
+ out.append(
400
+ f" {label:<18} {cov:>4} {totals.must_hole:>9} {totals.broken:>6} "
401
+ f"{totals.delivered}/{totals.total} delivered"
402
+ )
391
403
  return "\n".join(out)
@@ -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,
@@ -105,6 +106,29 @@ def _attestation(raw: object, where: str) -> Attestation | None:
105
106
  )
106
107
 
107
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
+
108
132
  def _withdrawal(raw: object, where: str) -> Withdrawal | None:
109
133
  """Parse ``withdrawn: {reason, date}`` into a :class:`Withdrawal` (#74).
110
134
 
@@ -178,6 +202,7 @@ def _need(
178
202
  product = _as_bool(tbl["product"], f"{where}.product") if "product" in tbl else True
179
203
  draft = _as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False
180
204
  withdrawn = _withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn")
205
+ answer = _answer(tbl.get("answer"), f"{where}.answer")
181
206
 
182
207
  sources = tuple(
183
208
  _source(s, f"{where}.sources[{i}]")
@@ -236,6 +261,7 @@ def _need(
236
261
  importance_set_at=importance_set_at,
237
262
  draft=draft,
238
263
  withdrawn=withdrawn,
264
+ answer=answer,
239
265
  )
240
266
 
241
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."""
@@ -179,6 +199,9 @@ class Need:
179
199
  product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
180
200
  draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
181
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
+ )
182
205
  not_duplicate_of: tuple[str, ...] = () # ids this need is confirmed NOT a duplicate of (#55)
183
206
  not_variant_of: tuple[str, ...] = () # ids this need is confirmed NOT a shared variant of (#56)
184
207