prodc 0.4.2__tar.gz → 0.4.3__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 (65) hide show
  1. {prodc-0.4.2 → prodc-0.4.3}/PKG-INFO +1 -1
  2. {prodc-0.4.2 → prodc-0.4.3}/pyproject.toml +1 -1
  3. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/render.py +13 -2
  4. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/serialize.py +4 -1
  5. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/loader.py +2 -0
  6. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/model.py +9 -0
  7. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/report.py +5 -4
  8. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/report_html.py +11 -2
  9. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/status.py +20 -0
  10. {prodc-0.4.2 → prodc-0.4.3}/tests/test_loader.py +20 -0
  11. {prodc-0.4.2 → prodc-0.4.3}/tests/test_status.py +17 -1
  12. {prodc-0.4.2 → prodc-0.4.3}/.gitignore +0 -0
  13. {prodc-0.4.2 → prodc-0.4.3}/CHANGELOG.md +0 -0
  14. {prodc-0.4.2 → prodc-0.4.3}/LICENSE +0 -0
  15. {prodc-0.4.2 → prodc-0.4.3}/README.md +0 -0
  16. {prodc-0.4.2 → prodc-0.4.3}/examples/quickstart/features/orders.feature +0 -0
  17. {prodc-0.4.2 → prodc-0.4.3}/examples/quickstart/needs/jordan.yaml +0 -0
  18. {prodc-0.4.2 → prodc-0.4.3}/examples/quickstart/prodc.toml +0 -0
  19. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/__init__.py +0 -0
  20. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/adapters.py +0 -0
  21. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/agent.md +0 -0
  22. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/board.py +0 -0
  23. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/callbacks.py +0 -0
  24. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/__init__.py +0 -0
  25. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/app.py +0 -0
  26. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/commands.py +0 -0
  27. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/common.py +0 -0
  28. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/config.py +0 -0
  29. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/dedup.py +0 -0
  30. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/guide.md +0 -0
  31. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/py.typed +0 -0
  32. {prodc-0.4.2 → prodc-0.4.3}/src/prodc/writer.py +0 -0
  33. {prodc-0.4.2 → prodc-0.4.3}/tests/test_adapters.py +0 -0
  34. {prodc-0.4.2 → prodc-0.4.3}/tests/test_answer.py +0 -0
  35. {prodc-0.4.2 → prodc-0.4.3}/tests/test_callbacks.py +0 -0
  36. {prodc-0.4.2 → prodc-0.4.3}/tests/test_citation.py +0 -0
  37. {prodc-0.4.2 → prodc-0.4.3}/tests/test_claim_gating.py +0 -0
  38. {prodc-0.4.2 → prodc-0.4.3}/tests/test_cli.py +0 -0
  39. {prodc-0.4.2 → prodc-0.4.3}/tests/test_command_hook.py +0 -0
  40. {prodc-0.4.2 → prodc-0.4.3}/tests/test_contradicts.py +0 -0
  41. {prodc-0.4.2 → prodc-0.4.3}/tests/test_decisions.py +0 -0
  42. {prodc-0.4.2 → prodc-0.4.3}/tests/test_dedup.py +0 -0
  43. {prodc-0.4.2 → prodc-0.4.3}/tests/test_description.py +0 -0
  44. {prodc-0.4.2 → prodc-0.4.3}/tests/test_drafts.py +0 -0
  45. {prodc-0.4.2 → prodc-0.4.3}/tests/test_evalgate.py +0 -0
  46. {prodc-0.4.2 → prodc-0.4.3}/tests/test_external_adapter.py +0 -0
  47. {prodc-0.4.2 → prodc-0.4.3}/tests/test_gitlab_source.py +0 -0
  48. {prodc-0.4.2 → prodc-0.4.3}/tests/test_importance.py +0 -0
  49. {prodc-0.4.2 → prodc-0.4.3}/tests/test_models.py +0 -0
  50. {prodc-0.4.2 → prodc-0.4.3}/tests/test_needref.py +0 -0
  51. {prodc-0.4.2 → prodc-0.4.3}/tests/test_principles.py +0 -0
  52. {prodc-0.4.2 → prodc-0.4.3}/tests/test_relink.py +0 -0
  53. {prodc-0.4.2 → prodc-0.4.3}/tests/test_report.py +0 -0
  54. {prodc-0.4.2 → prodc-0.4.3}/tests/test_report_html.py +0 -0
  55. {prodc-0.4.2 → prodc-0.4.3}/tests/test_review.py +0 -0
  56. {prodc-0.4.2 → prodc-0.4.3}/tests/test_roles.py +0 -0
  57. {prodc-0.4.2 → prodc-0.4.3}/tests/test_source_role.py +0 -0
  58. {prodc-0.4.2 → prodc-0.4.3}/tests/test_sources.py +0 -0
  59. {prodc-0.4.2 → prodc-0.4.3}/tests/test_staleness.py +0 -0
  60. {prodc-0.4.2 → prodc-0.4.3}/tests/test_suggest.py +0 -0
  61. {prodc-0.4.2 → prodc-0.4.3}/tests/test_variants.py +0 -0
  62. {prodc-0.4.2 → prodc-0.4.3}/tests/test_who_variants.py +0 -0
  63. {prodc-0.4.2 → prodc-0.4.3}/tests/test_withdrawn.py +0 -0
  64. {prodc-0.4.2 → prodc-0.4.3}/tests/test_writer.py +0 -0
  65. {prodc-0.4.2 → prodc-0.4.3}/tests/test_writer_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.4.2
