prodc 0.3.61__tar.gz → 0.3.63__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 (47) hide show
  1. {prodc-0.3.61 → prodc-0.3.63}/PKG-INFO +1 -1
  2. {prodc-0.3.61 → prodc-0.3.63}/pyproject.toml +1 -1
  3. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/agent.md +1 -0
  4. prodc-0.3.63/src/prodc/board.py +218 -0
  5. prodc-0.3.63/src/prodc/callbacks.py +195 -0
  6. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/cli.py +106 -97
  7. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/config.py +52 -3
  8. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/model.py +5 -7
  9. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/report.py +10 -18
  10. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/report_html.py +26 -73
  11. prodc-0.3.63/tests/test_callbacks.py +216 -0
  12. {prodc-0.3.61 → prodc-0.3.63}/tests/test_command_hook.py +1 -3
  13. {prodc-0.3.61 → prodc-0.3.63}/tests/test_description.py +2 -6
  14. {prodc-0.3.61 → prodc-0.3.63}/tests/test_report.py +1 -3
  15. {prodc-0.3.61 → prodc-0.3.63}/tests/test_report_html.py +9 -7
  16. {prodc-0.3.61 → prodc-0.3.63}/tests/test_sources.py +12 -3
  17. {prodc-0.3.61 → prodc-0.3.63}/tests/test_status.py +3 -1
  18. {prodc-0.3.61 → prodc-0.3.63}/.gitignore +0 -0
  19. {prodc-0.3.61 → prodc-0.3.63}/CHANGELOG.md +0 -0
  20. {prodc-0.3.61 → prodc-0.3.63}/LICENSE +0 -0
  21. {prodc-0.3.61 → prodc-0.3.63}/README.md +0 -0
  22. {prodc-0.3.61 → prodc-0.3.63}/examples/quickstart/features/orders.feature +0 -0
  23. {prodc-0.3.61 → prodc-0.3.63}/examples/quickstart/needs/jordan.yaml +0 -0
  24. {prodc-0.3.61 → prodc-0.3.63}/examples/quickstart/prodc.toml +0 -0
  25. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/__init__.py +0 -0
  26. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/adapters.py +0 -0
  27. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/dedup.py +0 -0
  28. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/loader.py +0 -0
  29. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/py.typed +0 -0
  30. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/status.py +0 -0
  31. {prodc-0.3.61 → prodc-0.3.63}/src/prodc/writer.py +0 -0
  32. {prodc-0.3.61 → prodc-0.3.63}/tests/test_adapters.py +0 -0
  33. {prodc-0.3.61 → prodc-0.3.63}/tests/test_cli.py +0 -0
  34. {prodc-0.3.61 → prodc-0.3.63}/tests/test_dedup.py +0 -0
  35. {prodc-0.3.61 → prodc-0.3.63}/tests/test_drafts.py +0 -0
  36. {prodc-0.3.61 → prodc-0.3.63}/tests/test_external_adapter.py +0 -0
  37. {prodc-0.3.61 → prodc-0.3.63}/tests/test_gitlab_source.py +0 -0
  38. {prodc-0.3.61 → prodc-0.3.63}/tests/test_importance.py +0 -0
  39. {prodc-0.3.61 → prodc-0.3.63}/tests/test_loader.py +0 -0
  40. {prodc-0.3.61 → prodc-0.3.63}/tests/test_models.py +0 -0
  41. {prodc-0.3.61 → prodc-0.3.63}/tests/test_roles.py +0 -0
  42. {prodc-0.3.61 → prodc-0.3.63}/tests/test_staleness.py +0 -0
  43. {prodc-0.3.61 → prodc-0.3.63}/tests/test_suggest.py +0 -0
  44. {prodc-0.3.61 → prodc-0.3.63}/tests/test_variants.py +0 -0
  45. {prodc-0.3.61 → prodc-0.3.63}/tests/test_who_variants.py +0 -0
  46. {prodc-0.3.61 → prodc-0.3.63}/tests/test_writer.py +0 -0
  47. {prodc-0.3.61 → prodc-0.3.63}/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.61
