prodc 0.3.77__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.77 → prodc-0.3.78}/PKG-INFO +1 -1
  2. {prodc-0.3.77 → prodc-0.3.78}/pyproject.toml +1 -1
  3. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/app.py +7 -0
  4. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/commands.py +27 -5
  5. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/render.py +18 -3
  6. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/serialize.py +14 -3
  7. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/config.py +3 -1
  8. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/loader.py +3 -1
  9. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/model.py +6 -2
  10. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/status.py +18 -0
  11. {prodc-0.3.77 → prodc-0.3.78}/tests/test_importance.py +49 -0
  12. {prodc-0.3.77 → prodc-0.3.78}/tests/test_relink.py +1 -1
  13. {prodc-0.3.77 → prodc-0.3.78}/tests/test_review.py +6 -2
  14. {prodc-0.3.77 → prodc-0.3.78}/tests/test_source_role.py +1 -1
  15. {prodc-0.3.77 → prodc-0.3.78}/tests/test_staleness.py +48 -0
  16. {prodc-0.3.77 → prodc-0.3.78}/tests/test_withdrawn.py +12 -2
  17. {prodc-0.3.77 → prodc-0.3.78}/.gitignore +0 -0
  18. {prodc-0.3.77 → prodc-0.3.78}/CHANGELOG.md +0 -0
  19. {prodc-0.3.77 → prodc-0.3.78}/LICENSE +0 -0
  20. {prodc-0.3.77 → prodc-0.3.78}/README.md +0 -0
  21. {prodc-0.3.77 → prodc-0.3.78}/examples/quickstart/features/orders.feature +0 -0
  22. {prodc-0.3.77 → prodc-0.3.78}/examples/quickstart/needs/jordan.yaml +0 -0
  23. {prodc-0.3.77 → prodc-0.3.78}/examples/quickstart/prodc.toml +0 -0
  24. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/__init__.py +0 -0
  25. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/adapters.py +0 -0
  26. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/agent.md +0 -0
  27. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/board.py +0 -0
  28. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/callbacks.py +0 -0
  29. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/__init__.py +0 -0
  30. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/common.py +0 -0
  31. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/dedup.py +0 -0
  32. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/py.typed +0 -0
  33. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/report.py +0 -0
  34. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/report_html.py +0 -0
  35. {prodc-0.3.77 → prodc-0.3.78}/src/prodc/writer.py +0 -0
  36. {prodc-0.3.77 → prodc-0.3.78}/tests/test_adapters.py +0 -0
  37. {prodc-0.3.77 → prodc-0.3.78}/tests/test_answer.py +0 -0
  38. {prodc-0.3.77 → prodc-0.3.78}/tests/test_callbacks.py +0 -0
  39. {prodc-0.3.77 → prodc-0.3.78}/tests/test_citation.py +0 -0
  40. {prodc-0.3.77 → prodc-0.3.78}/tests/test_claim_gating.py +0 -0
  41. {prodc-0.3.77 → prodc-0.3.78}/tests/test_cli.py +0 -0
  42. {prodc-0.3.77 → prodc-0.3.78}/tests/test_command_hook.py +0 -0
  43. {prodc-0.3.77 → prodc-0.3.78}/tests/test_dedup.py +0 -0
  44. {prodc-0.3.77 → prodc-0.3.78}/tests/test_description.py +0 -0
  45. {prodc-0.3.77 → prodc-0.3.78}/tests/test_drafts.py +0 -0
  46. {prodc-0.3.77 → prodc-0.3.78}/tests/test_evalgate.py +0 -0
  47. {prodc-0.3.77 → prodc-0.3.78}/tests/test_external_adapter.py +0 -0
  48. {prodc-0.3.77 → prodc-0.3.78}/tests/test_gitlab_source.py +0 -0
  49. {prodc-0.3.77 → prodc-0.3.78}/tests/test_loader.py +0 -0
  50. {prodc-0.3.77 → prodc-0.3.78}/tests/test_models.py +0 -0
  51. {prodc-0.3.77 → prodc-0.3.78}/tests/test_needref.py +0 -0
  52. {prodc-0.3.77 → prodc-0.3.78}/tests/test_report.py +0 -0
  53. {prodc-0.3.77 → prodc-0.3.78}/tests/test_report_html.py +0 -0
  54. {prodc-0.3.77 → prodc-0.3.78}/tests/test_roles.py +0 -0
  55. {prodc-0.3.77 → prodc-0.3.78}/tests/test_sources.py +0 -0
  56. {prodc-0.3.77 → prodc-0.3.78}/tests/test_status.py +0 -0
  57. {prodc-0.3.77 → prodc-0.3.78}/tests/test_suggest.py +0 -0
  58. {prodc-0.3.77 → prodc-0.3.78}/tests/test_variants.py +0 -0
  59. {prodc-0.3.77 → prodc-0.3.78}/tests/test_who_variants.py +0 -0
  60. {prodc-0.3.77 → prodc-0.3.78}/tests/test_writer.py +0 -0
  61. {prodc-0.3.77 → 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.77
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.77"
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"
@@ -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")
@@ -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,
@@ -384,7 +385,20 @@ def cmd_brief(args: argparse.Namespace) -> int:
384
385
  return 2
385
386
  scoped = applicable_needs(needs, args.client)
386
387
  results = resolve(scoped, cfg.adapters, cfg.source_adapters)
387
- 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
+ )
388
402
  return 0