3
+ Version: 0.4.3
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.4.2"
3
+ version = "0.4.3"
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"
@@ -7,6 +7,7 @@ formatting: it reads the resolved model and returns strings, never loads or writ
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import datetime
10
11
  import shutil
11
12
  from collections.abc import Callable
12
13
 
@@ -27,6 +28,7 @@ from ..status import (
27
28
  PrincipleReport,
28
29
  Recurrence,
29
30
  Summary,
31
+ group_state,
30
32
  orphaned_decisions,
31
33
  rank,
32
34
  state_priority,
@@ -209,12 +211,21 @@ def render_out_of_scope(results: list[NeedResult]) -> str:
209
211
 
210
212
 
211
213
  def render_goals(goal_cov: list[tuple[Goal, Summary]], ungoaled: list[NeedResult]) -> str:
212
- """Per-goal coverage, plus the needs that serve no objective (#27)."""
214
+ """Per-goal coverage with a derived state and target, plus the needs that serve no objective (#27, #80)."""
215
+ today = datetime.date.today().isoformat()
213
216
  out = ["\ngoals (objectives needs trace up to):"]
214
217
  for goal, gsum in goal_cov:
218
+ state = group_state(gsum)
215
219
  metric = f" [{goal.metric}]" if goal.metric else ""
216
220
  pct = round(gsum.coverage * 100)
217
- out.append(f" {goal.id:<12} {gsum.delivered}/{gsum.total} ({pct}%){metric} {goal.text}")
221
+ overdue = goal.target is not None and goal.target < today and state is not NeedState.DELIVERED
222
+ target = ""
223
+ if goal.target:
224
+ target = f" ⚠ overdue {goal.target}" if overdue else f" target {goal.target}"
225
+ out.append(
226
+ f" {goal.id:<12} {_LABEL[state]:<10} {gsum.delivered}/{gsum.total} ({pct}%)"
227
+ f"{metric}{target} {goal.text}"
228
+ )
218
229
  if ungoaled:
219
230
  ids = ", ".join(r.need.id for r in ungoaled)
220
231
  out.append(f" ⚠ {len(ungoaled)} need(s) serve no goal: {ids}")
@@ -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 Decision, Goal, NeedResult, NeedState, Release, Role
16
- from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
16
+ from ..status import Handoff, PrincipleReport, Recurrence, Summary, group_state, rank
17
17
  from .common import brief_open
18
18
 
19
19
 
@@ -119,6 +119,9 @@ def to_json(
119
119
  "id": goal.id,
120
120
  "text": goal.text,
121
121
  "metric": goal.metric,
122
+ "description": goal.description,
123
+ "target": goal.target,
124
+ "state": group_state(gsum).value,
122
125
  "coverage": round(gsum.coverage, 4),
123
126
  "delivered": gsum.delivered,
124
127
  "total": gsum.total,
@@ -322,6 +322,8 @@ def _goal(raw: object, where: str) -> Goal:
322
322
  id=_as_str(tbl.get("id"), f"{where}.id"),
323
323
  text=_as_str(tbl.get("text"), f"{where}.text"),
324
324
  metric=_opt_str(tbl.get("metric"), f"{where}.metric"),
325
+ description=_opt_str(tbl.get("description"), f"{where}.description"),
326
+ target=_opt_str(tbl.get("target"), f"{where}.target"),
325
327
  )
326
328
 
327
329
 
@@ -102,11 +102,20 @@ class Goal:
102
102
 
103
103
  A need with no ``goal`` is flagged the way an unsourced need is flagged an opinion — it
104
104
  lets the board answer "are we moving the objective", not only "is this need delivered".
105
+
106
+ A goal is also the home for the long-lived container other trackers call an *initiative* or
107
+ *epic* (#80): a ``description`` and an optional ``target`` window give it a title, a blurb and a
108
+ deadline, and its state is *derived* from the needs that cite it (:func:`~prodc.status.group_state`),
109
+ never hand-set — so an "is this big piece of work done" rolls up from the proofs, not a status
110
+ field that drifts. No separate ``initiative`` noun: an output bundle that is neither an outcome
111
+ (goal) nor a ship slice (release) is engineering tracking, not product.
105
112
  """
106
113
 
107
114
  id: str
108
115
  text: str
109
116
  metric: str | None = None # how the objective is measured, e.g. "churn < 5%"
117
+ description: str | None = None # optional longer blurb — the initiative's prose (#80)
118
+ target: str | None = None # optional ISO date the objective is aimed at; past + not DELIVERED warns
110
119
 
111
120
 
112
121
  @dataclass(frozen=True)
@@ -14,7 +14,7 @@ from datetime import date
14
14
  from .board import gather
15
15
  from .config import Config
16
16
  from .model import Model, NeedResult, NeedState
17
- from .status import active_decisions, principle_report, state_priority
17
+ from .status import active_decisions, group_state, principle_report, state_priority
18
18
 
19
19
  _LABEL = {
20
20
  NeedState.DELIVERED: "DELIVERED",
@@ -97,11 +97,12 @@ def render_markdown(
97
97
  out.append("")
98
98
 
99
99
  if model.goals:
100
- out += ["## Goals", "", "| goal | delivered | goal |", "|---|---|---|"]
100
+ out += ["## Goals", "", "| goal | state | delivered | target | goal |", "|---|---|---|---|---|"]
101
101
  for goal, gsum in goal_cov:
102
102
  out.append(
103
- f"| {goal.id} | {gsum.delivered}/{gsum.total} ({round(gsum.coverage * 100)}%) "
104
- f"| {goal.text} |"
103
+ f"| {goal.id} | {_LABEL[group_state(gsum)]} "
104
+ f"| {gsum.delivered}/{gsum.total} ({round(gsum.coverage * 100)}%) "
105
+ f"| {goal.target or ''} | {goal.text} |"
105
106
  )
106
107
  if ungoaled:
107
108
  out.append("")
@@ -15,7 +15,7 @@ from datetime import date
15
15
  from .board import gather, need_payload
16
16
  from .config import Config
17
17
  from .model import Model
18
- from .status import active_decisions, principle_report
18
+ from .status import active_decisions, group_state, principle_report
19
19
 
20
20
 
21
21
  def board_payload(
@@ -55,7 +55,16 @@ def board_payload(
55
55
  for r in results
56
56
  if r.need.product and not r.need.is_withdrawn
57
57
  ],
58
- "goals": [{"id": g.id, "text": g.text, **_cov(s.delivered, s.total)} for g, s in goal_cov],
58
+ "goals": [
59
+ {
60
+ "id": g.id,
61
+ "text": g.text,
62
+ "target": g.target,
63
+ "state": group_state(s).value,
64
+ **_cov(s.delivered, s.total),
65
+ }
66
+ for g, s in goal_cov
67
+ ],
59
68
  "ungoaled": [r.need.id for r in ungoaled],
60
69
  "releases": [
61
70
  {"id": rel.id, "text": rel.text, **_cov(s.delivered, s.total)} for rel, s in rel_cov
@@ -385,6 +385,26 @@ def summarize(results: list[NeedResult]) -> Summary:
385
385
  )
386
386
 
387
387
 
388
+ def group_state(s: Summary) -> NeedState:
389
+ """The derived state of an FK group (goal, release, role) — the roll-up of its members (#80).
390
+
391
+ One function behind every group's state, so a goal/initiative, a release slice and a role all
392
+ read the same way and none carries a hand-set status that can drift from its needs: BROKEN if
393
+ any member proof is broken, UNRESOLVED if any source couldn't be read, DELIVERED only when every
394
+ member is, HOLE when nothing is covered yet, else PENDING (in progress). An empty group is a
395
+ HOLE (nothing delivered, nothing pending).
396
+ """
397
+ if s.broken:
398
+ return NeedState.BROKEN
399
+ if s.unresolved:
400
+ return NeedState.UNRESOLVED
401
+ if s.total and s.delivered == s.total:
402
+ return NeedState.DELIVERED
403
+ if s.delivered == 0 and s.pending == 0:
404
+ return NeedState.HOLE
405
+ return NeedState.PENDING
406
+
407
+
388
408
  @dataclass(frozen=True)
389
409
  class Recurrence:
390
410
  """A baseline need and the clients that carry a variant of it (#32)."""
@@ -262,6 +262,26 @@ def test_goals_loaded_and_need_traces_up(tmp_path: Path) -> None:
262
262
  assert model.needs[0].goal == "G-1"
263
263
 
264
264
 
265
+ def test_goal_loads_description_and_target(tmp_path: Path) -> None:
266
+ # #80: a goal is the home for the initiative — a description and an optional target window.
267
+ from prodc.loader import load
268
+
269
+ cfg = _config(
270
+ tmp_path,
271
+ (
272
+ "goals.yaml",
273
+ "goals:\n"
274
+ " - id: INIT-SEARCH\n"
275
+ " text: Second-generation search\n"
276
+ " description: the long blurb\n"
277
+ " target: 2026-12-15\n",
278
+ ),
279
+ )
280
+ goal = load(cfg).goals[0]
281
+ assert goal.description == "the long blurb"
282
+ assert goal.target == "2026-12-15"
283
+
284
+
265
285
  def test_need_goal_unknown_raises(tmp_path: Path) -> None:
266
286
  cfg = _config(tmp_path, ("a.yaml", "needs:\n - id: N-1\n text: t\n goal: NOPE\n"))
267
287
  with pytest.raises(LoaderError, match="goal 'NOPE' is not a known goal id"):
@@ -7,7 +7,23 @@ from pathlib import Path
7
7
 
8
8
  from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
9
9
  from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
10
- from prodc.status import resolve
10
+ from prodc.status import Summary, group_state, resolve
11
+
12
+
13
+ def _summary(**kw: int) -> Summary:
14
+ base = dict(total=0, delivered=0, pending=0, hole=0, must_hole=0, broken=0, unresolved=0, opinion=0)
15
+ base.update(kw)
16
+ return Summary(**base)
17
+
18
+
19
+ def test_group_state_rolls_up_members() -> None:
20
+ # #80: a goal/release/role state is derived from its members, never hand-set.
21
+ assert group_state(_summary()) is NeedState.HOLE # empty group
22
+ assert group_state(_summary(total=2, hole=2)) is NeedState.HOLE # nothing started
23
+ assert group_state(_summary(total=2, delivered=2)) is NeedState.DELIVERED
24
+ assert group_state(_summary(total=2, delivered=1, pending=1)) is NeedState.PENDING
25
+ assert group_state(_summary(total=2, delivered=1, broken=1)) is NeedState.BROKEN # broken wins
26
+ assert group_state(_summary(total=2, delivered=1, unresolved=1)) is NeedState.UNRESOLVED
11
27
 
12
28
 
13
29
  @dataclass(frozen=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes