prodc 0.4.1__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.1 → prodc-0.4.3}/PKG-INFO +1 -1
  2. {prodc-0.4.1 → prodc-0.4.3}/pyproject.toml +1 -1
  3. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/render.py +17 -3
  4. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/serialize.py +5 -2
  5. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/loader.py +22 -4
  6. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/model.py +25 -1
  7. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/report.py +5 -4
  8. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/report_html.py +11 -2
  9. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/status.py +26 -6
  10. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/writer.py +14 -1
  11. {prodc-0.4.1 → prodc-0.4.3}/tests/test_loader.py +40 -0
  12. {prodc-0.4.1 → prodc-0.4.3}/tests/test_models.py +2 -2
  13. {prodc-0.4.1 → prodc-0.4.3}/tests/test_status.py +24 -8
  14. {prodc-0.4.1 → prodc-0.4.3}/tests/test_writer.py +27 -0
  15. {prodc-0.4.1 → prodc-0.4.3}/.gitignore +0 -0
  16. {prodc-0.4.1 → prodc-0.4.3}/CHANGELOG.md +0 -0
  17. {prodc-0.4.1 → prodc-0.4.3}/LICENSE +0 -0
  18. {prodc-0.4.1 → prodc-0.4.3}/README.md +0 -0
  19. {prodc-0.4.1 → prodc-0.4.3}/examples/quickstart/features/orders.feature +0 -0
  20. {prodc-0.4.1 → prodc-0.4.3}/examples/quickstart/needs/jordan.yaml +0 -0
  21. {prodc-0.4.1 → prodc-0.4.3}/examples/quickstart/prodc.toml +0 -0
  22. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/__init__.py +0 -0
  23. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/adapters.py +0 -0
  24. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/agent.md +0 -0
  25. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/board.py +0 -0
  26. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/callbacks.py +0 -0
  27. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/__init__.py +0 -0
  28. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/app.py +0 -0
  29. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/commands.py +0 -0
  30. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/common.py +0 -0
  31. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/config.py +0 -0
  32. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/dedup.py +0 -0
  33. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/guide.md +0 -0
  34. {prodc-0.4.1 → prodc-0.4.3}/src/prodc/py.typed +0 -0
  35. {prodc-0.4.1 → prodc-0.4.3}/tests/test_adapters.py +0 -0
  36. {prodc-0.4.1 → prodc-0.4.3}/tests/test_answer.py +0 -0
  37. {prodc-0.4.1 → prodc-0.4.3}/tests/test_callbacks.py +0 -0
  38. {prodc-0.4.1 → prodc-0.4.3}/tests/test_citation.py +0 -0
  39. {prodc-0.4.1 → prodc-0.4.3}/tests/test_claim_gating.py +0 -0
  40. {prodc-0.4.1 → prodc-0.4.3}/tests/test_cli.py +0 -0
  41. {prodc-0.4.1 → prodc-0.4.3}/tests/test_command_hook.py +0 -0
  42. {prodc-0.4.1 → prodc-0.4.3}/tests/test_contradicts.py +0 -0
  43. {prodc-0.4.1 → prodc-0.4.3}/tests/test_decisions.py +0 -0
  44. {prodc-0.4.1 → prodc-0.4.3}/tests/test_dedup.py +0 -0
  45. {prodc-0.4.1 → prodc-0.4.3}/tests/test_description.py +0 -0
  46. {prodc-0.4.1 → prodc-0.4.3}/tests/test_drafts.py +0 -0
  47. {prodc-0.4.1 → prodc-0.4.3}/tests/test_evalgate.py +0 -0
  48. {prodc-0.4.1 → prodc-0.4.3}/tests/test_external_adapter.py +0 -0
  49. {prodc-0.4.1 → prodc-0.4.3}/tests/test_gitlab_source.py +0 -0
  50. {prodc-0.4.1 → prodc-0.4.3}/tests/test_importance.py +0 -0
  51. {prodc-0.4.1 → prodc-0.4.3}/tests/test_needref.py +0 -0
  52. {prodc-0.4.1 → prodc-0.4.3}/tests/test_principles.py +0 -0
  53. {prodc-0.4.1 → prodc-0.4.3}/tests/test_relink.py +0 -0
  54. {prodc-0.4.1 → prodc-0.4.3}/tests/test_report.py +0 -0
  55. {prodc-0.4.1 → prodc-0.4.3}/tests/test_report_html.py +0 -0
  56. {prodc-0.4.1 → prodc-0.4.3}/tests/test_review.py +0 -0
  57. {prodc-0.4.1 → prodc-0.4.3}/tests/test_roles.py +0 -0
  58. {prodc-0.4.1 → prodc-0.4.3}/tests/test_source_role.py +0 -0
  59. {prodc-0.4.1 → prodc-0.4.3}/tests/test_sources.py +0 -0
  60. {prodc-0.4.1 → prodc-0.4.3}/tests/test_staleness.py +0 -0
  61. {prodc-0.4.1 → prodc-0.4.3}/tests/test_suggest.py +0 -0
  62. {prodc-0.4.1 → prodc-0.4.3}/tests/test_variants.py +0 -0
  63. {prodc-0.4.1 → prodc-0.4.3}/tests/test_who_variants.py +0 -0
  64. {prodc-0.4.1 → prodc-0.4.3}/tests/test_withdrawn.py +0 -0
  65. {prodc-0.4.1 → 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.1
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.1"
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}")
@@ -382,7 +393,10 @@ def render_show(
382
393
  else:
383
394
  lines.append(" sources: (none — opinion)")
384
395
  if need.children:
385
- lines.append(f" children: {', '.join(need.children)}")
396
+ rendered = ", ".join(
397
+ c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children
398
+ )
399
+ lines.append(f" children: {rendered}")
386
400
  if need.contradicts:
387
401
  lines.append(f" contradicts: {', '.join(need.contradicts)}")
388
402
  if need.seam:
@@ -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,
@@ -160,7 +163,7 @@ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
160
163
  for s in need.sources
161
164
  ],
162
165
  "proofs": list(need.proofs),
163
- "children": list(need.children),
166
+ "children": [{"id": c.id, "step": c.step} for c in need.children],
164
167
  "seam": list(need.seam),
165
168
  }
166
169
  )
@@ -21,6 +21,7 @@ from .model import (
21
21
  Answer,
22
22
  Attestation,
23
23
  AttestationKind,
24
+ Child,
24
25
  Decision,
25
26
  Goal,
26
27
  Model,
@@ -203,6 +204,21 @@ def _source(raw: object, where: str) -> Source:
203
204
  )
204
205
 
205
206
 
207
+ def _child(raw: object, where: str) -> Child:
208
+ """Parse one flow edge: a bare id string, or a ``{id, step}`` mapping (#90).
209
+
210
+ A bare ``US-050`` stays valid (``Child("US-050")`` with no step), so existing flows load
211
+ unchanged; a mapping adds the optional ``step`` the edge plays in the journey.
212
+ """
213
+ if isinstance(raw, str):
214
+ return Child(id=raw)
215
+ tbl = _as_mapping(raw, where)
216
+ return Child(
217
+ id=_as_str(tbl.get("id"), f"{where}.id"),
218
+ step=_opt_str(tbl.get("step"), f"{where}.step"),
219
+ )
220
+
221
+
206
222
  def _need(
207
223
  raw: object,
208
224
  where: str,
@@ -243,7 +259,7 @@ def _need(
243
259
  for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
244
260
  )
245
261
  children = tuple(
246
- _as_str(c, f"{where}.children[{i}]")
262
+ _child(c, f"{where}.children[{i}]")
247
263
  for i, c in enumerate(_as_list(tbl.get("children"), f"{where}.children"))
248
264
  )
249
265
  seam = tuple(
@@ -306,6 +322,8 @@ def _goal(raw: object, where: str) -> Goal:
306
322
  id=_as_str(tbl.get("id"), f"{where}.id"),
307
323
  text=_as_str(tbl.get("text"), f"{where}.text"),
308
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"),
309
327
  )
310
328
 
311
329
 
@@ -564,9 +582,9 @@ def _check_children(needs: list[Need]) -> None:
564
582
  """Every id in a need's ``children`` must name a known need."""
565
583
  known = {n.id for n in needs}
566
584
  for need in needs:
567
- for cid in need.children:
568
- if cid not in known:
569
- raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
585
+ for c in need.children:
586
+ if c.id not in known:
587
+ raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
570
588
 
571
589
 
572
590
  def _check_withdrawal_of(needs: list[Need]) -> None:
@@ -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)
@@ -257,6 +266,21 @@ class Withdrawal:
257
266
  of: str | None = None # id of the need this one duplicates (required when kind is duplicate)
