prodc 0.3.73__tar.gz → 0.3.76__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.73 → prodc-0.3.76}/PKG-INFO +1 -1
  2. {prodc-0.3.73 → prodc-0.3.76}/pyproject.toml +1 -1
  3. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/agent.md +7 -3
  4. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/board.py +9 -5
  5. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/cli/app.py +42 -1
  6. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/cli/commands.py +262 -2
  7. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/cli/render.py +6 -1
  8. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/cli/serialize.py +1 -0
  9. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/config.py +7 -0
  10. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/loader.py +19 -0
  11. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/model.py +2 -0
  12. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/report_html.py +1 -1
  13. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/status.py +56 -0
  14. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/writer.py +86 -0
  15. {prodc-0.3.73 → prodc-0.3.76}/tests/test_cli.py +12 -0
  16. {prodc-0.3.73 → prodc-0.3.76}/tests/test_drafts.py +44 -0
  17. prodc-0.3.76/tests/test_relink.py +103 -0
  18. prodc-0.3.76/tests/test_review.py +121 -0
  19. prodc-0.3.76/tests/test_source_role.py +126 -0
  20. {prodc-0.3.73 → prodc-0.3.76}/tests/test_writer.py +88 -1
  21. {prodc-0.3.73 → prodc-0.3.76}/.gitignore +0 -0
  22. {prodc-0.3.73 → prodc-0.3.76}/CHANGELOG.md +0 -0
  23. {prodc-0.3.73 → prodc-0.3.76}/LICENSE +0 -0
  24. {prodc-0.3.73 → prodc-0.3.76}/README.md +0 -0
  25. {prodc-0.3.73 → prodc-0.3.76}/examples/quickstart/features/orders.feature +0 -0
  26. {prodc-0.3.73 → prodc-0.3.76}/examples/quickstart/needs/jordan.yaml +0 -0
  27. {prodc-0.3.73 → prodc-0.3.76}/examples/quickstart/prodc.toml +0 -0
  28. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/__init__.py +0 -0
  29. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/adapters.py +0 -0
  30. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/callbacks.py +0 -0
  31. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/cli/__init__.py +0 -0
  32. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/cli/common.py +0 -0
  33. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/dedup.py +0 -0
  34. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/py.typed +0 -0
  35. {prodc-0.3.73 → prodc-0.3.76}/src/prodc/report.py +0 -0
  36. {prodc-0.3.73 → prodc-0.3.76}/tests/test_adapters.py +0 -0
  37. {prodc-0.3.73 → prodc-0.3.76}/tests/test_answer.py +0 -0
  38. {prodc-0.3.73 → prodc-0.3.76}/tests/test_callbacks.py +0 -0
  39. {prodc-0.3.73 → prodc-0.3.76}/tests/test_citation.py +0 -0
  40. {prodc-0.3.73 → prodc-0.3.76}/tests/test_claim_gating.py +0 -0
  41. {prodc-0.3.73 → prodc-0.3.76}/tests/test_command_hook.py +0 -0
  42. {prodc-0.3.73 → prodc-0.3.76}/tests/test_dedup.py +0 -0
  43. {prodc-0.3.73 → prodc-0.3.76}/tests/test_description.py +0 -0
  44. {prodc-0.3.73 → prodc-0.3.76}/tests/test_evalgate.py +0 -0
  45. {prodc-0.3.73 → prodc-0.3.76}/tests/test_external_adapter.py +0 -0
  46. {prodc-0.3.73 → prodc-0.3.76}/tests/test_gitlab_source.py +0 -0
  47. {prodc-0.3.73 → prodc-0.3.76}/tests/test_importance.py +0 -0
  48. {prodc-0.3.73 → prodc-0.3.76}/tests/test_loader.py +0 -0
  49. {prodc-0.3.73 → prodc-0.3.76}/tests/test_models.py +0 -0
  50. {prodc-0.3.73 → prodc-0.3.76}/tests/test_needref.py +0 -0
  51. {prodc-0.3.73 → prodc-0.3.76}/tests/test_report.py +0 -0
  52. {prodc-0.3.73 → prodc-0.3.76}/tests/test_report_html.py +0 -0
  53. {prodc-0.3.73 → prodc-0.3.76}/tests/test_roles.py +0 -0
  54. {prodc-0.3.73 → prodc-0.3.76}/tests/test_sources.py +0 -0
  55. {prodc-0.3.73 → prodc-0.3.76}/tests/test_staleness.py +0 -0
  56. {prodc-0.3.73 → prodc-0.3.76}/tests/test_status.py +0 -0
  57. {prodc-0.3.73 → prodc-0.3.76}/tests/test_suggest.py +0 -0
  58. {prodc-0.3.73 → prodc-0.3.76}/tests/test_variants.py +0 -0
  59. {prodc-0.3.73 → prodc-0.3.76}/tests/test_who_variants.py +0 -0
  60. {prodc-0.3.73 → prodc-0.3.76}/tests/test_withdrawn.py +0 -0
  61. {prodc-0.3.73 → prodc-0.3.76}/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.73
