prodc 0.3.76__tar.gz → 0.3.78__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.76 → prodc-0.3.78}/PKG-INFO +1 -1
- {prodc-0.3.76 → prodc-0.3.78}/pyproject.toml +1 -1
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/agent.md +1 -1
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/board.py +10 -2
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/app.py +14 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/commands.py +39 -6
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/render.py +21 -4
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/serialize.py +14 -3
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/config.py +3 -1
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/loader.py +15 -4
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/model.py +24 -4
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/status.py +18 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/writer.py +8 -3
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_importance.py +49 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_relink.py +1 -1
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_review.py +6 -2
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_source_role.py +1 -1
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_staleness.py +48 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_withdrawn.py +97 -1
- {prodc-0.3.76 → prodc-0.3.78}/.gitignore +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/CHANGELOG.md +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/LICENSE +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/README.md +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/__init__.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/adapters.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/dedup.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/py.typed +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/report.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/src/prodc/report_html.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_adapters.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_answer.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_callbacks.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_citation.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_cli.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_command_hook.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_dedup.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_description.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_drafts.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_evalgate.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_loader.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_models.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_needref.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_report.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_report_html.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_roles.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_sources.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_status.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_suggest.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_variants.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_who_variants.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/tests/test_writer.py +0 -0
- {prodc-0.3.76 → prodc-0.3.78}/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.78
|
|
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
|
|
@@ -79,7 +79,7 @@ Read first, then act, then check.
|
|
|
79
79
|
| link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
|
|
80
80
|
| record a human/AI review of the warrant | `prodc attest <id> --by <who> --date <ISO> [--ai]` |
|
|
81
81
|
| record a human acceptance pass | `prodc accept <id> --who <person> --date <ISO>` |
|
|
82
|
-
| decide against a need (won't-do) | `prodc withdraw <id> --reason "…"` — off the board + out of coverage; `prodc reinstate <id>` puts it back |
|
|
82
|
+
| decide against a need (won't-do) | `prodc withdraw <id> --reason "…" [--kind out_of_scope\|duplicate]` — off the board + out of coverage (use `out_of_scope` for a real need that isn't this product); `prodc reinstate <id>` puts it back |
|
|
83
83
|
| hand a human a readable status file | `prodc report -o status.md` (markdown; `--format html` for an interactive viewer; `--client ID` to scope) |
|
|
84
84
|
| 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) |
|
|
85
85
|
| gate in CI | `prodc lint --strict` |
|
|
@@ -102,7 +102,11 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
102
102
|
"claim": need.claim,
|
|
103
103
|
"answer": need.answer.value if need.answer else None,
|
|
104
104
|
"withdrawn": (
|
|
105
|
-
{
|
|
105
|
+
{
|
|
106
|
+
"reason": need.withdrawn.reason,
|
|
107
|
+
"date": need.withdrawn.date,
|
|
108
|
+
"kind": need.withdrawn.kind.value,
|
|
109
|
+
}
|
|
106
110
|
if need.withdrawn
|
|
107
111
|
else None
|
|
108
112
|
),
|
|
@@ -150,7 +154,11 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
150
154
|
"draft": need.draft,
|
|
151
155
|
"answer": need.answer.value if need.answer else None,
|
|
152
156
|
"withdrawn": (
|
|
153
|
-
{
|
|
157
|
+
{
|
|
158
|
+
"reason": need.withdrawn.reason,
|
|
159
|
+
"date": need.withdrawn.date,
|
|
160
|
+
"kind": need.withdrawn.kind.value,
|
|
161
|
+
}
|
|
154
162
|
if need.withdrawn
|
|
155
163
|
else None
|
|
156
164
|
),
|
|
@@ -133,6 +133,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
133
133
|
pl.add_argument(
|
|
134
134
|
"--include-drafts", action="store_true", help="include draft (staged) needs (#60)"
|
|
135
135
|
)
|
|
136
|
+
pl.add_argument(
|
|
137
|
+
"--revisit",
|
|
138
|
+
type=int,
|
|
139
|
+
default=None,
|
|
140
|
+
metavar="DAYS",
|
|
141
|
+
help="only needs whose importance is undated or set more than DAYS ago — a refresh pass (#59)",
|
|
142
|
+
)
|
|
136
143
|
pl.set_defaults(func=cmd_plan)
|
|
137
144
|
|
|
138
145
|
ar = sub.add_parser("argument", help="show the GSN assurance graph and its defects")
|
|
@@ -300,6 +307,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
300
307
|
wd.add_argument(
|
|
301
308
|
"--reason", required=True, help="why it was decided against (kept on the record)"
|
|
302
309
|
)
|
|
310
|
+
wd.add_argument(
|
|
311
|
+
"--kind",
|
|
312
|
+
choices=["wont_do", "out_of_scope", "duplicate"],
|
|
313
|
+
default="wont_do",
|
|
314
|
+
help="the decision's kind: wont_do (default), out_of_scope (real but not this product), "
|
|
315
|
+
"or duplicate (#74)",
|
|
316
|
+
)
|
|
303
317
|
wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
|
|
304
318
|
wd.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
305
319
|
wd.add_argument("--json", action="store_true", help="machine-readable output")
|
|
@@ -37,6 +37,7 @@ from ..status import (
|
|
|
37
37
|
applicable_needs,
|
|
38
38
|
argument_defects,
|
|
39
39
|
evidence_age_days,
|
|
40
|
+
importance_revisit_due,
|
|
40
41
|
proof_suggestions,
|
|
41
42
|
recurrence,
|
|
42
43
|
relink_suggestions,
|
|
@@ -263,6 +264,14 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
263
264
|
# orphans) until promoted with `set <id> draft=false`, unless --include-drafts is passed.
|
|
264
265
|
draft_count = sum(1 for n in needs if n.draft and not n.is_withdrawn)
|
|
265
266
|
withdrawn_count = sum(1 for n in needs if n.is_withdrawn)
|
|
267
|
+
# Break withdrawals down by kind (#74 follow-up), so "declined but valid" (out_of_scope) is
|
|
268
|
+
# legible on the board apart from plain won't-do; wont_do is the implicit default, not listed.
|
|
269
|
+
withdrawn_kinds: dict[str, int] = {}
|
|
270
|
+
for n in needs:
|
|
271
|
+
if n.withdrawn and n.withdrawn.kind.value != "wont_do":
|
|
272
|
+
withdrawn_kinds[n.withdrawn.kind.value] = (
|
|
273
|
+
withdrawn_kinds.get(n.withdrawn.kind.value, 0) + 1
|
|
274
|
+
)
|
|
266
275
|
needs = board_needs(needs, include_drafts=args.include_drafts)
|
|
267
276
|
# Scoped coverage (#49): restrict the board + coverage to a subset, so the number people
|
|
268
277
|
# act on (this client, this goal) isn't diluted by the whole mixed board.
|
|
@@ -318,7 +327,9 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
318
327
|
if draft_count and not args.include_drafts:
|
|
319
328
|
print(f"drafts: {draft_count} staged (hidden — --include-drafts to show) (#60)")
|
|
320
329
|
if withdrawn_count:
|
|
321
|
-
|
|
330
|
+
extra = ", ".join(f"{c} {k}" for k, c in sorted(withdrawn_kinds.items()))
|
|
331
|
+
suffix = f" — {extra}" if extra else ""
|
|
332
|
+
print(f"withdrawn: {withdrawn_count} (decided against){suffix} (#74)")
|
|
322
333
|
visible = [r for r in results if r.need.product]
|
|
323
334
|
if args.only_must:
|
|
324
335
|
visible = [r for r in visible if r.need.must]
|
|
@@ -374,7 +385,20 @@ def cmd_brief(args: argparse.Namespace) -> int:
|
|
|
374
385
|
return 2
|
|
375
386
|
scoped = applicable_needs(needs, args.client)
|
|
376
387
|
results = resolve(scoped, cfg.adapters, cfg.source_adapters)
|
|
377
|
-
|
|
388
|
+
# "Confirm still wanted" (#58): needs whose evidence is older than the stale threshold, to
|
|
389
|
+
# re-check with the client in the meeting. Only when [lint].stale_after_days is configured.
|
|
390
|
+
stale: list[tuple[NeedResult, int]] = []
|
|
391
|
+
if cfg.stale_after_days is not None:
|
|
392
|
+
today = date.today()
|
|
393
|
+
for r in results:
|
|
394
|
+
age = evidence_age_days(r.need, today)
|
|
395
|
+
if age is not None and age > cfg.stale_after_days:
|
|
396
|
+
stale.append((r, age))
|
|
397
|
+
print(
|
|
398
|
+
brief_json(args.client, results, stale)
|
|
399
|
+
if args.json
|
|
400
|
+
else render_brief(args.client, results, stale)
|
|
401
|
+
)
|
|
378
402
|
return 0
|
|
379
403
|
|
|
380
404
|
|
|
@@ -387,10 +411,14 @@ def cmd_plan(args: argparse.Namespace) -> int:
|
|
|
387
411
|
needs = board_needs(list(model.needs), include_drafts=args.include_drafts) # drafts off (#60)
|
|
388
412
|
results = resolve(needs, cfg.adapters, cfg.source_adapters)
|
|
389
413
|
recurrence_counts = {rec.baseline.id: rec.count for rec in recurrence(needs)}
|
|
414
|
+
revisit = args.revisit is not None
|
|
415
|
+
if revisit:
|
|
416
|
+
today = date.today()
|
|
417
|
+
results = [r for r in results if importance_revisit_due(r.need, today, args.revisit)]
|
|
390
418
|
print(
|
|
391
|
-
plan_json(results, recurrence_counts)
|
|
419
|
+
plan_json(results, recurrence_counts, revisit=revisit)
|
|
392
420
|
if args.json
|
|
393
|
-
else render_plan(results, recurrence_counts)
|
|
421
|
+
else render_plan(results, recurrence_counts, revisit=revisit)
|
|
394
422
|
)
|
|
395
423
|
return 0
|
|
396
424
|
|
|
@@ -549,6 +577,7 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
|
|
|
549
577
|
args.need_id,
|
|
550
578
|
reason=args.reason,
|
|
551
579
|
date=args.date or date.today().isoformat(),
|
|
580
|
+
kind=args.kind,
|
|
552
581
|
expect_sha=args.expect_sha,
|
|
553
582
|
dry_run=args.dry_run,
|
|
554
583
|
)
|
|
@@ -1079,7 +1108,9 @@ def _cmd_relink(cfg: Config, results: list[NeedResult], args: argparse.Namespace
|
|
|
1079
1108
|
print(f"{s.need.id}: {s.need.text}")
|
|
1080
1109
|
last = s.need.id
|
|
1081
1110
|
print(f" {s.score:.2f} {s.broken} → {s.locator}")
|
|
1082
|
-
print(
|
|
1111
|
+
print(
|
|
1112
|
+
f"suggest --relink: {len(suggestions)} candidate(s) at min-score {args.min_score:.2f}"
|
|
1113
|
+
)
|
|
1083
1114
|
return 0
|
|
1084
1115
|
|
|
1085
1116
|
|
|
@@ -1125,7 +1156,9 @@ def _apply_relinks(
|
|
|
1125
1156
|
else:
|
|
1126
1157
|
verb = "would relink" if args.dry_run else "relinked"
|
|
1127
1158
|
for s, edit in applied:
|
|
1128
|
-
print(
|
|
1159
|
+
print(
|
|
1160
|
+
f"{verb} {s.broken} → {s.locator} on {s.need.id} ({s.score:.2f}) in {edit.path.name}"
|
|
1161
|
+
)
|
|
1129
1162
|
print(edit.diff, end="")
|
|
1130
1163
|
print(f"suggest --relink --apply: {verb} {len(applied)} locator(s)")
|
|
1131
1164
|
return 0
|
|
@@ -276,7 +276,9 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
276
276
|
if need.answer:
|
|
277
277
|
lines.append(f" answer: {need.answer.value}")
|
|
278
278
|
if need.withdrawn:
|
|
279
|
-
|
|
279
|
+
w = need.withdrawn
|
|
280
|
+
tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}]"
|
|
281
|
+
lines.append(f" withdrawn{tag}: {w.reason} ({w.date})")
|
|
280
282
|
if need.description:
|
|
281
283
|
lines.append(f" description: {need.description}")
|
|
282
284
|
if need.importance is not None:
|
|
@@ -329,7 +331,11 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
329
331
|
return "\n".join(lines)
|
|
330
332
|
|
|
331
333
|
|
|
332
|
-
def render_brief(
|
|
334
|
+
def render_brief(
|
|
335
|
+
client: str,
|
|
336
|
+
results: list[NeedResult],
|
|
337
|
+
stale: list[tuple[NeedResult, int]] | None = None,
|
|
338
|
+
) -> str:
|
|
333
339
|
open_ = brief_open(results)
|
|
334
340
|
out = [f"brief — {client}: {len(open_)} open question{'s' if len(open_) != 1 else ''}"]
|
|
335
341
|
if not open_:
|
|
@@ -345,6 +351,10 @@ def render_brief(client: str, results: list[NeedResult]) -> str:
|
|
|
345
351
|
if g
|
|
346
352
|
)
|
|
347
353
|
out.append(f" {bang} {r.need.id:<12} [{gaps}] {r.need.text}")
|
|
354
|
+
if stale:
|
|
355
|
+
out.append(f" confirm still wanted ({len(stale)} with evidence past the stale threshold):")
|
|
356
|
+
for r, age in sorted(stale, key=lambda t: (-t[1], t[0].need.id)):
|
|
357
|
+
out.append(f" ~ {r.need.id:<12} {age}d old {r.need.text}")
|
|
348
358
|
return "\n".join(out)
|
|
349
359
|
|
|
350
360
|
|
|
@@ -357,9 +367,16 @@ def _importance_cell(need: Need) -> str:
|
|
|
357
367
|
return f"imp={need.importance}{' (guess)' if weak else ''}"
|
|
358
368
|
|
|
359
369
|
|
|
360
|
-
def render_plan(
|
|
370
|
+
def render_plan(
|
|
371
|
+
results: list[NeedResult], recurrence_counts: dict[str, int], *, revisit: bool = False
|
|
372
|
+
) -> str:
|
|
361
373
|
ranked = rank(results, recurrence_counts)
|
|
362
|
-
|
|
374
|
+
header = (
|
|
375
|
+
f"plan — revisit: {len(ranked)} need(s) with undated or stale importance (#59)"
|
|
376
|
+
if revisit
|
|
377
|
+
else "plan — ranked backlog (must + state + evidence + recurrence, importance refines)"
|
|
378
|
+
)
|
|
379
|
+
out = [header]
|
|
363
380
|
for i, r in enumerate(ranked, 1):
|
|
364
381
|
bang = "!!" if r.need.must else " "
|
|
365
382
|
imp = _importance_cell(r.need)
|
|
@@ -130,7 +130,11 @@ def show_json(r: NeedResult) -> str:
|
|
|
130
130
|
return json.dumps(payload, indent=2)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
def brief_json(
|
|
133
|
+
def brief_json(
|
|
134
|
+
client: str,
|
|
135
|
+
results: list[NeedResult],
|
|
136
|
+
stale: list[tuple[NeedResult, int]] | None = None,
|
|
137
|
+
) -> str:
|
|
134
138
|
open_ = brief_open(results)
|
|
135
139
|
return json.dumps(
|
|
136
140
|
{
|
|
@@ -146,15 +150,22 @@ def brief_json(client: str, results: list[NeedResult]) -> str:
|
|
|
146
150
|
}
|
|
147
151
|
for r in open_
|
|
148
152
|
],
|
|
153
|
+
"confirm_still_wanted": [
|
|
154
|
+
{"id": r.need.id, "text": r.need.text, "evidence_age": age}
|
|
155
|
+
for r, age in sorted(stale or [], key=lambda t: (-t[1], t[0].need.id))
|
|
156
|
+
],
|
|
149
157
|
},
|
|
150
158
|
indent=2,
|
|
151
159
|
)
|
|
152
160
|
|
|
153
161
|
|
|
154
|
-
def plan_json(
|
|
162
|
+
def plan_json(
|
|
163
|
+
results: list[NeedResult], recurrence_counts: dict[str, int], *, revisit: bool = False
|
|
164
|
+
) -> str:
|
|
155
165
|
ranked = rank(results, recurrence_counts)
|
|
156
166
|
return json.dumps(
|
|
157
167
|
{
|
|
168
|
+
"revisit": revisit,
|
|
158
169
|
"plan": [
|
|
159
170
|
{
|
|
160
171
|
"rank": i,
|
|
@@ -168,7 +179,7 @@ def plan_json(results: list[NeedResult], recurrence_counts: dict[str, int]) -> s
|
|
|
168
179
|
"importance_is_guess": (r.need.importance is not None and r.need.is_opinion),
|
|
169
180
|
}
|
|
170
181
|
for i, r in enumerate(ranked, 1)
|
|
171
|
-
]
|
|
182
|
+
],
|
|
172
183
|
},
|
|
173
184
|
indent=2,
|
|
174
185
|
)
|
|
@@ -272,7 +272,9 @@ class Config:
|
|
|
272
272
|
raise ConfigError("[lint].importance_revisit_days must be a positive integer")
|
|
273
273
|
draft_max_age = lint_tbl.get("draft_max_age_days")
|
|
274
274
|
if draft_max_age is not None and (
|
|
275
|
-
not isinstance(draft_max_age, int)
|
|
275
|
+
not isinstance(draft_max_age, int)
|
|
276
|
+
or isinstance(draft_max_age, bool)
|
|
277
|
+
or draft_max_age < 1
|
|
276
278
|
):
|
|
277
279
|
raise ConfigError("[lint].draft_max_age_days must be a positive integer")
|
|
278
280
|
|
|
@@ -30,6 +30,7 @@ from .model import (
|
|
|
30
30
|
Source,
|
|
31
31
|
SourceKind,
|
|
32
32
|
Withdrawal,
|
|
33
|
+
WithdrawalKind,
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
|
|
@@ -130,10 +131,12 @@ def _answer(raw: object, where: str) -> Answer | None:
|
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
133
|
-
"""Parse ``withdrawn: {reason, date}`` into a :class:`Withdrawal` (#74).
|
|
134
|
+
"""Parse ``withdrawn: {reason, date, kind?}`` into a :class:`Withdrawal` (#74).
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
and when, the same way an attestation needs who and when.
|
|
136
|
+
``reason`` and ``date`` are required when the key is present — a decision against a need must
|
|
137
|
+
say why and when, the same way an attestation needs who and when. ``kind`` is optional and
|
|
138
|
+
defaults to ``wont_do``; it is validated with the same ``Enum(name) except ValueError`` pattern
|
|
139
|
+
as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns.
|
|
137
140
|
"""
|
|
138
141
|
if raw is None:
|
|
139
142
|
return None
|
|
@@ -142,7 +145,15 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
|
142
145
|
date = _opt_str(tbl.get("date"), f"{where}.date")
|
|
143
146
|
if not reason or not date:
|
|
144
147
|
raise LoaderError(f"{where}: a withdrawal needs both 'reason' and 'date'")
|
|
145
|
-
|
|
148
|
+
kind_name = _opt_str(tbl.get("kind"), f"{where}.kind") or WithdrawalKind.WONT_DO.value
|
|
149
|
+
try:
|
|
150
|
+
kind = WithdrawalKind(kind_name)
|
|
151
|
+
except ValueError:
|
|
152
|
+
kinds = "|".join(k.value for k in WithdrawalKind)
|
|
153
|
+
raise LoaderError(
|
|
154
|
+
f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})"
|
|
155
|
+
) from None
|
|
156
|
+
return Withdrawal(reason=reason, date=date, kind=kind)
|
|
146
157
|
|
|
147
158
|
|
|
148
159
|
# Old source-kind names (pre-0.3.58) mapped to the primary/secondary/assumption ladder, so
|
|
@@ -74,7 +74,9 @@ class Source:
|
|
|
74
74
|
|
|
75
75
|
kind: SourceKind
|
|
76
76
|
who: str | None = None
|
|
77
|
-
role: str | None =
|
|
77
|
+
role: str | None = (
|
|
78
|
+
None # id of the Role the `who` spoke as — whose signal this is (#52 follow-up)
|
|
79
|
+
)
|
|
78
80
|
date: str | None = None
|
|
79
81
|
quote: str | None = None
|
|
80
82
|
locator: str | None = None
|
|
@@ -161,17 +163,33 @@ class Attestation:
|
|
|
161
163
|
comment: str | None = None # the review's feedback / rationale, kept on the record
|
|
162
164
|
|
|
163
165
|
|
|
166
|
+
class WithdrawalKind(StrEnum):
|
|
167
|
+
"""Why a need is off the board — the decision's *kind*, as data, not only free-text reason.
|
|
168
|
+
|
|
169
|
+
All three take the need off the active board and out of coverage identically; the kind records
|
|
170
|
+
*why* so the board can tell a validated-but-off-strategy need apart from one killed as not
|
|
171
|
+
worth doing. ``OUT_OF_SCOPE`` is the case a real, client-evidenced need is declined because it
|
|
172
|
+
is not the product being built — distinct from ``WONT_DO`` (decided against on its merits) and
|
|
173
|
+
``DUPLICATE`` (the same want is tracked under another need).
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
WONT_DO = "wont_do" # decided against on its merits — the default, #74's original meaning
|
|
177
|
+
OUT_OF_SCOPE = "out_of_scope" # real and valid, but not this product
|
|
178
|
+
DUPLICATE = "duplicate" # the same want is tracked elsewhere
|
|
179
|
+
|
|
180
|
+
|
|
164
181
|
@dataclass(frozen=True)
|
|
165
182
|
class Withdrawal:
|
|
166
183
|
"""A decision not to pursue a need — withdrawn / won't-do (#74).
|
|
167
184
|
|
|
168
185
|
Unlike a draft (staged, not yet decided), a withdrawal is a decision *against*: the need is
|
|
169
|
-
taken off the active board and out of coverage, with its ``reason`` and ``
|
|
170
|
-
record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
|
|
186
|
+
taken off the active board and out of coverage, with its ``reason``, ``date`` and ``kind`` kept
|
|
187
|
+
on the record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
|
|
171
188
|
"""
|
|
172
189
|
|
|
173
190
|
reason: str
|
|
174
191
|
date: str
|
|
192
|
+
kind: WithdrawalKind = WithdrawalKind.WONT_DO
|
|
175
193
|
|
|
176
194
|
|
|
177
195
|
@dataclass(frozen=True)
|
|
@@ -199,7 +217,9 @@ class Need:
|
|
|
199
217
|
release: str | None = None # id of the Release slice this need ships in; None = unslotted
|
|
200
218
|
product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
|
|
201
219
|
draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
|
|
202
|
-
draft_added: str | None =
|
|
220
|
+
draft_added: str | None = (
|
|
221
|
+
None # ISO date this need was staged (#60); lets a lint age-gate it later
|
|
222
|
+
)
|
|
203
223
|
withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
|
|
204
224
|
answer: Answer | None = (
|
|
205
225
|
None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
|
|
@@ -78,6 +78,24 @@ def evidence_age_days(need: Need, today: date) -> int | None:
|
|
|
78
78
|
return (today - max(dates)).days if dates else None
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def importance_revisit_due(need: Need, today: date, max_age_days: int) -> bool:
|
|
82
|
+
"""Whether a need's importance should be revisited — for ``plan --revisit`` (#59).
|
|
83
|
+
|
|
84
|
+
Due when the need has an importance set but it is either never dated (no ``importance_set_at``)
|
|
85
|
+
or last set more than ``max_age_days`` ago. A need with no importance is never due — there is
|
|
86
|
+
nothing to revisit. A malformed date is treated as not-due (the lint warning owns that defect).
|
|
87
|
+
"""
|
|
88
|
+
if need.importance is None:
|
|
89
|
+
return False
|
|
90
|
+
when = need.importance_set_at
|
|
91
|
+
if when is None:
|
|
92
|
+
return True
|
|
93
|
+
try:
|
|
94
|
+
return (today - date.fromisoformat(when)).days > max_age_days
|
|
95
|
+
except ValueError:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
81
99
|
def _resolve_proof(locator: str, adapters: dict[str, Adapter]) -> ProofResult:
|
|
82
100
|
name, sep, payload = locator.partition(":")
|
|
83
101
|
adapter = adapters.get(name)
|
|
@@ -474,16 +474,21 @@ def withdraw(
|
|
|
474
474
|
*,
|
|
475
475
|
reason: str,
|
|
476
476
|
date: str,
|
|
477
|
+
kind: str = "wont_do",
|
|
477
478
|
expect_sha: str | None,
|
|
478
479
|
dry_run: bool,
|
|
479
480
|
) -> Edit:
|
|
480
481
|
"""Record a decision against a need — withdrawn / won't-do (#74).
|
|
481
482
|
|
|
482
|
-
Writes a ``withdrawn: {reason, date}`` mapping onto the need, which takes it off the
|
|
483
|
-
board and out of coverage; the record stays as its durable trace.
|
|
484
|
-
|
|
483
|
+
Writes a ``withdrawn: {reason, date, kind?}`` mapping onto the need, which takes it off the
|
|
484
|
+
active board and out of coverage; the record stays as its durable trace. ``kind`` says *why*
|
|
485
|
+
(``wont_do`` default, ``out_of_scope``, ``duplicate``) and is written only when it is not the
|
|
486
|
+
default, to keep the common case clean. Replaces any existing withdrawal (re-withdrawing
|
|
487
|
+
updates it). Reinstate with :func:`reinstate`.
|
|
485
488
|
"""
|
|
486
489
|
mark: dict[str, object] = {"reason": reason, "date": date}
|
|
490
|
+
if kind != "wont_do":
|
|
491
|
+
mark["kind"] = kind
|
|
487
492
|
path, _ = _locate(config, need_id)
|
|
488
493
|
doc, old_text = _read_doc(path)
|
|
489
494
|
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
@@ -164,3 +164,52 @@ needs:
|
|
|
164
164
|
payload = json.loads(capsys.readouterr().out)
|
|
165
165
|
n1 = next(n for n in payload["needs"] if n["id"] == "N-1")
|
|
166
166
|
assert n1["importance_set_at"] == "2026-10-01"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- plan --revisit (#59 follow-up) -----------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_plan_revisit_filters_to_undated_and_stale(
|
|
173
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
174
|
+
) -> None:
|
|
175
|
+
from prodc.cli import main
|
|
176
|
+
|
|
177
|
+
old = (date.today() - timedelta(days=400)).isoformat()
|
|
178
|
+
recent = (date.today() - timedelta(days=5)).isoformat()
|
|
179
|
+
needs = f"""
|
|
180
|
+
needs:
|
|
181
|
+
- id: UNDATED
|
|
182
|
+
text: importance set, never dated
|
|
183
|
+
importance: 2
|
|
184
|
+
- id: STALE
|
|
185
|
+
text: importance set long ago
|
|
186
|
+
importance: 3
|
|
187
|
+
importance_set_at: {old}
|
|
188
|
+
- id: FRESH
|
|
189
|
+
text: importance set recently
|
|
190
|
+
importance: 1
|
|
191
|
+
importance_set_at: {recent}
|
|
192
|
+
- id: NONE
|
|
193
|
+
text: no importance at all
|
|
194
|
+
"""
|
|
195
|
+
main(["plan", "--revisit", "30", "--config", str(_project(tmp_path, _TOML, needs)), "--json"])
|
|
196
|
+
payload = json.loads(capsys.readouterr().out)
|
|
197
|
+
assert payload["revisit"] is True
|
|
198
|
+
assert {p["id"] for p in payload["plan"]} == {"UNDATED", "STALE"}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_plan_without_revisit_lists_all(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
202
|
+
from prodc.cli import main
|
|
203
|
+
|
|
204
|
+
needs = """
|
|
205
|
+
needs:
|
|
206
|
+
- id: N-1
|
|
207
|
+
text: a thing
|
|
208
|
+
importance: 2
|
|
209
|
+
- id: N-2
|
|
210
|
+
text: another thing
|
|
211
|
+
"""
|
|
212
|
+
main(["plan", "--config", str(_project(tmp_path, _TOML, needs)), "--json"])
|
|
213
|
+
payload = json.loads(capsys.readouterr().out)
|
|
214
|
+
assert payload["revisit"] is False
|
|
215
|
+
assert {p["id"] for p in payload["plan"]} == {"N-1", "N-2"}
|
|
@@ -95,7 +95,7 @@ def test_relink_skips_when_adapter_absent(
|
|
|
95
95
|
) -> None:
|
|
96
96
|
# A locator whose adapter does not exist is BROKEN but has no inventory to relink against.
|
|
97
97
|
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
98
|
-
(tmp_path / "prodc.toml").write_text(
|
|
98
|
+
(tmp_path / "prodc.toml").write_text('[prodc]\nneeds = ["needs/*.yaml"]\n', encoding="utf-8")
|
|
99
99
|
(tmp_path / "needs" / "a.yaml").write_text(
|
|
100
100
|
"needs:\n - id: N-1\n text: t\n proofs: ['ghost:1']\n", encoding="utf-8"
|
|
101
101
|
)
|
|
@@ -58,7 +58,9 @@ def test_review_list_carries_staged_date_and_dup(
|
|
|
58
58
|
assert rows["D-2"]["duplicate"] is None
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def test_review_promote_sets_draft_false(
|
|
61
|
+
def test_review_promote_sets_draft_false(
|
|
62
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
63
|
+
) -> None:
|
|
62
64
|
cfg = _project(tmp_path)
|
|
63
65
|
code = main(["review", "--promote", "D-1", "--config", str(cfg)])
|
|
64
66
|
assert code == 0
|
|
@@ -113,7 +115,9 @@ def test_review_dry_run_writes_nothing(tmp_path: Path, capsys: pytest.CaptureFix
|
|
|
113
115
|
assert "dry-run" in capsys.readouterr().out
|
|
114
116
|
|
|
115
117
|
|
|
116
|
-
def test_review_preserves_header_comment(
|
|
118
|
+
def test_review_preserves_header_comment(
|
|
119
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
120
|
+
) -> None:
|
|
117
121
|
cfg = _project(tmp_path)
|
|
118
122
|
main(["review", "--drop", "D-2", "--config", str(cfg)])
|
|
119
123
|
capsys.readouterr()
|
|
@@ -13,7 +13,7 @@ from prodc.loader import LoaderError, load
|
|
|
13
13
|
from prodc.model import Need, Source, SourceKind
|
|
14
14
|
from prodc.writer import capture_source
|
|
15
15
|
|
|
16
|
-
_TOML =
|
|
16
|
+
_TOML = '[prodc]\nneeds = ["needs/*.yaml"]\n'
|
|
17
17
|
|
|
18
18
|
_NEEDS = """
|
|
19
19
|
roles:
|
|
@@ -152,3 +152,51 @@ needs:
|
|
|
152
152
|
assert code == 0
|
|
153
153
|
written = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
154
154
|
assert "observed_at:" in written and "2026-10-05" in written
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- brief "confirm still wanted" (#58 follow-up) ---------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _client_needs() -> str:
|
|
161
|
+
old = (date.today() - timedelta(days=400)).isoformat()
|
|
162
|
+
fresh = date.today().isoformat()
|
|
163
|
+
return f"""
|
|
164
|
+
needs:
|
|
165
|
+
- id: OLD-1
|
|
166
|
+
text: acme wants the old thing
|
|
167
|
+
client: acme
|
|
168
|
+
sources:
|
|
169
|
+
- kind: primary
|
|
170
|
+
who: Ana
|
|
171
|
+
quote: "I need this"
|
|
172
|
+
observed_at: {old}
|
|
173
|
+
- id: FRESH-1
|
|
174
|
+
text: acme wants the fresh thing
|
|
175
|
+
client: acme
|
|
176
|
+
sources:
|
|
177
|
+
- kind: primary
|
|
178
|
+
who: Bob
|
|
179
|
+
quote: "me too"
|
|
180
|
+
observed_at: {fresh}
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_brief_confirm_still_wanted(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
185
|
+
from prodc.cli import main
|
|
186
|
+
|
|
187
|
+
toml = _TOML + "\n[lint]\nstale_after_days = 180\n"
|
|
188
|
+
main(["brief", "acme", "--config", str(_project(tmp_path, toml, _client_needs())), "--json"])
|
|
189
|
+
payload = json.loads(capsys.readouterr().out)
|
|
190
|
+
stale = {s["id"] for s in payload["confirm_still_wanted"]}
|
|
191
|
+
assert stale == {"OLD-1"} # only the >180d evidence is up for re-confirmation
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_brief_no_confirm_section_without_knob(
|
|
195
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
196
|
+
) -> None:
|
|
197
|
+
from prodc.cli import main
|
|
198
|
+
|
|
199
|
+
# no [lint].stale_after_days configured -> nothing to re-confirm
|
|
200
|
+
main(["brief", "acme", "--config", str(_project(tmp_path, _TOML, _client_needs())), "--json"])
|
|
201
|
+
payload = json.loads(capsys.readouterr().out)
|
|
202
|
+
assert payload["confirm_still_wanted"] == []
|
|
@@ -242,7 +242,11 @@ def test_show_json_carries_withdrawn(tmp_path: Path, capsys: pytest.CaptureFixtu
|
|
|
242
242
|
|
|
243
243
|
main(["show", "W-1", "--config", str(_project(tmp_path)), "--json"])
|
|
244
244
|
payload = json.loads(capsys.readouterr().out)
|
|
245
|
-
assert payload["withdrawn"] == {
|
|
245
|
+
assert payload["withdrawn"] == {
|
|
246
|
+
"reason": "superseded by N-1",
|
|
247
|
+
"date": "2026-10-10",
|
|
248
|
+
"kind": "wont_do", # default when the YAML omits kind
|
|
249
|
+
}
|
|
246
250
|
|
|
247
251
|
|
|
248
252
|
def test_status_json_live_need_has_null_withdrawn(
|
|
@@ -254,3 +258,95 @@ def test_status_json_live_need_has_null_withdrawn(
|
|
|
254
258
|
payload = json.loads(capsys.readouterr().out)
|
|
255
259
|
n1 = next(n for n in payload["needs"] if n["id"] == "N-1")
|
|
256
260
|
assert n1["withdrawn"] is None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --- withdrawal kind (#74 follow-up) ----------------------------------------
|
|
264
|
+
|
|
265
|
+
_KIND_NEEDS = """
|
|
266
|
+
needs:
|
|
267
|
+
- id: N-1
|
|
268
|
+
text: user can see a delivered thing
|
|
269
|
+
proofs: ["x:1"]
|
|
270
|
+
- id: OOS-1
|
|
271
|
+
text: a real but off-strategy need
|
|
272
|
+
withdrawn:
|
|
273
|
+
reason: not the product we build
|
|
274
|
+
date: 2026-10-10
|
|
275
|
+
kind: out_of_scope
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_loader_parses_withdrawal_kind(tmp_path: Path) -> None:
|
|
280
|
+
from prodc.model import WithdrawalKind
|
|
281
|
+
|
|
282
|
+
cfg = Config.load(_project(tmp_path, needs=_KIND_NEEDS))
|
|
283
|
+
by_id = {n.id: n for n in load(cfg).needs}
|
|
284
|
+
assert by_id["OOS-1"].withdrawn is not None
|
|
285
|
+
assert by_id["OOS-1"].withdrawn.kind is WithdrawalKind.OUT_OF_SCOPE
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_withdrawal_kind_defaults_wont_do(tmp_path: Path) -> None:
|
|
289
|
+
from prodc.model import WithdrawalKind
|
|
290
|
+
|
|
291
|
+
cfg = Config.load(_project(tmp_path)) # W-1 omits kind
|
|
292
|
+
by_id = {n.id: n for n in load(cfg).needs}
|
|
293
|
+
w = by_id["W-1"].withdrawn
|
|
294
|
+
assert w is not None and w.kind is WithdrawalKind.WONT_DO
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_unknown_withdrawal_kind_rejected(tmp_path: Path) -> None:
|
|
298
|
+
needs = _KIND_NEEDS.replace("kind: out_of_scope", "kind: bogus")
|
|
299
|
+
with pytest.raises(LoaderError, match="unknown withdrawal kind 'bogus'"):
|
|
300
|
+
load(Config.load(_project(tmp_path, needs=needs)))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_withdraw_writes_non_default_kind(tmp_path: Path) -> None:
|
|
304
|
+
from prodc.cli import main
|
|
305
|
+
from prodc.model import WithdrawalKind
|
|
306
|
+
|
|
307
|
+
cfg = _project(tmp_path)
|
|
308
|
+
code = main(
|
|
309
|
+
[
|
|
310
|
+
"withdraw",
|
|
311
|
+
"N-1",
|
|
312
|
+
"--reason",
|
|
313
|
+
"off strategy",
|
|
314
|
+
"--kind",
|
|
315
|
+
"out_of_scope",
|
|
316
|
+
"--date",
|
|
317
|
+
"2026-10-15",
|
|
318
|
+
"--config",
|
|
319
|
+
str(cfg),
|
|
320
|
+
]
|
|
321
|
+
)
|
|
322
|
+
assert code == 0
|
|
323
|
+
by_id = {n.id: n for n in load(Config.load(cfg)).needs}
|
|
324
|
+
w = by_id["N-1"].withdrawn
|
|
325
|
+
assert w is not None and w.kind is WithdrawalKind.OUT_OF_SCOPE
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_withdraw_omits_default_kind_in_yaml(tmp_path: Path) -> None:
|
|
329
|
+
from prodc.cli import main
|
|
330
|
+
|
|
331
|
+
cfg = _project(tmp_path)
|
|
332
|
+
main(["withdraw", "N-1", "--reason", "nope", "--date", "2026-10-15", "--config", str(cfg)])
|
|
333
|
+
text = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
334
|
+
assert "kind:" not in text # wont_do is the default; not written, keeps files clean
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_status_breaks_down_withdrawn_by_kind(
|
|
338
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
339
|
+
) -> None:
|
|
340
|
+
from prodc.cli import main
|
|
341
|
+
|
|
342
|
+
main(["status", "--config", str(_project(tmp_path, needs=_KIND_NEEDS))])
|
|
343
|
+
out = capsys.readouterr().out
|
|
344
|
+
assert "withdrawn: 1 (decided against) — 1 out_of_scope" in out
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_show_tags_non_default_kind(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
348
|
+
from prodc.cli import main
|
|
349
|
+
|
|
350
|
+
main(["show", "OOS-1", "--config", str(_project(tmp_path, needs=_KIND_NEEDS))])
|
|
351
|
+
out = capsys.readouterr().out
|
|
352
|
+
assert "withdrawn [out_of_scope]: not the product we build (2026-10-10)" in out
|
|
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
|