prodc 0.3.72__tar.gz → 0.3.75__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.72 → prodc-0.3.75}/PKG-INFO +1 -1
- {prodc-0.3.72 → prodc-0.3.75}/pyproject.toml +1 -1
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/agent.md +9 -5
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/board.py +9 -5
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/cli/app.py +49 -2
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/cli/commands.py +208 -6
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/cli/common.py +77 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/cli/render.py +18 -3
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/cli/serialize.py +1 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/config.py +7 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/loader.py +19 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/model.py +2 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/report_html.py +1 -1
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/writer.py +46 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_cli.py +59 -3
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_drafts.py +44 -0
- prodc-0.3.75/tests/test_review.py +121 -0
- prodc-0.3.75/tests/test_source_role.py +126 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_writer.py +66 -1
- {prodc-0.3.72 → prodc-0.3.75}/.gitignore +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/CHANGELOG.md +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/LICENSE +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/README.md +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/__init__.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/adapters.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/dedup.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/py.typed +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/report.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/src/prodc/status.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_adapters.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_answer.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_callbacks.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_citation.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_command_hook.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_dedup.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_description.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_evalgate.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_importance.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_loader.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_models.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_needref.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_report.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_report_html.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_roles.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_sources.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_staleness.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_status.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_suggest.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_variants.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_who_variants.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.72 → prodc-0.3.75}/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.75
|
|
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
|
|
@@ -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
|
|
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,8 +70,9 @@ 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]` |
|
|
75
78
|
| link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
|
|
@@ -80,8 +83,9 @@ Read first, then act, then check.
|
|
|
80
83
|
| 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) |
|
|
81
84
|
| gate in CI | `prodc lint --strict` |
|
|
82
85
|
|
|
83
|
-
Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <
|
|
84
|
-
(roll up many
|
|
86
|
+
Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <path>…`
|
|
87
|
+
(roll up many boards into one scoreboard + totals; a path is a prodc.toml or a dir to discover
|
|
88
|
+
boards under), and `prodc diff <snapshot.json>` (what changed since a
|
|
85
89
|
saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
|
|
86
90
|
`--wide`, `--include-drafts`, and `--stale <days>`.
|
|
87
91
|
|
|
@@ -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`)
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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,8 +331,48 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
324
331
|
)
|
|
325
332
|
ri.set_defaults(func=cmd_reinstate)
|
|
326
333
|
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
|
|
363
|
+
pf = sub.add_parser("portfolio", help="roll up many boards into one scoreboard")
|
|
364
|
+
pf.add_argument(
|
|
365
|
+
"configs",
|
|
366
|
+
nargs="+",
|
|
367
|
+
metavar="PATH",
|
|
368
|
+
help="prodc.toml paths and/or directories to discover boards under",
|
|
369
|
+
)
|
|
370
|
+
pf.add_argument(
|
|
371
|
+
"--max-depth",
|
|
372
|
+
type=int,
|
|
373
|
+
default=4,
|
|
374
|
+
help="how deep to descend when discovering boards under a directory (default 4)",
|
|
375
|
+
)
|
|
329
376
|
pf.add_argument("--json", action="store_true", help="machine-readable output")
|
|
330
377
|
pf.set_defaults(func=cmd_portfolio)
|
|
331
378
|
|
|
@@ -50,13 +50,22 @@ from ..writer import (
|
|
|
50
50
|
add_role,
|
|
51
51
|
attest,
|
|
52
52
|
capture_source,
|
|
53
|
+
delete_need,
|
|
53
54
|
link_variant_group,
|
|
54
55
|
reinstate,
|
|
55
56
|
set_field,
|
|
56
57
|
set_importance,
|
|
57
58
|
withdraw,
|
|
58
59
|
)
|
|
59
|
-
from .common import
|
|
60
|
+
from .common import (
|
|
61
|
+
RepoRow,
|
|
62
|
+
discover_configs,
|
|
63
|
+
emit_edit,
|
|
64
|
+
exit_code,
|
|
65
|
+
git_rev,
|
|
66
|
+
load_or_report,
|
|
67
|
+
portfolio_totals,
|
|
68
|
+
)
|
|
60
69
|
from .render import (
|
|
61
70
|
COLUMNS,
|
|
62
71
|
provenance_counts,
|
|
@@ -128,6 +137,47 @@ def _annotate_importance_warnings(results: list[NeedResult], cfg: Config) -> Non
|
|
|
128
137
|
)
|
|
129
138
|
|
|
130
139
|
|
|
140
|
+
def _annotate_draft_age_warnings(results: list[NeedResult], cfg: Config) -> None:
|
|
141
|
+
"""Flag staged drafts older than `[lint].draft_max_age_days`, so a batch can't be forgotten (#60).
|
|
142
|
+
|
|
143
|
+
Mirrors the importance-revisit check: a draft with no `draft_added` stamp is flagged undated
|
|
144
|
+
(it cannot be aged, so it is not silently exempt); a dated draft past the limit is flagged with
|
|
145
|
+
its age. Only runs when the knob is set.
|
|
146
|
+
"""
|
|
147
|
+
if cfg.draft_max_age_days is None:
|
|
148
|
+
return
|
|
149
|
+
today = date.today()
|
|
150
|
+
for r in results:
|
|
151
|
+
if not r.need.draft or r.need.is_withdrawn:
|
|
152
|
+
continue
|
|
153
|
+
when = r.need.draft_added
|
|
154
|
+
if when is None:
|
|
155
|
+
r.warnings.append("draft with no staged date (`add --draft` stamps one) — #60")
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
days = (today - date.fromisoformat(when)).days
|
|
159
|
+
except ValueError:
|
|
160
|
+
continue
|
|
161
|
+
if days > cfg.draft_max_age_days:
|
|
162
|
+
r.warnings.append(
|
|
163
|
+
f"draft staged {days}d ago (> {cfg.draft_max_age_days}d) — promote or drop (#60)"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _annotate_label_deprecation(results: list[NeedResult]) -> None:
|
|
168
|
+
"""Nudge needs still carrying the free-text `label` toward role + client (#52, deprecation).
|
|
169
|
+
|
|
170
|
+
Since Role (#52) carries the archetype and `client` the org, the per-need `label` is redundant —
|
|
171
|
+
"design partner, Sincerely" is `role` + `client`. Soft only: `label` is still parsed and still
|
|
172
|
+
renders; this flags it so a board can migrate off it before it is removed.
|
|
173
|
+
"""
|
|
174
|
+
for r in results:
|
|
175
|
+
if r.need.label:
|
|
176
|
+
r.warnings.append(
|
|
177
|
+
f"'label' ({r.need.label!r}) is deprecated — express it with role + client (#52)"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
131
181
|
def _lint_errors(results: list[NeedResult]) -> list[str]:
|
|
132
182
|
"""Hard problems a lint gate fails on: a dangling proof, or a must-have with no evidence."""
|
|
133
183
|
errors: list[str] = []
|
|
@@ -176,6 +226,8 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
176
226
|
f"who may name the same person: {whos} (needs {', '.join(v.need_ids)}) — #61"
|
|
177
227
|
)
|
|
178
228
|
_annotate_importance_warnings(results, cfg)
|
|
229
|
+
_annotate_draft_age_warnings(results, cfg)
|
|
230
|
+
_annotate_label_deprecation(results)
|
|
179
231
|
errors = _lint_errors(results)
|
|
180
232
|
warnings = [
|
|
181
233
|
(r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
|
|
@@ -523,6 +575,132 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
|
|
|
523
575
|
return emit_edit(edit, args.dry_run, args.json)
|
|
524
576
|
|
|
525
577
|
|
|
578
|
+
def cmd_review(args: argparse.Namespace) -> int:
|
|
579
|
+
"""Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
|
|
580
|
+
|
|
581
|
+
The companion to `add --draft`: drafts pile up staged off the board, and this is the surface
|
|
582
|
+
that clears them. With no action flag it lists each draft with its provenance and the nearest
|
|
583
|
+
existing need (a dedup score, reusing #55), so a reviewer can see what is a duplicate before
|
|
584
|
+
promoting. `--promote` flips `draft=false` (sugar over `set`); `--drop` deletes the draft
|
|
585
|
+
outright (the #60 delete-need path). Both act only on drafts — a real need is withdrawn, not
|
|
586
|
+
dropped, so its record survives.
|
|
587
|
+
"""
|
|
588
|
+
loaded = load_or_report(args)
|
|
589
|
+
if loaded is None:
|
|
590
|
+
return 2
|
|
591
|
+
cfg, model = loaded
|
|
592
|
+
needs = list(model.needs)
|
|
593
|
+
drafts = [n for n in needs if n.draft and not n.is_withdrawn]
|
|
594
|
+
draft_ids = {n.id for n in drafts}
|
|
595
|
+
|
|
596
|
+
drop = list(args.drop or [])
|
|
597
|
+
if args.promote_all:
|
|
598
|
+
promote = [n.id for n in drafts if n.id not in drop]
|
|
599
|
+
else:
|
|
600
|
+
promote = list(args.promote or [])
|
|
601
|
+
|
|
602
|
+
unknown = [i for i in (*promote, *drop) if i not in draft_ids]
|
|
603
|
+
if unknown:
|
|
604
|
+
print(
|
|
605
|
+
f"prodc: not a staged draft: {', '.join(unknown)} "
|
|
606
|
+
"(review acts only on drafts — withdraw a real need instead)",
|
|
607
|
+
file=sys.stderr,
|
|
608
|
+
)
|
|
609
|
+
return 2
|
|
610
|
+
both = sorted(set(promote) & set(drop))
|
|
611
|
+
if both:
|
|
612
|
+
print(f"prodc: {', '.join(both)} named in both --promote and --drop", file=sys.stderr)
|
|
613
|
+
return 2
|
|
614
|
+
|
|
615
|
+
if not promote and not drop:
|
|
616
|
+
return _review_list(cfg, drafts, needs, args.json)
|
|
617
|
+
return _review_apply(cfg, promote, drop, args)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _review_list(cfg: Config, drafts: list[Need], needs: list[Need], as_json: bool) -> int:
|
|
621
|
+
"""List the staged drafts with provenance and the nearest existing need (read-only)."""
|
|
622
|
+
rows: list[dict[str, object]] = []
|
|
623
|
+
for d in sorted(drafts, key=lambda n: n.id):
|
|
624
|
+
others = [n for n in needs if n.id != d.id]
|
|
625
|
+
hits = matches_for(d.text, others, cfg.duplicate_threshold, exclude_id=d.id)
|
|
626
|
+
dup = {"id": hits[0][0].id, "score": round(hits[0][1], 4)} if hits else None
|
|
627
|
+
rows.append(
|
|
628
|
+
{
|
|
629
|
+
"id": d.id,
|
|
630
|
+
"text": d.text,
|
|
631
|
+
"who": d.who,
|
|
632
|
+
"client": d.client,
|
|
633
|
+
"sources": [s.kind.value for s in d.sources],
|
|
634
|
+
"staged": d.draft_added,
|
|
635
|
+
"duplicate": dup,
|
|
636
|
+
}
|
|
637
|
+
)
|
|
638
|
+
if as_json:
|
|
639
|
+
print(json.dumps({"drafts": rows}, indent=2))
|
|
640
|
+
return 0
|
|
641
|
+
for r in rows:
|
|
642
|
+
who = f" — {r['who']}" if r["who"] else ""
|
|
643
|
+
client = f" [{r['client']}]" if r["client"] else ""
|
|
644
|
+
staged = f" (staged {r['staged']})" if r["staged"] else ""
|
|
645
|
+
print(f"{r['id']}{who}{client}{staged}: {r['text']}")
|
|
646
|
+
kinds = cast("list[str]", r["sources"])
|
|
647
|
+
if kinds:
|
|
648
|
+
print(f" sources: {', '.join(kinds)}")
|
|
649
|
+
dup = cast("dict[str, object] | None", r["duplicate"])
|
|
650
|
+
if dup is not None:
|
|
651
|
+
print(f" possible duplicate of {dup['id']} ({dup['score']:.2f}) — #55")
|
|
652
|
+
print(f"review: {len(rows)} draft(s) staged")
|
|
653
|
+
return 0
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _review_apply(
|
|
657
|
+
cfg: Config, promote: list[str], drop: list[str], args: argparse.Namespace
|
|
658
|
+
) -> int:
|
|
659
|
+
"""Promote (`draft=false`) and/or drop (delete) the named drafts, each a validated edit (#60).
|
|
660
|
+
|
|
661
|
+
Sequential and independent, like `suggest --apply`: each need is one reload-gated, single-file
|
|
662
|
+
write applied in id order, and a rejection aborts with a non-zero exit (earlier writes stand).
|
|
663
|
+
"""
|
|
664
|
+
applied: list[tuple[str, str, Edit]] = [] # (action, need_id, edit)
|
|
665
|
+
try:
|
|
666
|
+
for nid in sorted(promote):
|
|
667
|
+
edit = set_field(cfg, nid, "draft=false", expect_sha=None, dry_run=args.dry_run)
|
|
668
|
+
applied.append(("promote", nid, edit))
|
|
669
|
+
for nid in sorted(drop):
|
|
670
|
+
edit = delete_need(cfg, nid, dry_run=args.dry_run)
|
|
671
|
+
applied.append(("drop", nid, edit))
|
|
672
|
+
except WriteError as exc:
|
|
673
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
674
|
+
return 2
|
|
675
|
+
if args.json:
|
|
676
|
+
print(
|
|
677
|
+
json.dumps(
|
|
678
|
+
[
|
|
679
|
+
{
|
|
680
|
+
"action": action,
|
|
681
|
+
"need": nid,
|
|
682
|
+
"file": edit.path.name,
|
|
683
|
+
"dry_run": args.dry_run,
|
|
684
|
+
"diff": edit.diff,
|
|
685
|
+
}
|
|
686
|
+
for action, nid, edit in applied
|
|
687
|
+
],
|
|
688
|
+
indent=2,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
return 0
|
|
692
|
+
for action, nid, edit in applied:
|
|
693
|
+
verb = "would promote" if action == "promote" else "would drop"
|
|
694
|
+
if not args.dry_run:
|
|
695
|
+
verb = "promoted" if action == "promote" else "dropped"
|
|
696
|
+
print(f"{verb} {nid} in {edit.path.name}")
|
|
697
|
+
print(edit.diff, end="")
|
|
698
|
+
n_prom = sum(1 for row in applied if row[0] == "promote")
|
|
699
|
+
n_drop = len(applied) - n_prom
|
|
700
|
+
print(f"review: {n_prom} promoted, {n_drop} dropped" + (" (dry-run)" if args.dry_run else ""))
|
|
701
|
+
return 0
|
|
702
|
+
|
|
703
|
+
|
|
526
704
|
def cmd_capture(args: argparse.Namespace) -> int:
|
|
527
705
|
"""Append a Source to an existing need (capture evidence)."""
|
|
528
706
|
cfg_path = Path(args.config)
|
|
@@ -536,6 +714,7 @@ def cmd_capture(args: argparse.Namespace) -> int:
|
|
|
536
714
|
args.need_id,
|
|
537
715
|
kind=args.kind,
|
|
538
716
|
who=args.who,
|
|
717
|
+
role=args.role,
|
|
539
718
|
date=args.date,
|
|
540
719
|
quote=args.quote,
|
|
541
720
|
locator=args.locator,
|
|
@@ -565,7 +744,9 @@ def _repo_row(config_path: str) -> RepoRow:
|
|
|
565
744
|
name = p.parent.name or str(p)
|
|
566
745
|
try:
|
|
567
746
|
cfg = Config.load(p)
|
|
568
|
-
|
|
747
|
+
# Drafts (#60) and withdrawn needs (#74) are off the counted board, same as `status`/`plan`,
|
|
748
|
+
# so a repo's portfolio line never inflates with staged ideas.
|
|
749
|
+
needs = board_needs(list(load(cfg).needs), include_drafts=False)
|
|
569
750
|
except (ConfigError, LoaderError, OSError) as exc:
|
|
570
751
|
return RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
|
|
571
752
|
results = resolve(needs, cfg.adapters, cfg.source_adapters)
|
|
@@ -582,12 +763,33 @@ def _repo_row(config_path: str) -> RepoRow:
|
|
|
582
763
|
|
|
583
764
|
|
|
584
765
|
def cmd_portfolio(args: argparse.Namespace) -> int:
|
|
585
|
-
"""Roll up many
|
|
586
|
-
|
|
766
|
+
"""Roll up many boards into one scoreboard — coverage, must-HOLEs, provenance per board.
|
|
767
|
+
|
|
768
|
+
Each argument is a ``prodc.toml`` path or a directory to discover boards under (every
|
|
769
|
+
``prodc.toml`` beneath it, pruning VCS/vendor/build and hidden dirs, capped at ``--max-depth``).
|
|
770
|
+
Boards stay separate rows; a totals footer aggregates the clean ones. Never a merged board.
|
|
771
|
+
"""
|
|
772
|
+
paths: list[str] = []
|
|
773
|
+
for arg in args.configs:
|
|
774
|
+
p = Path(arg)
|
|
775
|
+
if p.is_dir():
|
|
776
|
+
paths.extend(str(c) for c in discover_configs(p, max_depth=args.max_depth))
|
|
777
|
+
else:
|
|
778
|
+
paths.append(arg)
|
|
779
|
+
seen: set[str] = set()
|
|
780
|
+
unique: list[str] = []
|
|
781
|
+
for path in paths:
|
|
782
|
+
key = str(Path(path).resolve())
|
|
783
|
+
if key not in seen:
|
|
784
|
+
seen.add(key)
|
|
785
|
+
unique.append(path)
|
|
786
|
+
rows = [_repo_row(path) for path in unique]
|
|
787
|
+
totals = portfolio_totals(rows)
|
|
587
788
|
if args.json:
|
|
588
|
-
|
|
789
|
+
payload = {"boards": [asdict(r) for r in rows], "totals": asdict(totals)}
|
|
790
|
+
print(json.dumps(payload, indent=2))
|
|
589
791
|
else:
|
|
590
|
-
print(render_portfolio(rows))
|
|
792
|
+
print(render_portfolio(rows, totals))
|
|
591
793
|
return 2 if any(r.error is not None for r in rows) else 0
|
|
592
794
|
|
|
593
795
|
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
12
|
import json
|
|
13
|
+
import os
|
|
13
14
|
import sys
|
|
14
15
|
from dataclasses import dataclass
|
|
15
16
|
from pathlib import Path
|
|
@@ -100,3 +101,79 @@ class RepoRow:
|
|
|
100
101
|
coverage: float
|
|
101
102
|
provenance: dict[str, int]
|
|
102
103
|
error: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class PortfolioTotals:
|
|
108
|
+
"""The aggregate across every board in a portfolio — a footer, not a merged board.
|
|
109
|
+
|
|
110
|
+
Error rows are excluded (a board that would not load contributes no counts). ``coverage`` is
|
|
111
|
+
the pooled ``delivered / total``, which is not the mean of the per-board percentages.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
boards: int # how many boards loaded cleanly and are summed here
|
|
115
|
+
delivered: int
|
|
116
|
+
total: int
|
|
117
|
+
must_hole: int
|
|
118
|
+
broken: int
|
|
119
|
+
coverage: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def portfolio_totals(rows: list[RepoRow]) -> PortfolioTotals:
|
|
123
|
+
"""Sum the clean rows into a :class:`PortfolioTotals` footer."""
|
|
124
|
+
ok = [r for r in rows if r.error is None]
|
|
125
|
+
delivered = sum(r.delivered for r in ok)
|
|
126
|
+
total = sum(r.total for r in ok)
|
|
127
|
+
return PortfolioTotals(
|
|
128
|
+
boards=len(ok),
|
|
129
|
+
delivered=delivered,
|
|
130
|
+
total=total,
|
|
131
|
+
must_hole=sum(r.must_hole for r in ok),
|
|
132
|
+
broken=sum(r.broken for r in ok),
|
|
133
|
+
coverage=round(delivered / total, 4) if total else 0.0,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Directory names never descended into during board discovery: VCS, virtualenvs, dependency and
|
|
138
|
+
# build trees. Any name starting with "." is pruned too (handled in the walk), so a vendored or
|
|
139
|
+
# example prodc.toml buried in one of these is never mistaken for a board.
|
|
140
|
+
_PRUNE_DIRS = frozenset(
|
|
141
|
+
{
|
|
142
|
+
".git",
|
|
143
|
+
"node_modules",
|
|
144
|
+
".venv",
|
|
145
|
+
"venv",
|
|
146
|
+
"__pycache__",
|
|
147
|
+
"dist",
|
|
148
|
+
"build",
|
|
149
|
+
"target",
|
|
150
|
+
".tox",
|
|
151
|
+
".mypy_cache",
|
|
152
|
+
".ruff_cache",
|
|
153
|
+
"site-packages",
|
|
154
|
+
"vendor",
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def discover_configs(root: Path, *, max_depth: int = 4) -> list[Path]:
|
|
160
|
+
"""Every ``prodc.toml`` under ``root``, for the portfolio roll-up — bounded so it can't run away.
|
|
161
|
+
|
|
162
|
+
A plain recursive scan is a footgun: it descends into ``.git``/``node_modules``/build trees and
|
|
163
|
+
picks up vendored or example configs. So this prunes those (and every ``.``-prefixed dir) and
|
|
164
|
+
caps the descent at ``max_depth`` directories below ``root``. Returns sorted, de-duplicated
|
|
165
|
+
paths; a board is the directory that holds the ``prodc.toml``.
|
|
166
|
+
"""
|
|
167
|
+
root = root.resolve()
|
|
168
|
+
found: set[Path] = set()
|
|
169
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
170
|
+
if "prodc.toml" in filenames:
|
|
171
|
+
found.add(Path(dirpath) / "prodc.toml")
|
|
172
|
+
depth = len(Path(dirpath).relative_to(root).parts)
|
|
173
|
+
# Prune in place so os.walk never descends into the skipped dirs, then stop at the depth cap.
|
|
174
|
+
dirnames[:] = (
|
|
175
|
+
[]
|
|
176
|
+
if depth >= max_depth
|
|
177
|
+
else [d for d in dirnames if d not in _PRUNE_DIRS and not d.startswith(".")]
|
|
178
|
+
)
|
|
179
|
+
return sorted(found)
|
|
@@ -22,7 +22,7 @@ from ..model import (
|
|
|
22
22
|
ears_shape,
|
|
23
23
|
)
|
|
24
24
|
from ..status import Handoff, Recurrence, Summary, rank, state_priority
|
|
25
|
-
from .common import RepoRow, brief_open
|
|
25
|
+
from .common import PortfolioTotals, RepoRow, brief_open
|
|
26
26
|
|
|
27
27
|
_LABEL = {
|
|
28
28
|
NeedState.DELIVERED: "DELIVERED",
|
|
@@ -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 (
|
|
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)
|
|
@@ -380,7 +385,9 @@ def render_argument(model: Model, defects: list[str]) -> str:
|
|
|
380
385
|
return "\n".join(out)
|
|
381
386
|
|
|
382
387
|
|
|
383
|
-
def render_portfolio(rows: list[RepoRow]) -> str:
|
|
388
|
+
def render_portfolio(rows: list[RepoRow], totals: PortfolioTotals | None = None) -> str:
|
|
389
|
+
if not rows:
|
|
390
|
+
return " no boards found"
|
|
384
391
|
out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov prim/sec/asm/op"]
|
|
385
392
|
for row in rows:
|
|
386
393
|
if row.error is not None:
|
|
@@ -390,4 +397,12 @@ def render_portfolio(rows: list[RepoRow]) -> str:
|
|
|
390
397
|
prov = f"{p['primary']}/{p['secondary']}/{p['assumption']}/{p['opinion']}"
|
|
391
398
|
cov = f"{round(row.coverage * 100)}%"
|
|
392
399
|
out.append(f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}")
|
|
400
|
+
if totals is not None:
|
|
401
|
+
cov = f"{round(totals.coverage * 100)}%"
|
|
402
|
+
label = f"TOTAL ({totals.boards} boards)"
|
|
403
|
+
out.append(f" {'─' * 48}")
|
|
404
|
+
out.append(
|
|
405
|
+
f" {label:<18} {cov:>4} {totals.must_hole:>9} {totals.broken:>6} "
|
|
406
|
+
f"{totals.delivered}/{totals.total} delivered"
|
|
407
|
+
)
|
|
393
408
|
return "\n".join(out)
|
|
@@ -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
|
|