3
+ Version: 0.3.76
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.73"
3
+ version = "0.3.76"
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"
@@ -49,14 +49,16 @@ Read first, then act, then check.
49
49
  3. **When someone describes a want**, decide: is there real evidence? If a user said it, capture
50
50
  it as `primary`. If it is your own inference, record it as an `assumption` and let it show as the
51
51
  weaker signal it is. Stage anything still half-formed with `add … --draft` — it loads but stays off the
52
- counted board until you promote it with `prodc set <id> draft=false`.
52
+ counted board until you promote it. `prodc review` is the triage surface: it lists the staged
53
+ drafts with a duplicate score, and `--promote <id>…` / `--promote-all` move them onto the board
54
+ while `--drop <id>…` deletes the ones that were noise.
53
55
  4. **Find the proof.** For a HOLE need, `prodc suggest <id>` ranks candidate proofs from the test
54
56
  suite; `prodc suggest <id> --apply` writes the best one (preview with `--dry-run`).
55
57
  5. **Check for duplication and recurrence.** `prodc dupes` flags near-duplicate needs;
56
58
  `prodc recurrence --suggest` finds the same want across clients that could share one baseline.
57
59
  6. **Gate before you hand off.** `prodc lint` is the red gate: it exits non-zero on a BROKEN proof
58
60
  or a must-have opinion. `prodc lint --strict` *also* fails on any hygiene warning (shared proofs,
59
- unlinked recurrence, stale evidence). When you want a coverage threshold in CI, `prodc status`
61
+ unlinked recurrence, stale evidence, drafts left staged too long via `[lint].draft_max_age_days`). When you want a coverage threshold in CI, `prodc status`
60
62
  carries its own gate flags (`--min-coverage`, `--max-dangling`, `--max-orphans`, `--strict`).
61
63
 
62
64
  ## The commands you will use most
@@ -68,10 +70,12 @@ Read first, then act, then check.
68
70
  | rank the backlog | `prodc plan` |
69
71
  | prep a client meeting | `prodc brief <client>` |
70
72
  | record a new need (with honest provenance) | `prodc add need <id> --text "…" --who <you> --kind assumption --file needs/<f>.yaml` |
73
+ | triage staged drafts | `prodc review` — list them; `--promote <id>…`/`--promote-all` onto the board, `--drop <id>…` to delete |
71
74
  | declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
72
- | attach evidence after a user said something | `prodc capture <id> --kind primary --who <person> --date <ISO> --quote "…"` |
75
+ | attach evidence after a user said something | `prodc capture <id> --kind primary --who <person> [--role <id>] --date <ISO> --quote "…"` (`--role` records which declared role vouched for it) |
73
76
  | set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
74
77
  | propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
78
+ | relink a BROKEN proof (renamed test) | `prodc suggest --relink [<id>] [--apply] [--dry-run]` — proposes the nearest live inventory entry to replace the broken locator |
75
79
  | link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
76
80
  | record a human/AI review of the warrant | `prodc attest <id> --by <who> --date <ISO> [--ai]` |
77
81
  | record a human acceptance pass | `prodc accept <id> --who <person> --date <ISO>` |