389
403
 
390
404
 
@@ -397,10 +411,14 @@ def cmd_plan(args: argparse.Namespace) -> int:
397
411
  needs = board_needs(list(model.needs), include_drafts=args.include_drafts) # drafts off (#60)
398
412
  results = resolve(needs, cfg.adapters, cfg.source_adapters)
399
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)]
400
418
  print(
401
- plan_json(results, recurrence_counts)
419
+ plan_json(results, recurrence_counts, revisit=revisit)
402
420
  if args.json
403
- else render_plan(results, recurrence_counts)
421
+ else render_plan(results, recurrence_counts, revisit=revisit)
404
422
  )
405
423
  return 0
406
424
 
@@ -1090,7 +1108,9 @@ def _cmd_relink(cfg: Config, results: list[NeedResult], args: argparse.Namespace
1090
1108
  print(f"{s.need.id}: {s.need.text}")
1091
1109
  last = s.need.id
1092
1110
  print(f" {s.score:.2f} {s.broken} → {s.locator}")
1093
- 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
+ )
1094
1114
  return 0
1095
1115
 
1096
1116
 
@@ -1136,7 +1156,9 @@ def _apply_relinks(
1136
1156
  else:
1137
1157
  verb = "would relink" if args.dry_run else "relinked"
1138
1158
  for s, edit in applied:
1139
- 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
+ )
1140
1162
  print(edit.diff, end="")
1141
1163
  print(f"suggest --relink --apply: {verb} {len(applied)} locator(s)")
1142
1164
  return 0
@@ -331,7 +331,11 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
331
331
  return "\n".join(lines)
332
332
 
333
333
 
334
- 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:
335
339
  open_ = brief_open(results)
336
340
  out = [f"brief — {client}: {len(open_)} open question{'s' if len(open_) != 1 else ''}"]
337
341
  if not open_:
@@ -347,6 +351,10 @@ def render_brief(client: str, results: list[NeedResult]) -> str:
347
351
  if g
348
352
  )
349
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}")
350
358
  return "\n".join(out)
351
359
 
352
360
 
@@ -359,9 +367,16 @@ def _importance_cell(need: Need) -> str:
359
367
  return f"imp={need.importance}{' (guess)' if weak else ''}"
360
368
 
361
369
 
362
- 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:
363
373
  ranked = rank(results, recurrence_counts)
364
- 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]
365
380
  for i, r in enumerate(ranked, 1):
366
381
  bang = "!!" if r.need.must else " "
367
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
 
@@ -150,7 +150,9 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
150
150
  kind = WithdrawalKind(kind_name)
151
151
  except ValueError:
152
152
  kinds = "|".join(k.value for k in WithdrawalKind)
153
- raise LoaderError(f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})") from None
153
+ raise LoaderError(
154
+ f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})"
155
+ ) from None
154
156
  return Withdrawal(reason=reason, date=date, kind=kind)
155
157
 
156
158
 
@@ -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
@@ -215,7 +217,9 @@ class Need:
215
217
  release: str | None = None # id of the Release slice this need ships in; None = unslotted
216
218
  product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
217
219
  draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
218
- 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
+ )
219
223
  withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
220
224
  answer: Answer | None = (
221
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)
@@ -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"] == []
@@ -306,8 +306,18 @@ def test_withdraw_writes_non_default_kind(tmp_path: Path) -> None:
306
306
 
307
307
  cfg = _project(tmp_path)
308
308
  code = main(
309
- ["withdraw", "N-1", "--reason", "off strategy", "--kind", "out_of_scope",
310
- "--date", "2026-10-15", "--config", str(cfg)]
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
+ ]
311
321
  )
312
322
  assert code == 0
313
323
  by_id = {n.id: n for n in load(Config.load(cfg)).needs}
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