258
267
 
259
268
 
269
+ @dataclass(frozen=True)
270
+ class Child:
271
+ """One edge of a flow: the sub-need and, optionally, the ``step`` it plays in the journey (#90).
272
+
273
+ A flow's ``children`` are ordered, and a use case ("job") is just a flow with a ``role`` set and
274
+ a ``step`` on each edge (locate / execute / conclude). The step annotates the *edge*, not the
275
+ sub-need — the same need can be the ``execute`` step of one flow and the ``locate`` of another —
276
+ so it lives here, not on :class:`Need`. A bare ``US-050`` in YAML loads as ``Child("US-050")``
277
+ with no step, so every existing flow keeps working unchanged.
278
+ """
279
+
280
+ id: str
281
+ step: str | None = None
282
+
283
+
260
284
  @dataclass(frozen=True)
261
285
  class Need:
262
286
  """What a named person wants, with its provenance and its proofs."""
@@ -269,7 +293,7 @@ class Need:
269
293
  must: bool = False
270
294
  sources: tuple[Source, ...] = ()
271
295
  proofs: tuple[str, ...] = () # locators, "<adapter>:<payload>" (leaf needs)
272
- children: tuple[str, ...] = () # ids of sub-needs (a flow is a Need with children)
296
+ children: tuple[Child, ...] = () # ordered sub-need edges (a flow is a Need with children)
273
297
  seam: tuple[str, ...] = () # end-to-end proof locators for a flow's between-steps behaviour
274
298
  client: str | None = None # which client this instance is for; None = shared baseline
275
299
  variant_of: str | None = None # id of the baseline need this specialises; None = standalone
@@ -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
@@ -293,11 +293,11 @@ class Resolver:
293
293
  warnings: list[str] = []
294
294
  child_states: list[NeedState] = []
295
295
  declared = False
296
- for cid in need.children:
297
- if cid not in self._by_id:
298
- warnings.append(f"unknown child id '{cid}'")
296
+ for c in need.children:
297
+ if c.id not in self._by_id:
298
+ warnings.append(f"unknown child id '{c.id}'")
299
299
  continue
300
- cr = self.resolve(cid)
300
+ cr = self.resolve(c.id)
301
301
  child_states.append(cr.state)
302
302
  declared = declared or cr.declared
303
303
  delivered = sum(1 for s in child_states if s is NeedState.DELIVERED)
