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.
Files changed (61) hide show
  1. {prodc-0.3.76 → prodc-0.3.78}/PKG-INFO +1 -1
  2. {prodc-0.3.76 → prodc-0.3.78}/pyproject.toml +1 -1
  3. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/agent.md +1 -1
  4. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/board.py +10 -2
  5. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/app.py +14 -0
  6. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/commands.py +39 -6
  7. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/render.py +21 -4
  8. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/serialize.py +14 -3
  9. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/config.py +3 -1
  10. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/loader.py +15 -4
  11. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/model.py +24 -4
  12. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/status.py +18 -0
  13. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/writer.py +8 -3
  14. {prodc-0.3.76 → prodc-0.3.78}/tests/test_importance.py +49 -0
  15. {prodc-0.3.76 → prodc-0.3.78}/tests/test_relink.py +1 -1
  16. {prodc-0.3.76 → prodc-0.3.78}/tests/test_review.py +6 -2
  17. {prodc-0.3.76 → prodc-0.3.78}/tests/test_source_role.py +1 -1
  18. {prodc-0.3.76 → prodc-0.3.78}/tests/test_staleness.py +48 -0
  19. {prodc-0.3.76 → prodc-0.3.78}/tests/test_withdrawn.py +97 -1
  20. {prodc-0.3.76 → prodc-0.3.78}/.gitignore +0 -0
  21. {prodc-0.3.76 → prodc-0.3.78}/CHANGELOG.md +0 -0
  22. {prodc-0.3.76 → prodc-0.3.78}/LICENSE +0 -0
  23. {prodc-0.3.76 → prodc-0.3.78}/README.md +0 -0
  24. {prodc-0.3.76 → prodc-0.3.78}/examples/quickstart/features/orders.feature +0 -0
  25. {prodc-0.3.76 → prodc-0.3.78}/examples/quickstart/needs/jordan.yaml +0 -0
  26. {prodc-0.3.76 → prodc-0.3.78}/examples/quickstart/prodc.toml +0 -0
  27. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/__init__.py +0 -0
  28. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/adapters.py +0 -0
  29. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/callbacks.py +0 -0
  30. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/__init__.py +0 -0
  31. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/cli/common.py +0 -0
  32. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/dedup.py +0 -0
  33. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/py.typed +0 -0
  34. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/report.py +0 -0
  35. {prodc-0.3.76 → prodc-0.3.78}/src/prodc/report_html.py +0 -0
  36. {prodc-0.3.76 → prodc-0.3.78}/tests/test_adapters.py +0 -0
  37. {prodc-0.3.76 → prodc-0.3.78}/tests/test_answer.py +0 -0
  38. {prodc-0.3.76 → prodc-0.3.78}/tests/test_callbacks.py +0 -0
  39. {prodc-0.3.76 → prodc-0.3.78}/tests/test_citation.py +0 -0
  40. {prodc-0.3.76 → prodc-0.3.78}/tests/test_claim_gating.py +0 -0
  41. {prodc-0.3.76 → prodc-0.3.78}/tests/test_cli.py +0 -0
  42. {prodc-0.3.76 → prodc-0.3.78}/tests/test_command_hook.py +0 -0
  43. {prodc-0.3.76 → prodc-0.3.78}/tests/test_dedup.py +0 -0
  44. {prodc-0.3.76 → prodc-0.3.78}/tests/test_description.py +0 -0
  45. {prodc-0.3.76 → prodc-0.3.78}/tests/test_drafts.py +0 -0
  46. {prodc-0.3.76 → prodc-0.3.78}/tests/test_evalgate.py +0 -0
  47. {prodc-0.3.76 → prodc-0.3.78}/tests/test_external_adapter.py +0 -0
  48. {prodc-0.3.76 → prodc-0.3.78}/tests/test_gitlab_source.py +0 -0
  49. {prodc-0.3.76 → prodc-0.3.78}/tests/test_loader.py +0 -0
  50. {prodc-0.3.76 → prodc-0.3.78}/tests/test_models.py +0 -0
  51. {prodc-0.3.76 → prodc-0.3.78}/tests/test_needref.py +0 -0
  52. {prodc-0.3.76 → prodc-0.3.78}/tests/test_report.py +0 -0
  53. {prodc-0.3.76 → prodc-0.3.78}/tests/test_report_html.py +0 -0
  54. {prodc-0.3.76 → prodc-0.3.78}/tests/test_roles.py +0 -0
  55. {prodc-0.3.76 → prodc-0.3.78}/tests/test_sources.py +0 -0
  56. {prodc-0.3.76 → prodc-0.3.78}/tests/test_status.py +0 -0
  57. {prodc-0.3.76 → prodc-0.3.78}/tests/test_suggest.py +0 -0
  58. {prodc-0.3.76 → prodc-0.3.78}/tests/test_variants.py +0 -0
  59. {prodc-0.3.76 → prodc-0.3.78}/tests/test_who_variants.py +0 -0
  60. {prodc-0.3.76 → prodc-0.3.78}/tests/test_writer.py +0 -0
  61. {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.76
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.76"
3
+ version = "0.3.78"
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"
@@ -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
- {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
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
- {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
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
- print(f"withdrawn: {withdrawn_count} (decided against) (#74)")
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
- print(brief_json(args.client, results) if args.json else render_brief(args.client, results))
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(f"suggest --relink: {len(suggestions)} candidate(s) at min-score {args.min_score:.2f}")
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(f"{verb} {s.broken} → {s.locator} on {s.need.id} ({s.score:.2f}) in {edit.path.name}")
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
- lines.append(f" withdrawn: {need.withdrawn.reason} ({need.withdrawn.date})")
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(client: str, results: list[NeedResult]) -> str:
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(results: list[NeedResult], recurrence_counts: dict[str, int]) -> str:
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
- out = ["plan — ranked backlog (must + state + evidence + recurrence, importance refines)"]
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(client: str, results: list[NeedResult]) -> str:
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(results: list[NeedResult], recurrence_counts: dict[str, int]) -> str:
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) or isinstance(draft_max_age, bool) or draft_max_age < 1
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
- 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.
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
- return Withdrawal(reason=reason, date=date)
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 = None # id of the Role the `who` spoke as — whose signal this is (#52 follow-up)
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 ``date`` kept on the
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 = None # ISO date this need was staged (#60); lets a lint age-gate it later
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 active
483
- board and out of coverage; the record stays as its durable trace. Replaces any existing
484
- withdrawal (re-withdrawing updates the reason/date). Reinstate with :func:`reinstate`.
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("[prodc]\nneeds = [\"needs/*.yaml\"]\n", encoding="utf-8")
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(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
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(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
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 = "[prodc]\nneeds = [\"needs/*.yaml\"]\n"
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"] == {"reason": "superseded by N-1", "date": "2026-10-10"}
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