@@ -48,16 +48,19 @@ from .status import (
48
48
  def warrant(need: Need) -> str:
49
49
  """The warrant column: the strongest source kind, or 'opinion' when there is no source at all.
50
50
 
51
- A `primary` source names its person (`primary:who`); `secondary`/`assumption` show the kind. A
52
- need with any source is shown by that kind even if it is an opinion (only an assumption) — the
53
- opinion roll-up lives in the gap counts, this column shows the literal evidence tier.
51
+ A `primary` source names its person (`primary:who`, and `primary:who (role)` when that person's
52
+ role is recorded, #52); `secondary`/`assumption` show the kind. A need with any source is shown
53
+ by that kind even if it is an opinion (only an assumption) — the opinion roll-up lives in the
54
+ gap counts, this column shows the literal evidence tier.
54
55
  """
55
56
  strongest = need.strongest_kind # the one source-strength ranking lives on the model
56
57
  if strongest is None:
57
58
  return "opinion"
58
59
  if strongest is SourceKind.PRIMARY:
59
- who = next((s.who for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
60
- return f"primary:{who}" if who else "primary"
60
+ src = next((s for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
61
+ if src is None:
62
+ return "primary"
63
+ return f"primary:{src.who}" + (f" ({src.role})" if src.role else "")
61
64
  return strongest.value
62
65
 
63
66
 
@@ -120,6 +123,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
120
123
  {
121
124
  "kind": s.kind.value,
122
125
  "who": s.who,
126
+ "role": s.role,
123
127
  "date": s.date,
124
128
  "quote": s.quote,
125
129
  "locator": s.locator,
@@ -28,6 +28,7 @@ from .commands import (
28
28
  cmd_recurrence,
29
29
  cmd_reinstate,
30
30
  cmd_report,
31
+ cmd_review,
31
32
  cmd_set,
32
33
  cmd_show,
33
34
  cmd_status,
@@ -224,6 +225,12 @@ def main(argv: list[str] | None = None) -> int:
224
225
  "(interview/artifact/proxy/internal accepted as aliases)",
225
226
  )
226
227
  cp.add_argument("--who", default=None, help="the person the evidence came from")
228
+ cp.add_argument(
229
+ "--role",
230
+ default=None,
231
+ metavar="ID",
232
+ help="the declared role the person spoke as (validated against roles:) — whose signal it is (#52)",
233
+ )
227
234
  cp.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-05)")
228
235
  cp.add_argument("--quote", default=None, help="a verbatim quote (for a primary source)")
229
236
  cp.add_argument("--locator", default=None, help="a source locator (for a secondary source)")
@@ -324,6 +331,35 @@ def main(argv: list[str] | None = None) -> int:
324
331
  )
325
332
  ri.set_defaults(func=cmd_reinstate)
326
333
 
334
+ rv = sub.add_parser(
335
+ "review", help="triage staged drafts: list them, or --promote / --drop a batch (#60)"
336
+ )
337
+ rv.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
338
+ rv.add_argument("--json", action="store_true", help="machine-readable output")
339
+ rv.add_argument(
340
+ "--promote",
341
+ nargs="+",
342
+ default=None,
343
+ metavar="ID",
344
+ help="promote these drafts onto the board (sets draft=false)",
345
+ )
346
+ rv.add_argument(
347
+ "--promote-all",
348
+ action="store_true",
349
+ help="promote every staged draft (except any named in --drop)",
350
+ )
351
+ rv.add_argument(
352
+ "--drop",
353
+ nargs="+",
354
+ default=None,
355
+ metavar="ID",
356
+ help="delete these drafts outright (a draft, never a real need — withdraw that)",
357
+ )
358
+ rv.add_argument(
359
+ "--dry-run", action="store_true", help="validate and show the diffs, don't write"
360
+ )
361
+ rv.set_defaults(func=cmd_review)
362
+
327
363
  pf = sub.add_parser("portfolio", help="roll up many boards into one scoreboard")
328
364
  pf.add_argument(
329
365
  "configs",
@@ -397,10 +433,15 @@ def main(argv: list[str] | None = None) -> int:
397
433
  sg.add_argument(
398
434
  "--kind", default=None, metavar="ADAPTER", help="only suggest from this adapter name"
399
435
  )
436
+ sg.add_argument(
437
+ "--relink",
438
+ action="store_true",
439
+ help="BROKEN-need mode: propose a live inventory entry to replace each broken locator (#54)",
440
+ )
400
441
  sg.add_argument(
401
442
  "--apply",
402
443
  action="store_true",
403
- help="write the best suggestion per need as a proof (#54); honours --min-score",
444
+ help="write the best suggestion per need as a proof (relink: replace the broken one) (#54)",
404
445
  )
405
446
  sg.add_argument(
406
447
  "--dry-run",
@@ -33,11 +33,13 @@ from ..report import render_markdown
33
33
  from ..report_html import render_html
34
34
  from ..status import (
35
35
  ProofSuggestion,
36
+ RelinkSuggestion,
36
37
  applicable_needs,
37
38
  argument_defects,
38
39
  evidence_age_days,
39
40
  proof_suggestions,
40
41
  recurrence,
42
+ relink_suggestions,
41
43
  resolve,
42
44
  summarize,
43
45
  )
@@ -50,8 +52,10 @@ from ..writer import (
50
52
  add_role,
51
53
  attest,
52
54
  capture_source,
55
+ delete_need,
53
56
  link_variant_group,
54
57
  reinstate,
58
+ relink_proof,
55
59
  set_field,
56
60
  set_importance,
57
61
  withdraw,
@@ -136,6 +140,47 @@ def _annotate_importance_warnings(results: list[NeedResult], cfg: Config) -> Non
136
140
  )
137
141
 
138
142
 
143
+ def _annotate_draft_age_warnings(results: list[NeedResult], cfg: Config) -> None:
144
+ """Flag staged drafts older than `[lint].draft_max_age_days`, so a batch can't be forgotten (#60).
145
+
146
+ Mirrors the importance-revisit check: a draft with no `draft_added` stamp is flagged undated
147
+ (it cannot be aged, so it is not silently exempt); a dated draft past the limit is flagged with
148
+ its age. Only runs when the knob is set.
149
+ """
150
+ if cfg.draft_max_age_days is None:
151
+ return
152
+ today = date.today()
153
+ for r in results:
154
+ if not r.need.draft or r.need.is_withdrawn:
155
+ continue
156
+ when = r.need.draft_added
157
+ if when is None:
158
+ r.warnings.append("draft with no staged date (`add --draft` stamps one) — #60")
159
+ continue
160
+ try:
161
+ days = (today - date.fromisoformat(when)).days
162
+ except ValueError:
163
+ continue
164
+ if days > cfg.draft_max_age_days:
165
+ r.warnings.append(
166
+ f"draft staged {days}d ago (> {cfg.draft_max_age_days}d) — promote or drop (#60)"
167
+ )
168
+
169
+
170
+ def _annotate_label_deprecation(results: list[NeedResult]) -> None:
171
+ """Nudge needs still carrying the free-text `label` toward role + client (#52, deprecation).
172
+
173
+ Since Role (#52) carries the archetype and `client` the org, the per-need `label` is redundant —
174
+ "design partner, Sincerely" is `role` + `client`. Soft only: `label` is still parsed and still
175
+ renders; this flags it so a board can migrate off it before it is removed.
176
+ """
177
+ for r in results:
178
+ if r.need.label:
179
+ r.warnings.append(
180
+ f"'label' ({r.need.label!r}) is deprecated — express it with role + client (#52)"
181
+ )
182
+
183
+
139
184
  def _lint_errors(results: list[NeedResult]) -> list[str]:
140
185
  """Hard problems a lint gate fails on: a dangling proof, or a must-have with no evidence."""
141
186
  errors: list[str] = []
@@ -184,6 +229,8 @@ def cmd_lint(args: argparse.Namespace) -> int:
184
229
  f"who may name the same person: {whos} (needs {', '.join(v.need_ids)}) — #61"
185
230
  )
186
231
  _annotate_importance_warnings(results, cfg)
232
+ _annotate_draft_age_warnings(results, cfg)
233
+ _annotate_label_deprecation(results)
187
234
  errors = _lint_errors(results)
188
235
  warnings = [
189
236
  (r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
@@ -531,6 +578,132 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
531
578
  return emit_edit(edit, args.dry_run, args.json)
532
579
 
533
580
 
581
+ def cmd_review(args: argparse.Namespace) -> int:
582
+ """Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
583
+
584
+ The companion to `add --draft`: drafts pile up staged off the board, and this is the surface
585
+ that clears them. With no action flag it lists each draft with its provenance and the nearest
586
+ existing need (a dedup score, reusing #55), so a reviewer can see what is a duplicate before
587
+ promoting. `--promote` flips `draft=false` (sugar over `set`); `--drop` deletes the draft
588
+ outright (the #60 delete-need path). Both act only on drafts — a real need is withdrawn, not
589
+ dropped, so its record survives.
590
+ """
591
+ loaded = load_or_report(args)
592
+ if loaded is None:
593
+ return 2
594
+ cfg, model = loaded
595
+ needs = list(model.needs)
596
+ drafts = [n for n in needs if n.draft and not n.is_withdrawn]
597
+ draft_ids = {n.id for n in drafts}
598
+
599
+ drop = list(args.drop or [])
600
+ if args.promote_all:
601
+ promote = [n.id for n in drafts if n.id not in drop]
602
+ else:
603
+ promote = list(args.promote or [])
604
+
605
+ unknown = [i for i in (*promote, *drop) if i not in draft_ids]
606
+ if unknown:
607
+ print(
608
+ f"prodc: not a staged draft: {', '.join(unknown)} "
609
+ "(review acts only on drafts — withdraw a real need instead)",
610
+ file=sys.stderr,
611
+ )
612
+ return 2
613
+ both = sorted(set(promote) & set(drop))
614
+ if both:
615
+ print(f"prodc: {', '.join(both)} named in both --promote and --drop", file=sys.stderr)
616
+ return 2
617
+
618
+ if not promote and not drop:
619
+ return _review_list(cfg, drafts, needs, args.json)
620
+ return _review_apply(cfg, promote, drop, args)
621
+
622
+
623
+ def _review_list(cfg: Config, drafts: list[Need], needs: list[Need], as_json: bool) -> int:
624
+ """List the staged drafts with provenance and the nearest existing need (read-only)."""
625
+ rows: list[dict[str, object]] = []
626
+ for d in sorted(drafts, key=lambda n: n.id):
627
+ others = [n for n in needs if n.id != d.id]
628
+ hits = matches_for(d.text, others, cfg.duplicate_threshold, exclude_id=d.id)
629
+ dup = {"id": hits[0][0].id, "score": round(hits[0][1], 4)} if hits else None
630
+ rows.append(
631
+ {
632
+ "id": d.id,
633
+ "text": d.text,
634
+ "who": d.who,
635
+ "client": d.client,
636
+ "sources": [s.kind.value for s in d.sources],
637
+ "staged": d.draft_added,
638
+ "duplicate": dup,
639
+ }
640
+ )
641
+ if as_json:
642
+ print(json.dumps({"drafts": rows}, indent=2))
643
+ return 0
644
+ for r in rows:
645
+ who = f" — {r['who']}" if r["who"] else ""
646
+ client = f" [{r['client']}]" if r["client"] else ""
647
+ staged = f" (staged {r['staged']})" if r["staged"] else ""
648
+ print(f"{r['id']}{who}{client}{staged}: {r['text']}")
649
+ kinds = cast("list[str]", r["sources"])
650
+ if kinds:
651
+ print(f" sources: {', '.join(kinds)}")
652
+ dup = cast("dict[str, object] | None", r["duplicate"])
653
+ if dup is not None:
654
+ print(f" possible duplicate of {dup['id']} ({dup['score']:.2f}) — #55")
655
+ print(f"review: {len(rows)} draft(s) staged")
656
+ return 0
657
+
658
+
659
+ def _review_apply(
660
+ cfg: Config, promote: list[str], drop: list[str], args: argparse.Namespace
661
+ ) -> int:
662
+ """Promote (`draft=false`) and/or drop (delete) the named drafts, each a validated edit (#60).
663
+
664
+ Sequential and independent, like `suggest --apply`: each need is one reload-gated, single-file
665
+ write applied in id order, and a rejection aborts with a non-zero exit (earlier writes stand).
666
+ """
667
+ applied: list[tuple[str, str, Edit]] = [] # (action, need_id, edit)
668
+ try:
669
+ for nid in sorted(promote):
670
+ edit = set_field(cfg, nid, "draft=false", expect_sha=None, dry_run=args.dry_run)
671
+ applied.append(("promote", nid, edit))
672
+ for nid in sorted(drop):
673
+ edit = delete_need(cfg, nid, dry_run=args.dry_run)
674
+ applied.append(("drop", nid, edit))
675
+ except WriteError as exc:
676
+ print(f"prodc: {exc}", file=sys.stderr)
677
+ return 2
678
+ if args.json:
679
+ print(
680
+ json.dumps(
681
+ [
682
+ {
683
+ "action": action,
684
+ "need": nid,
685
+ "file": edit.path.name,
686
+ "dry_run": args.dry_run,
687
+ "diff": edit.diff,
688
+ }
689
+ for action, nid, edit in applied
690
+ ],
691
+ indent=2,
692
+ )
693
+ )
694
+ return 0
695
+ for action, nid, edit in applied:
696
+ verb = "would promote" if action == "promote" else "would drop"
697
+ if not args.dry_run:
698
+ verb = "promoted" if action == "promote" else "dropped"
699
+ print(f"{verb} {nid} in {edit.path.name}")
700
+ print(edit.diff, end="")
701
+ n_prom = sum(1 for row in applied if row[0] == "promote")
702
+ n_drop = len(applied) - n_prom
703
+ print(f"review: {n_prom} promoted, {n_drop} dropped" + (" (dry-run)" if args.dry_run else ""))
704
+ return 0
705
+
706
+
534
707
  def cmd_capture(args: argparse.Namespace) -> int:
535
708
  """Append a Source to an existing need (capture evidence)."""
536
709
  cfg_path = Path(args.config)
@@ -544,6 +717,7 @@ def cmd_capture(args: argparse.Namespace) -> int:
544
717
  args.need_id,
545
718
  kind=args.kind,
546
719
  who=args.who,
720
+ role=args.role,
547
721
  date=args.date,
548
722
  quote=args.quote,
549
723
  locator=args.locator,
@@ -573,7 +747,9 @@ def _repo_row(config_path: str) -> RepoRow:
573
747
  name = p.parent.name or str(p)
574
748
  try:
575
749
  cfg = Config.load(p)
576
- needs = list(load(cfg).needs)
750
+ # Drafts (#60) and withdrawn needs (#74) are off the counted board, same as `status`/`plan`,
751
+ # so a repo's portfolio line never inflates with staged ideas.
752
+ needs = board_needs(list(load(cfg).needs), include_drafts=False)
577
753
  except (ConfigError, LoaderError, OSError) as exc:
578
754
  return RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
579
755
  results = resolve(needs, cfg.adapters, cfg.source_adapters)
@@ -781,7 +957,7 @@ def cmd_agent(args: argparse.Namespace) -> int:
781
957
 
782
958
 
783
959
  def cmd_suggest(args: argparse.Namespace) -> int:
784
- """Propose candidate proof locators for HOLE needs from the adapters' inventories (#54)."""
960
+ """Propose candidate proof locators for HOLE needs (or relink BROKEN ones) from the inventories (#54)."""
785
961
  loaded = load_or_report(args)
786
962
  if loaded is None:
787
963
  return 2
@@ -793,6 +969,8 @@ def cmd_suggest(args: argparse.Namespace) -> int:
793
969
  if not results:
794
970
  print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
795
971
  return 2
972
+ if args.relink:
973
+ return _cmd_relink(cfg, results, args)
796
974
  suggestions = proof_suggestions(
797
975
  results, cfg.adapters, min_score=args.min_score, top=args.top, kind=args.kind
798
976
  )
@@ -871,6 +1049,88 @@ def _apply_suggestions(
871
1049
  return 0
872
1050
 
873
1051
 
1052
+ def _cmd_relink(cfg: Config, results: list[NeedResult], args: argparse.Namespace) -> int:
1053
+ """Propose (or --apply) a live inventory entry to replace each BROKEN proof locator (#54)."""
1054
+ suggestions = relink_suggestions(
1055
+ results, cfg.adapters, min_score=args.min_score, top=args.top, kind=args.kind
1056
+ )
1057
+ if args.apply:
1058
+ return _apply_relinks(cfg, suggestions, args)
1059
+ if args.json:
1060
+ print(
1061
+ json.dumps(
1062
+ [
1063
+ {
1064
+ "need": s.need.id,
1065
+ "adapter": s.adapter,
1066
+ "broken": s.broken,
1067
+ "locator": s.locator,
1068
+ "score": round(s.score, 4),
1069
+ }
1070
+ for s in suggestions
1071
+ ],
1072
+ indent=2,
1073
+ )
1074
+ )
1075
+ else:
1076
+ last = None
1077
+ for s in suggestions:
1078
+ if s.need.id != last:
1079
+ print(f"{s.need.id}: {s.need.text}")
1080
+ last = s.need.id
1081
+ 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}")
1083
+ return 0
1084
+
1085
+
1086
+ def _apply_relinks(
1087
+ cfg: Config, suggestions: list[RelinkSuggestion], args: argparse.Namespace
1088
+ ) -> int:
1089
+ """Apply the best relink per broken locator, printing each need's diff (#54 relink).
1090
+
1091
+ Keyed by ``(need, broken locator)`` so a need with two broken proofs relinks both to their own
1092
+ best candidate. Each is one reload-gated single-file edit (:func:`relink_proof`), applied in a
1093
+ stable order; a rejected write aborts with a non-zero exit (earlier writes stand).
1094
+ """
1095
+ best: dict[tuple[str, str], RelinkSuggestion] = {}
1096
+ for s in suggestions:
1097
+ best.setdefault((s.need.id, s.broken), s)
1098
+ applied: list[tuple[RelinkSuggestion, Edit]] = []
1099
+ for key in sorted(best):
1100
+ s = best[key]
1101
+ try:
1102
+ edit = relink_proof(cfg, s.need.id, s.broken, s.locator, dry_run=args.dry_run)
1103
+ except WriteError as exc:
1104
+ print(f"prodc: {exc}", file=sys.stderr)
1105
+ return 2
1106
+ applied.append((s, edit))
1107
+ if args.json:
1108
+ print(
1109
+ json.dumps(
1110
+ [
1111
+ {
1112
+ "need": s.need.id,
1113
+ "broken": s.broken,
1114
+ "locator": s.locator,
1115
+ "score": round(s.score, 4),
1116
+ "file": edit.path.name,
1117
+ "dry_run": args.dry_run,
1118
+ "diff": edit.diff,
1119
+ }
1120
+ for s, edit in applied
1121
+ ],
1122
+ indent=2,
1123
+ )
1124
+ )
1125
+ else:
1126
+ verb = "would relink" if args.dry_run else "relinked"
1127
+ for s, edit in applied:
1128
+ print(f"{verb} {s.broken} → {s.locator} on {s.need.id} ({s.score:.2f}) in {edit.path.name}")
1129
+ print(edit.diff, end="")
1130
+ print(f"suggest --relink --apply: {verb} {len(applied)} locator(s)")
1131
+ return 0
1132
+
1133
+
874
1134
  def cmd_recurrence(args: argparse.Namespace) -> int:
875
1135
  """Show baselines shared across clients; `--suggest` proposes unlinked variant groups (#56)."""
876
1136
  loaded = load_or_report(args)
@@ -304,7 +304,12 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
304
304
  bits = [s.kind.value]
305
305
  bits += [
306
306
  f"{k}={v}"
307
- for k, v in (("who", s.who), ("date", s.date), ("locator", s.locator))
307
+ for k, v in (
308
+ ("who", s.who),
309
+ ("role", s.role),
310
+ ("date", s.date),
311
+ ("locator", s.locator),
312
+ )
308
313
  if v
309
314
  ]
310
315
  line = " - " + " ".join(bits)
@@ -115,6 +115,7 @@ def show_json(r: NeedResult) -> str:
115
115
  {
116
116
  "kind": s.kind.value,
117
117
  "who": s.who,
118
+ "role": s.role,
118
119
  "date": s.date,
119
120
  "quote": s.quote,
120
121
  "locator": s.locator,
@@ -115,6 +115,7 @@ class Config:
115
115
  default_factory=dict[int, tuple[str, str]]
116
116
  ) # [importance].levels — ordinal → (label, meaning); empty = no rubric declared (#59)
117
117
  importance_revisit_days: int | None = None # [lint].importance_revisit_days (#59)
118
+ draft_max_age_days: int | None = None # [lint].draft_max_age_days — flag stale drafts (#60)
118
119
  default_draft: bool = False # [add].default_draft — `add` stages as draft by default (#60)
119
120
  callbacks: tuple[Callback, ...] = () # [[callbacks.hooks]] — lifecycle callbacks (#71)
120
121
  callback_state_file: str = ".prodc/state.json" # [callbacks].state_file — the state ledger
@@ -269,6 +270,11 @@ class Config:
269
270
  not isinstance(revisit, int) or isinstance(revisit, bool) or revisit < 1
270
271
  ):
271
272
  raise ConfigError("[lint].importance_revisit_days must be a positive integer")
273
+ draft_max_age = lint_tbl.get("draft_max_age_days")
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
276
+ ):
277
+ raise ConfigError("[lint].draft_max_age_days must be a positive integer")
272
278
 
273
279
  importance_levels: dict[int, tuple[str, str]] = {}
274
280
  imp_tbl = _as_dict(raw.get("importance", {}), "[importance]")
@@ -303,6 +309,7 @@ class Config:
303
309
  stale_after_days=stale,
304
310
  importance_levels=importance_levels,
305
311
  importance_revisit_days=revisit,
312
+ draft_max_age_days=draft_max_age,
306
313
  default_draft=default_draft,
307
314
  callbacks=callbacks,
308
315
  callback_state_file=callback_state_file,
@@ -169,6 +169,7 @@ def _source(raw: object, where: str) -> Source:
169
169
  return Source(
170
170
  kind=kind,
171
171
  who=_opt_str(tbl.get("who"), f"{where}.who"),
172
+ role=_opt_str(tbl.get("role"), f"{where}.role"),
172
173
  date=_opt_str(tbl.get("date"), f"{where}.date"),
173
174
  quote=_opt_str(tbl.get("quote"), f"{where}.quote"),
174
175
  locator=_opt_str(tbl.get("locator"), f"{where}.locator"),
@@ -201,6 +202,7 @@ def _need(
201
202
  release = _opt_str(tbl.get("release"), f"{where}.release")
202
203
  product = _as_bool(tbl["product"], f"{where}.product") if "product" in tbl else True
203
204
  draft = _as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False
205
+ draft_added = _opt_str(tbl.get("draft_added"), f"{where}.draft_added")
204
206
  withdrawn = _withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn")
205
207
  answer = _answer(tbl.get("answer"), f"{where}.answer")
206
208
 
@@ -260,6 +262,7 @@ def _need(
260
262
  not_variant_of=not_variant_of,
261
263
  importance_set_at=importance_set_at,
262
264
  draft=draft,
265
+ draft_added=draft_added,
263
266
  withdrawn=withdrawn,
264
267
  answer=answer,
265
268
  )
@@ -399,6 +402,7 @@ def load(config: Config) -> Model:
399
402
  _check_ref(acc.needs, "goal", lambda n: n.goal, {g.id for g in acc.goals})
400
403
  _check_ref(acc.needs, "release", lambda n: n.release, {r.id for r in acc.releases})
401
404
  _check_ref(acc.needs, "role", lambda n: n.role, {p.id for p in acc.roles})
405
+ _check_source_roles(acc.needs, {p.id for p in acc.roles})
402
406
  _check_argument(acc.argument)
403
407
  return Model(
404
408
  needs=tuple(acc.needs),
@@ -424,6 +428,21 @@ def _check_ref(
424
428
  raise LoaderError(f"need {need.id!r}: {role} {ref!r} is not a known {role} id")
425
429
 
426
430
 
431
+ def _check_source_roles(needs: list[Need], known: set[str]) -> None:
432
+ """Every ``source.role`` that is set must name a declared role id (like ``need.role``, #52).
433
+
434
+ Validated unconditionally: ``role`` is a new, opt-in field, so there is no migration reason to
435
+ be lax — a dangling role on a source is a typo the author owns, surfaced at load, not a silent
436
+ unknown that only shows as missing provenance later.
437
+ """
438
+ for need in needs:
439
+ for i, s in enumerate(need.sources):
440
+ if s.role is not None and s.role not in known:
441
+ raise LoaderError(
442
+ f"need {need.id!r}: source[{i}] role {s.role!r} is not a known role id"
443
+ )
444
+
445
+
427
446
  def _check_argument(argument: list[ArgNode]) -> None:
428
447
  """Every ``supported_by``/``in_context_of`` link must name a known argument node.
429
448
 
@@ -74,6 +74,7 @@ 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
78
  date: str | None = None
78
79
  quote: str | None = None
79
80
  locator: str | None = None
@@ -198,6 +199,7 @@ class Need:
198
199
  release: str | None = None # id of the Release slice this need ships in; None = unslotted
199
200
  product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
200
201
  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
201
203
  withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
202
204
  answer: Answer | None = (
203
205
  None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
@@ -361,7 +361,7 @@ function renderDetail(n) {
361
361
  let srcHtml;
362
362
  if (!n.sources.length) srcHtml = '<span class="warn">no source — this need is an opinion</span>';
363
363
  else srcHtml = n.sources.map(s => {
364
- const head = `<span class="tag">${esc(s.kind)}</span>${s.who?" "+esc(s.who):""}${s.date?' <span class="meta">· '+esc(s.date)+'</span>':""}`;
364
+ const head = `<span class="tag">${esc(s.kind)}</span>${s.who?" "+esc(s.who):""}${s.role?' <span class="meta">('+esc(s.role)+')</span>':""}${s.date?' <span class="meta">· '+esc(s.date)+'</span>':""}`;
365
365
  const q = s.quote ? `<div class="q">“${esc(s.quote)}”</div>` : "";
366
366
  const loc = s.locator ? `<div class="meta">${esc(s.locator)}</div>` : "";
367
367
  return `<div class="src">${head}${q}${loc}</div>`;