@@ -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)."""
@@ -806,7 +826,7 @@ def handoffs(needs: list[Need]) -> list[Handoff]:
806
826
  for flow in needs:
807
827
  if not flow.children:
808
828
  continue
809
- steps = [by_id[c] for c in flow.children if c in by_id]
829
+ steps = [by_id[c.id] for c in flow.children if c.id in by_id]
810
830
  for a, b in pairwise(steps):
811
831
  if a.role and b.role and a.role != b.role:
812
832
  out.append(Handoff(flow, a, b, a.role, b.role))
@@ -844,7 +864,7 @@ def flow_orphan_warnings(needs: list[Need]) -> set[str]:
844
864
  return set()
845
865
  in_a_flow: set[str] = set()
846
866
  for flow in flows:
847
- in_a_flow.update(flow.children)
867
+ in_a_flow.update(c.id for c in flow.children)
848
868
  return {n.id for n in needs if not n.children and n.id not in in_a_flow}
849
869
 
850
870
 
@@ -365,6 +365,19 @@ def _coerce(value: str) -> object:
365
365
  return value
366
366
 
367
367
 
368
+ def _child_item(raw: str) -> object:
369
+ """Parse a ``children+=`` value into a flow edge: ``id`` stays a bare string, ``id@step`` a map.
370
+
371
+ Mirrors the loader's dual shape (#90): ``children+=US-050`` appends the bare id (unstepped),
372
+ ``children+=US-052@execute`` appends ``{id: US-052, step: execute}``. A bare id keeps the file's
373
+ existing plain-string style; the ``@`` suffix mirrors the ``need:A@yes`` proof-locator idiom.
374
+ """
375
+ head, sep, step = raw.partition("@")
376
+ if not sep:
377
+ return raw
378
+ return {"id": head.strip(), "step": step.strip()}
379
+
380
+
368
381
  def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> None:
369
382
  if "+=" in assignment:
370
383
  key, _, raw = assignment.partition("+=")
@@ -377,7 +390,7 @@ def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> N
377
390
  if not isinstance(current, list):
378
391
  raise WriteError(f"{path.name}: {key!r} is not a list")
379
392
  items = cast("list[object]", current)
380
- items.append(raw.strip())
393
+ items.append(_child_item(raw.strip()) if key == "children" else raw.strip())
381
394
  need[key] = items
382
395
  return
383
396
  key, sep, raw = assignment.partition("=")
@@ -109,6 +109,26 @@ def test_flow_with_proofs_raises(tmp_path: Path) -> None:
109
109
  load_needs(cfg)
110
110
 
111
111
 
112
+ def test_children_load_as_bare_id_or_stepped_mapping(tmp_path: Path) -> None:
113
+ # #90: a flow's children take either a bare id (unstepped) or a {id, step} mapping, mixed.
114
+ cfg = _config(
115
+ tmp_path,
116
+ (
117
+ "a.yaml",
118
+ "needs:\n"
119
+ " - id: F-1\n"
120
+ " text: a flow\n"
121
+ " children:\n"
122
+ " - C-1\n"
123
+ " - {id: C-2, step: execute}\n"
124
+ " - id: C-1\n text: one\n"
125
+ " - id: C-2\n text: two\n",
126
+ ),
127
+ )
128
+ flow = next(n for n in load_needs(cfg) if n.id == "F-1")
129
+ assert [(c.id, c.step) for c in flow.children] == [("C-1", None), ("C-2", "execute")]
130
+
131
+
112
132
  def test_variant_scope_shared_baseline_across_clients(tmp_path: Path) -> None:
113
133
  cfg = _config(
114
134
  tmp_path,
@@ -242,6 +262,26 @@ def test_goals_loaded_and_need_traces_up(tmp_path: Path) -> None:
242
262
  assert model.needs[0].goal == "G-1"
243
263
 
244
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
+
245
285
  def test_need_goal_unknown_raises(tmp_path: Path) -> None:
246
286
  cfg = _config(tmp_path, ("a.yaml", "needs:\n - id: N-1\n text: t\n goal: NOPE\n"))
247
287
  with pytest.raises(LoaderError, match="goal 'NOPE' is not a known goal id"):
@@ -14,7 +14,7 @@ from dataclasses import dataclass, replace
14
14
  from musil import Action, Model, check, explore, reaches
15
15
 
16
16
  from prodc.adapters import Adapter, ProofResult
17
- from prodc.model import Decision, Need, NeedState, ProofState, Withdrawal, board_needs
17
+ from prodc.model import Child, Decision, Need, NeedState, ProofState, Withdrawal, board_needs
18
18
  from prodc.status import Resolver, active_decisions
19
19
 
20
20
 
@@ -141,7 +141,7 @@ def flow_state(f: Flow) -> NeedState:
141
141
  """The NeedState prodc's real aggregate gives this set of children + seam."""
142
142
  children = [_child_need(f"c{i}", s) for i, s in enumerate(f.children)]
143
143
  seam = tuple(f"stub:{s}" for s in f.seam if s != "none")
144
- flow = Need(id="flow", text="f", children=tuple(c.id for c in children), seam=seam)
144
+ flow = Need(id="flow", text="f", children=tuple(Child(c.id) for c in children), seam=seam)
145
145
  return Resolver([flow, *children], _ADAPTERS).resolve("flow").state
146
146
 
147
147
 
@@ -6,8 +6,24 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
8
  from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
9
- from prodc.model import Need, NeedState, ProofState, Source, SourceKind
10
- from prodc.status import resolve
9
+ from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
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)
@@ -52,7 +68,7 @@ def test_flow_aggregates_two_of_three() -> None:
52
68
  Need(id="C-2", text="two", proofs=("web:2",)),
53
69
  Need(id="C-3", text="three", proofs=()), # a HOLE
54
70
  )
55
- flow = Need(id="F-1", text="a flow", children=("C-1", "C-2", "C-3"))
71
+ flow = Need(id="F-1", text="a flow", children=(Child("C-1"), Child("C-2"), Child("C-3")))
56
72
  results = {r.need.id: r for r in resolve([flow, *children], {"web": _delivered()})}
57
73
  flow_result = results["F-1"]
58
74
  assert flow_result.state is NeedState.PENDING # not all delivered → pending