3
+ Version: 0.3.63
4
4
  Summary: Product management as code: roles, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.61"
3
+ version = "0.3.63"
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"
@@ -76,6 +76,7 @@ Read first, then act, then check.
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
78
  | hand a human a readable status file | `prodc report -o status.md` (markdown; `--format html` for an interactive viewer; `--client ID` to scope) |
79
+ | 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) |
79
80
  | gate in CI | `prodc lint --strict` |
80
81
 
81
82
  Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <toml>…`
@@ -0,0 +1,218 @@
1
+ """Gather the resolved board once, for every renderer to consume.
2
+
3
+ `prodc status`, `prodc report` (markdown), and `prodc report --format html` each need the
4
+ same thing: resolve every need, roll it up, and compute the per-goal / per-release / per-role
5
+ coverage, the handoffs, the recurrence, and the orphan proofs. They used to do that gather
6
+ three separate times, which is how the three outputs could silently drift. :func:`gather` does
7
+ it once and returns a :class:`ResolvedBoard`; the renderers format that struct. :func:`warrant`
8
+ and :func:`need_payload` are the one user-signal string and the one Need-to-dict serialiser the
9
+ board and the HTML viewer share, so a need looks the same wherever it is rendered.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import date
16
+ from typing import Literal
17
+
18
+ from .config import Config
19
+ from .model import (
20
+ Goal,
21
+ Model,
22
+ Need,
23
+ NeedResult,
24
+ Release,
25
+ Role,
26
+ SourceKind,
27
+ ears_shape,
28
+ )
29
+ from .status import (
30
+ Handoff,
31
+ Recurrence,
32
+ Summary,
33
+ evidence_age_days,
34
+ flow_orphan_warnings,
35
+ goal_report,
36
+ handoffs,
37
+ last_observed,
38
+ orphans,
39
+ recurrence,
40
+ release_report,
41
+ resolve,
42
+ role_report,
43
+ shared_proof_warnings,
44
+ summarize,
45
+ )
46
+
47
+
48
+ def warrant(need: Need) -> str:
49
+ """The warrant column: the strongest source kind, or 'opinion' when there is no source at all.
50
+
51
+ A `primary` source names its person (`primary:who`); `secondary`/`assumption` show the kind. A
52
+ need with any source is shown by that kind even if it is an opinion (only an assumption) — the
53
+ opinion roll-up lives in the gap counts, this column shows the literal evidence tier.
54
+ """
55
+ strongest = need.strongest_kind # the one source-strength ranking lives on the model
56
+ if strongest is None:
57
+ return "opinion"
58
+ if strongest is SourceKind.PRIMARY:
59
+ who = next((s.who for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
60
+ return f"primary:{who}" if who else "primary"
61
+ return strongest.value
62
+
63
+
64
+ def annotate_board_warnings(results: list[NeedResult], needs: list[Need]) -> None:
65
+ """Append the cross-need hygiene warnings (shared proofs #45, seam coverage #44)."""
66
+ shared = shared_proof_warnings(results)
67
+ flow_orphans = flow_orphan_warnings(needs)
68
+ for r in results:
69
+ if r.need.id in shared:
70
+ r.warnings.append(shared[r.need.id])
71
+ if r.need.id in flow_orphans:
72
+ r.warnings.append("belongs to no flow — seam not covered (#44)")
73
+
74
+
75
+ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -> dict[str, object]:
76
+ """One need serialised to a plain dict — the single Need-to-JSON used by the board and viewer.
77
+
78
+ ``flavor="board"`` is the status/`show` payload (the full record); ``flavor="html"`` is the
79
+ report viewer's flatter row. They carry different fields in a different order, so the two
80
+ shapes live here together, byte-for-byte as they were, rather than as copies in two modules.
81
+ """
82
+ need = r.need
83
+ if flavor == "html":
84
+ strongest = need.strongest_kind
85
+ return {
86
+ "id": need.id,
87
+ "text": need.text,
88
+ "description": need.description,
89
+ "state": r.state.value,
90
+ "must": need.must,
91
+ "who": need.who or "(unassigned)",
92
+ "label": need.label,
93
+ "client": need.client,
94
+ "opinion": need.is_opinion,
95
+ "warrant": strongest.value if strongest is not None else "opinion",
96
+ "importance": need.importance,
97
+ "goal": need.goal,
98
+ "release": need.release,
99
+ "claim": need.claim,
100
+ "proofs": list(need.proofs),
101
+ "detail": list(r.detail),
102
+ "warnings": list(r.warnings),
103
+ "attestation": (
104
+ {
105
+ "who": need.attestation.who,
106
+ "date": need.attestation.date,
107
+ "kind": need.attestation.kind.value,
108
+ "verdict": need.attestation.verdict,
109
+ }
110
+ if need.attestation
111
+ else None
112
+ ),
113
+ "sources": [
114
+ {
115
+ "kind": s.kind.value,
116
+ "who": s.who,
117
+ "date": s.date,
118
+ "quote": s.quote,
119
+ "locator": s.locator,
120
+ }
121
+ for s in need.sources
122
+ ],
123
+ }
124
+ return {
125
+ "id": need.id,
126
+ "who": need.who,
127
+ "role": need.role,
128
+ "text": need.text,
129
+ "description": need.description,
130
+ "must": need.must,
131
+ "state": r.state.value,
132
+ "grounds": r.state.value,
133
+ "warrant": warrant(need),
134
+ "shape": ears_shape(need.text).value,
135
+ "client": need.client,
136
+ "variant_of": need.variant_of,
137
+ "goal": need.goal,
138
+ "release": need.release,
139
+ "product": need.product,
140
+ "draft": need.draft,
141
+ "importance": need.importance,
142
+ "importance_set_at": need.importance_set_at,
143
+ "claim": need.claim,
144
+ "attestation": (
145
+ {
146
+ "who": need.attestation.who,
147
+ "date": need.attestation.date,
148
+ "kind": need.attestation.kind.value,
149
+ "verdict": need.attestation.verdict,
150
+ "comment": need.attestation.comment,
151
+ }
152
+ if need.attestation
153
+ else None
154
+ ),
155
+ "delivered": r.delivered,
156
+ "total": r.total,
157
+ "declared": r.declared,
158
+ "opinion": need.is_opinion,
159
+ "only_assumption": need.only_assumption,
160
+ "evidence_age": evidence_age_days(need, date.today()),
161
+ "last_observed_at": last_observed(need),
162
+ "detail": r.detail,
163
+ "warnings": r.warnings,
164
+ }
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class ResolvedBoard:
169
+ """The board resolved once: the results and every roll-up the renderers read off it."""
170
+
171
+ results: list[NeedResult]
172
+ summary: Summary
173
+ goal_cov: list[tuple[Goal, Summary]]
174
+ ungoaled: list[NeedResult]
175
+ rel_cov: list[tuple[Release, Summary]]
176
+ role_cov: list[tuple[Role, Summary]]
177
+ unroled: list[NeedResult]
178
+ handoffs: list[Handoff]
179
+ recurrence: list[Recurrence]
180
+ orphans: dict[str, list[str]]
181
+
182
+
183
+ def gather(
184
+ cfg: Config,
185
+ model: Model,
186
+ *,
187
+ needs: list[Need] | None = None,
188
+ annotate_warnings: bool = False,
189
+ ) -> ResolvedBoard:
190
+ """Resolve the board once and compute every roll-up a renderer needs.
191
+
192
+ ``needs`` overrides which needs are resolved (the caller filters drafts/client/goal/stale
193
+ before calling and passes the result); it defaults to the whole model. ``annotate_warnings``
194
+ appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
195
+ the markdown/HTML reports leave it off, exactly as they did before this was factored out.
196
+ The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
197
+ are computed over the resolved ``needs``.
198
+ """
199
+ use_needs = list(model.needs) if needs is None else needs
200
+ results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
201
+ if annotate_warnings:
202
+ annotate_board_warnings(results, use_needs)
203
+ summary = summarize(results)
204
+ goal_cov, ungoaled = goal_report(results, list(model.goals))
205
+ rel_cov = release_report(results, list(model.releases))
206
+ role_cov, unroled = role_report(results, list(model.roles))
207
+ return ResolvedBoard(
208
+ results=results,
209
+ summary=summary,
210
+ goal_cov=goal_cov,
211
+ ungoaled=ungoaled,
212
+ rel_cov=rel_cov,
213
+ role_cov=role_cov,
214
+ unroled=unroled,
215
+ handoffs=handoffs(use_needs),
216
+ recurrence=recurrence(use_needs),
217
+ orphans=orphans(use_needs, cfg.adapters),
218
+ )
@@ -0,0 +1,195 @@
1
+ """Lifecycle callbacks: run a command when a need changes state (#71).
2
+
3
+ The signal that a need is done already lives in prodc — its proof resolving to DELIVERED. This
4
+ lets a team act on that event: when a need's state changes, prodc runs a command from the project
5
+ config with the affected need (and board context) as JSON on **stdin**. Same trust model as the
6
+ command hook (#70): the command comes only from the config, never from need data; the need data is
7
+ piped as data, never executed.
8
+
9
+ A state change needs the *previous* state, which prodc (stateless per run — it re-reads every file)
10
+ does not otherwise keep. So a small ledger (``state_file``) records each need's last-seen state;
11
+ `prodc sync` resolves the board, diffs it against the ledger, fires the matching callbacks, and
12
+ writes the ledger forward. The first run records a baseline and fires nothing (everything would
13
+ look new). A callback that fails is reported, never silent, and its need is *not* advanced in the
14
+ ledger, so the next sync retries it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import subprocess
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import cast
25
+
26
+ from .model import Need
27
+
28
+ #: The only event implemented today; others from #71 (capture/report/pre_gate) are follow-ups.
29
+ STATE_CHANGE = "state_change"
30
+
31
+ #: Runs an argv (no shell) with a JSON payload on stdin, returning (ok, short reason). Injected in
32
+ #: tests so no process spawns; :func:`_invoke` is the real subprocess default.
33
+ Invoker = Callable[[tuple[str, ...], str, Path, float], tuple[bool, str]]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Callback:
38
+ """One configured lifecycle callback: an event, a command, and optional transition filters."""
39
+
40
+ event: str
41
+ command: tuple[str, ...]
42
+ timeout: float = 30.0
43
+ on_from: frozenset[str] = frozenset() # empty = any source state
44
+ on_to: frozenset[str] = frozenset() # empty = any target state
45
+
46
+ def matches(self, frm: str, to: str) -> bool:
47
+ """Whether this callback fires for a transition ``frm -> to``."""
48
+ if self.on_from and frm not in self.on_from:
49
+ return False
50
+ return not (self.on_to and to not in self.on_to)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Transition:
55
+ """A need whose resolved state changed since the last sync."""
56
+
57
+ need_id: str
58
+ frm: str
59
+ to: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Fired:
64
+ """The outcome of running one callback for one transition."""
65
+
66
+ transition: Transition
67
+ command: tuple[str, ...]
68
+ ok: bool
69
+ detail: str
70
+
71
+
72
+ def read_ledger(path: Path) -> dict[str, str] | None:
73
+ """The last-seen state per need id, or ``None`` when no ledger exists yet (first run)."""
74
+ if not path.exists():
75
+ return None
76
+ try:
77
+ raw: object = json.loads(path.read_text(encoding="utf-8"))
78
+ except ValueError:
79
+ return None
80
+ if not isinstance(raw, dict):
81
+ return None
82
+ return {str(k): str(v) for k, v in cast("dict[object, object]", raw).items()}
83
+
84
+
85
+ def write_ledger(path: Path, states: dict[str, str]) -> None:
86
+ """Persist the current state per need id (keys sorted for a stable, reviewable file)."""
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ body = json.dumps({k: states[k] for k in sorted(states)}, indent=2) + "\n"
89
+ path.write_text(body, encoding="utf-8")
90
+
91
+
92
+ def transitions(ledger: dict[str, str], current: dict[str, str]) -> list[Transition]:
93
+ """Needs whose state differs from the ledger.
94
+
95
+ A need absent from the ledger is *new*, not a transition — it is baselined silently and never
96
+ fired on, so adding a need does not masquerade as a state change.
97
+ """
98
+ out = [
99
+ Transition(nid, ledger[nid], state)
100
+ for nid, state in current.items()
101
+ if nid in ledger and ledger[nid] != state
102
+ ]
103
+ out.sort(key=lambda t: t.need_id)
104
+ return out
105
+
106
+
107
+ def fire(
108
+ trans: list[Transition],
109
+ callbacks: list[Callback],
110
+ *,
111
+ context: dict[str, Need],
112
+ board: dict[str, object],
113
+ cwd: Path,
114
+ invoke: Invoker | None = None,
115
+ ) -> list[Fired]:
116
+ """Run every callback matching each transition, piping the need + board as JSON on stdin.
117
+
118
+ ``context`` maps a need id to its :class:`~prodc.model.Need` (for the payload). Returns one
119
+ :class:`Fired` per (transition, matching callback) actually run, in transition then config
120
+ order.
121
+ """
122
+ run = invoke or _invoke
123
+ state_cbs = [c for c in callbacks if c.event == STATE_CHANGE]
124
+ out: list[Fired] = []
125
+ for t in trans:
126
+ need = context.get(t.need_id)
127
+ payload = json.dumps(
128
+ {
129
+ "event": STATE_CHANGE,
130
+ "from": t.frm,
131
+ "to": t.to,
132
+ "need": _need_json(need, t.to) if need else {"id": t.need_id, "state": t.to},
133
+ "board": board,
134
+ },
135
+ ensure_ascii=False,
136
+ )
137
+ for cb in state_cbs:
138
+ if cb.matches(t.frm, t.to):
139
+ ok, detail = run(cb.command, payload, cwd, cb.timeout)
140
+ out.append(Fired(t, cb.command, ok, detail))
141
+ return out
142
+
143
+
144
+ def advance_ledger(
145
+ current: dict[str, str], ledger: dict[str, str], fired: list[Fired]
146
+ ) -> dict[str, str]:
147
+ """The ledger to persist: every need at its current state, except a need whose callback failed
148
+ — that one keeps its prior state so the next sync retries it."""
149
+ failed = {f.transition.need_id for f in fired if not f.ok}
150
+ new = dict(current)
151
+ for nid in failed:
152
+ if nid in ledger: # do not advance a need whose callback failed; retry next sync
153
+ new[nid] = ledger[nid]
154
+ return new
155
+
156
+
157
+ def _invoke(command: tuple[str, ...], payload: str, cwd: Path, timeout: float) -> tuple[bool, str]:
158
+ """Run ``command`` (no shell) with ``payload`` on stdin; report ok and a short reason.
159
+
160
+ Never raises: a spawn failure, a timeout, or a non-zero exit all come back as ``ok=False`` with
161
+ a reason, so a broken callback is reported, never silent and never fatal to the sync itself.
162
+ """
163
+ try:
164
+ proc = subprocess.run(
165
+ list(command),
166
+ input=payload,
167
+ cwd=cwd,
168
+ capture_output=True,
169
+ text=True,
170
+ timeout=timeout,
171
+ check=False,
172
+ )
173
+ except subprocess.TimeoutExpired:
174
+ return False, f"timed out after {timeout:g}s"
175
+ except OSError as exc:
176
+ return False, f"could not run ({exc})"
177
+ if proc.returncode == 0:
178
+ return True, "ok"
179
+ tail = (proc.stderr or proc.stdout or "").strip().splitlines()
180
+ return False, f"exit {proc.returncode}" + (f": {tail[-1]}" if tail else "")
181
+
182
+
183
+ def _need_json(need: Need, state: str) -> dict[str, object]:
184
+ strongest = need.strongest_kind
185
+ return {
186
+ "id": need.id,
187
+ "text": need.text,
188
+ "state": state,
189
+ "who": need.who,
190
+ "client": need.client,
191
+ "must": need.must,
192
+ "goal": need.goal,
193
+ "release": need.release,
194
+ "warrant": strongest.value if strongest is not None else "opinion",
195
+ }