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