@@ -234,7 +250,7 @@ def test_coverage_counts_leaves_not_flow_parents() -> None:
234
250
  from prodc.status import summarize
235
251
 
236
252
  needs = [
237
- Need(id="F-1", text="roast day flow", children=("L-1", "L-2")),
253
+ Need(id="F-1", text="roast day flow", children=(Child("L-1"), Child("L-2"))),
238
254
  Need(id="L-1", text="step one", proofs=("x:1",)),
239
255
  Need(id="L-2", text="step two", proofs=("x:1",)),
240
256
  ]
@@ -296,7 +312,7 @@ def test_flow_orphan_warning() -> None:
296
312
  from prodc.status import flow_orphan_warnings
297
313
 
298
314
  needs = [
299
- Need(id="F-1", text="a flow", children=("L-1",)),
315
+ Need(id="F-1", text="a flow", children=(Child("L-1"),)),
300
316
  Need(id="L-1", text="in the flow"),
301
317
  Need(id="L-2", text="in no flow"),
302
318
  ]
@@ -354,7 +370,7 @@ def test_flow_seam_proof_gates_delivered() -> None:
354
370
  # all children delivered but the seam proof pending → flow is PENDING, not DELIVERED (#44)
355
371
  from prodc.adapters import Adapter
356
372
 
357
- flow = Need(id="F-1", text="roast day", children=("L-1", "L-2"), seam=("web:seam#E2E",))
373
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"), Child("L-2")), seam=("web:seam#E2E",))
358
374
  children = [
359
375
  Need(id="L-1", text="a", proofs=("x:1",)),
360
376
  Need(id="L-2", text="b", proofs=("x:1",)),
@@ -368,14 +384,14 @@ def test_flow_seam_proof_gates_delivered() -> None:
368
384
 
369
385
 
370
386
  def test_flow_seam_green_delivers() -> None:
371
- flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("web:seam#E2E",))
387
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("web:seam#E2E",))
372
388
  child = Need(id="L-1", text="a", proofs=("x:1",))
373
389
  by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered(), "web": _delivered()})}
374
390
  assert by_id["F-1"].state is NeedState.DELIVERED
375
391
 
376
392
 
377
393
  def test_flow_seam_broken_breaks_flow() -> None:
378
- flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("nope:x",))
394
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("nope:x",))
379
395
  child = Need(id="L-1", text="a", proofs=("x:1",))
380
396
  by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered()})}
381
397
  assert by_id["F-1"].state is NeedState.BROKEN # no 'nope' adapter → seam BROKEN
@@ -275,6 +275,33 @@ def test_delete_need_removes_the_block_keeping_others(tmp_path: Path) -> None:
275
275
  assert "# keep me" in text # header comment survives the splice
276
276
 
277
277
 
278
+ def test_children_append_stepped_edge(tmp_path: Path) -> None:
279
+ # #90: `children+=id@step` appends a {id, step} edge; a bare id stays a plain string.
280
+ body = (
281
+ "needs:\n"
282
+ " - id: F-1\n text: a flow\n children: [N-2]\n"
283
+ " - id: N-2\n text: step two\n proofs: ['x:1']\n"
284
+ " - id: N-3\n text: step three\n proofs: ['x:2']\n"
285
+ )
286
+ cfg = _proj(tmp_path, body)
287
+ set_field(cfg, "F-1", "children+=N-3@conclude", expect_sha=None, dry_run=False)
288
+ flow = next(n for n in load(cfg).needs if n.id == "F-1")
289
+ assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", "conclude")]
290
+
291
+
292
+ def test_children_append_bare_id(tmp_path: Path) -> None:
293
+ body = (
294
+ "needs:\n"
295
+ " - id: F-1\n text: a flow\n children: [N-2]\n"
296
+ " - id: N-2\n text: step two\n proofs: ['x:1']\n"
297
+ " - id: N-3\n text: step three\n proofs: ['x:2']\n"
298
+ )
299
+ cfg = _proj(tmp_path, body)
300
+ set_field(cfg, "F-1", "children+=N-3", expect_sha=None, dry_run=False)
301
+ flow = next(n for n in load(cfg).needs if n.id == "F-1")
302
+ assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
303
+
304
+
278
305
  def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
279
306
  cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
280
307
  with pytest.raises(WriteError, match="no need 'N-9'"):
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