prodc 0.3.78__tar.gz → 0.3.79__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.78 → prodc-0.3.79}/PKG-INFO +1 -1
- {prodc-0.3.78 → prodc-0.3.79}/pyproject.toml +1 -1
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/agent.md +2 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/board.py +8 -1
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/app.py +42 -4
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/commands.py +61 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/render.py +22 -2
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/serialize.py +12 -1
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/loader.py +51 -16
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/model.py +27 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/status.py +48 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/writer.py +120 -20
- prodc-0.3.79/tests/test_principles.py +332 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_withdrawn.py +1 -0
- {prodc-0.3.78 → prodc-0.3.79}/.gitignore +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/CHANGELOG.md +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/LICENSE +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/README.md +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/__init__.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/adapters.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/config.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/dedup.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/py.typed +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/report.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/src/prodc/report_html.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_adapters.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_answer.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_callbacks.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_citation.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_cli.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_command_hook.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_dedup.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_description.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_drafts.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_evalgate.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_importance.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_loader.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_models.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_needref.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_relink.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_report.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_report_html.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_review.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_roles.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_source_role.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_sources.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_staleness.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_status.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_suggest.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_variants.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_who_variants.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/tests/test_writer.py +0 -0
- {prodc-0.3.78 → prodc-0.3.79}/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.79
|
|
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
|
|
@@ -72,6 +72,8 @@ Read first, then act, then check.
|
|
|
72
72
|
| record a new need (with honest provenance) | `prodc add need <id> --text "…" --who <you> --kind assumption --file needs/<f>.yaml` |
|
|
73
73
|
| triage staged drafts | `prodc review` — list them; `--promote <id>…`/`--promote-all` onto the board, `--drop <id>…` to delete |
|
|
74
74
|
| declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
|
|
75
|
+
| declare a product principle (the vision, held as given) | `prodc add principle <id> --text "…" --file needs/<f>.yaml` — the frame that decides between clients who want opposite things; what an `out_of_scope` decision cites |
|
|
76
|
+
| revise a principle (fires the re-review ripple) | `prodc revise principle <id> [--text "…"] [--date ISO]` — re-stamps `revised_at`, so every need withdrawn under it with an earlier date comes back up |
|
|
75
77
|
| 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) |
|
|
76
78
|
| set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
|
|
77
79
|
| propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
|
|
@@ -28,6 +28,7 @@ from .model import (
|
|
|
28
28
|
)
|
|
29
29
|
from .status import (
|
|
30
30
|
Handoff,
|
|
31
|
+
PrincipleReport,
|
|
31
32
|
Recurrence,
|
|
32
33
|
Summary,
|
|
33
34
|
evidence_age_days,
|
|
@@ -36,6 +37,7 @@ from .status import (
|
|
|
36
37
|
handoffs,
|
|
37
38
|
last_observed,
|
|
38
39
|
orphans,
|
|
40
|
+
principle_report,
|
|
39
41
|
recurrence,
|
|
40
42
|
release_report,
|
|
41
43
|
resolve,
|
|
@@ -106,6 +108,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
106
108
|
"reason": need.withdrawn.reason,
|
|
107
109
|
"date": need.withdrawn.date,
|
|
108
110
|
"kind": need.withdrawn.kind.value,
|
|
111
|
+
"principle": need.withdrawn.principle,
|
|
109
112
|
}
|
|
110
113
|
if need.withdrawn
|
|
111
114
|
else None
|
|
@@ -158,6 +161,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
158
161
|
"reason": need.withdrawn.reason,
|
|
159
162
|
"date": need.withdrawn.date,
|
|
160
163
|
"kind": need.withdrawn.kind.value,
|
|
164
|
+
"principle": need.withdrawn.principle,
|
|
161
165
|
}
|
|
162
166
|
if need.withdrawn
|
|
163
167
|
else None
|
|
@@ -202,6 +206,7 @@ class ResolvedBoard:
|
|
|
202
206
|
handoffs: list[Handoff]
|
|
203
207
|
recurrence: list[Recurrence]
|
|
204
208
|
orphans: dict[str, list[str]]
|
|
209
|
+
principles: list[PrincipleReport] # over the whole model — declined needs are off the board
|
|
205
210
|
|
|
206
211
|
|
|
207
212
|
def gather(
|
|
@@ -218,7 +223,8 @@ def gather(
|
|
|
218
223
|
appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
|
|
219
224
|
the markdown/HTML reports leave it off, exactly as they did before this was factored out.
|
|
220
225
|
The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
|
|
221
|
-
are computed over the resolved ``needs``.
|
|
226
|
+
are computed over the resolved ``needs``. The principle report reads the whole ``model.needs``:
|
|
227
|
+
the needs declined under a principle are withdrawn, so the filtered ``needs`` never holds them.
|
|
222
228
|
"""
|
|
223
229
|
use_needs = list(model.needs) if needs is None else needs
|
|
224
230
|
results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
|
|
@@ -239,4 +245,5 @@ def gather(
|
|
|
239
245
|
handoffs=handoffs(use_needs),
|
|
240
246
|
recurrence=recurrence(use_needs),
|
|
241
247
|
orphans=orphans(use_needs, cfg.adapters),
|
|
248
|
+
principles=principle_report(list(model.needs), list(model.principles)),
|
|
242
249
|
)
|
|
@@ -29,6 +29,7 @@ from .commands import (
|
|
|
29
29
|
cmd_reinstate,
|
|
30
30
|
cmd_report,
|
|
31
31
|
cmd_review,
|
|
32
|
+
cmd_revise,
|
|
32
33
|
cmd_set,
|
|
33
34
|
cmd_show,
|
|
34
35
|
cmd_status,
|
|
@@ -165,11 +166,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
165
166
|
st2.set_defaults(func=cmd_set)
|
|
166
167
|
|
|
167
168
|
ad = sub.add_parser(
|
|
168
|
-
"add",
|
|
169
|
+
"add",
|
|
170
|
+
help="create a new agent-authored need, or declare a role or a principle (writes YAML)",
|
|
169
171
|
)
|
|
170
|
-
ad.add_argument("noun", choices=["need", "role"], help="what to add
|
|
171
|
-
ad.add_argument(
|
|
172
|
-
|
|
172
|
+
ad.add_argument("noun", choices=["need", "role", "principle"], help="what to add")
|
|
173
|
+
ad.add_argument(
|
|
174
|
+
"need_id",
|
|
175
|
+
help="the new need's id (or role id for `add role`, principle id for `add principle`), e.g. N-9",
|
|
176
|
+
)
|
|
177
|
+
ad.add_argument("--text", required=True, help="the need text (or the role / principle text)")
|
|
173
178
|
ad.add_argument(
|
|
174
179
|
"--who",
|
|
175
180
|
default=None,
|
|
@@ -315,6 +320,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
315
320
|
"or duplicate (#74)",
|
|
316
321
|
)
|
|
317
322
|
wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
|
|
323
|
+
wd.add_argument(
|
|
324
|
+
"--principle",
|
|
325
|
+
default=None,
|
|
326
|
+
metavar="ID",
|
|
327
|
+
help="the product principle this decision rests on (validated against principles:) — revising it "
|
|
328
|
+
"later flags this decision for re-review",
|
|
329
|
+
)
|
|
318
330
|
wd.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
319
331
|
wd.add_argument("--json", action="store_true", help="machine-readable output")
|
|
320
332
|
wd.add_argument(
|
|
@@ -345,6 +357,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
345
357
|
)
|
|
346
358
|
ri.set_defaults(func=cmd_reinstate)
|
|
347
359
|
|
|
360
|
+
rvz = sub.add_parser(
|
|
361
|
+
"revise", help="revise a product principle in place — new text and/or a fresh revised_at"
|
|
362
|
+
)
|
|
363
|
+
rvz.add_argument("noun", choices=["principle"], help="what to revise (principle)")
|
|
364
|
+
rvz.add_argument("principle_id", help="the principle id to revise, e.g. P-1")
|
|
365
|
+
rvz.add_argument(
|
|
366
|
+
"--text", default=None, help="new principle text (optional; omit to only re-date it)"
|
|
367
|
+
)
|
|
368
|
+
rvz.add_argument(
|
|
369
|
+
"--date",
|
|
370
|
+
default=None,
|
|
371
|
+
help="the new revised_at (ISO, e.g. 2026-10-15; default: today) — fires the ripple",
|
|
372
|
+
)
|
|
373
|
+
rvz.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
374
|
+
rvz.add_argument("--json", action="store_true", help="machine-readable output")
|
|
375
|
+
rvz.add_argument(
|
|
376
|
+
"--dry-run", action="store_true", help="validate and show the diff, don't write"
|
|
377
|
+
)
|
|
378
|
+
rvz.add_argument(
|
|
379
|
+
"--expect-sha",
|
|
380
|
+
default=None,
|
|
381
|
+
metavar="SHA256",
|
|
382
|
+
help="refuse if the file's content hash differs (optimistic lock)",
|
|
383
|
+
)
|
|
384
|
+
rvz.set_defaults(func=cmd_revise)
|
|
385
|
+
|
|
348
386
|
rv = sub.add_parser(
|
|
349
387
|
"review", help="triage staged drafts: list them, or --promote / --drop a batch (#60)"
|
|
350
388
|
)
|
|
@@ -38,6 +38,7 @@ from ..status import (
|
|
|
38
38
|
argument_defects,
|
|
39
39
|
evidence_age_days,
|
|
40
40
|
importance_revisit_due,
|
|
41
|
+
principle_report,
|
|
41
42
|
proof_suggestions,
|
|
42
43
|
recurrence,
|
|
43
44
|
relink_suggestions,
|
|
@@ -49,6 +50,7 @@ from ..writer import (
|
|
|
49
50
|
WriteError,
|
|
50
51
|
accept,
|
|
51
52
|
add_need,
|
|
53
|
+
add_principle,
|
|
52
54
|
add_proof,
|
|
53
55
|
add_role,
|
|
54
56
|
attest,
|
|
@@ -57,6 +59,7 @@ from ..writer import (
|
|
|
57
59
|
link_variant_group,
|
|
58
60
|
reinstate,
|
|
59
61
|
relink_proof,
|
|
62
|
+
revise_principle,
|
|
60
63
|
set_field,
|
|
61
64
|
set_importance,
|
|
62
65
|
withdraw,
|
|
@@ -83,6 +86,7 @@ from .render import (
|
|
|
83
86
|
render_out_of_scope,
|
|
84
87
|
render_plan,
|
|
85
88
|
render_portfolio,
|
|
89
|
+
render_principles,
|
|
86
90
|
render_recurrence,
|
|
87
91
|
render_releases,
|
|
88
92
|
render_roles,
|
|
@@ -232,6 +236,16 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
232
236
|
_annotate_importance_warnings(results, cfg)
|
|
233
237
|
_annotate_draft_age_warnings(results, cfg)
|
|
234
238
|
_annotate_label_deprecation(results)
|
|
239
|
+
# The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
|
|
240
|
+
# comes back up — the frame it was judged against has changed since. A warning, never a hard
|
|
241
|
+
# error; prodc flags, the PM re-decides.
|
|
242
|
+
for rep in principle_report(needs, list(model.principles)):
|
|
243
|
+
for n in rep.stale:
|
|
244
|
+
if n.id in by_id and n.withdrawn is not None:
|
|
245
|
+
by_id[n.id].warnings.append(
|
|
246
|
+
f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
|
|
247
|
+
f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
|
|
248
|
+
)
|
|
235
249
|
errors = _lint_errors(results)
|
|
236
250
|
warnings = [
|
|
237
251
|
(r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
|
|
@@ -318,6 +332,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
318
332
|
role_cov,
|
|
319
333
|
unroled,
|
|
320
334
|
hos,
|
|
335
|
+
principles=board.principles,
|
|
321
336
|
)
|
|
322
337
|
)
|
|
323
338
|
else:
|
|
@@ -350,6 +365,8 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
350
365
|
print(render_handoffs(hos))
|
|
351
366
|
if recs:
|
|
352
367
|
print(render_recurrence(recs))
|
|
368
|
+
if model.principles:
|
|
369
|
+
print(render_principles(board.principles))
|
|
353
370
|
if orphan_map:
|
|
354
371
|
print(render_orphans(orphan_map))
|
|
355
372
|
|
|
@@ -479,6 +496,22 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
479
496
|
print(f"prodc: {exc}", file=sys.stderr)
|
|
480
497
|
return 2
|
|
481
498
|
return emit_edit(edit, args.dry_run, args.json)
|
|
499
|
+
if args.noun == "principle":
|
|
500
|
+
try:
|
|
501
|
+
cfg = Config.load(cfg_path)
|
|
502
|
+
edit = add_principle(
|
|
503
|
+
cfg,
|
|
504
|
+
args.need_id,
|
|
505
|
+
args.text,
|
|
506
|
+
file=args.file,
|
|
507
|
+
revised_at=date.today().isoformat(),
|
|
508
|
+
expect_sha=args.expect_sha,
|
|
509
|
+
dry_run=args.dry_run,
|
|
510
|
+
)
|
|
511
|
+
except (ConfigError, LoaderError, WriteError) as exc:
|
|
512
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
513
|
+
return 2
|
|
514
|
+
return emit_edit(edit, args.dry_run, args.json)
|
|
482
515
|
if args.who is None:
|
|
483
516
|
print("prodc: add need requires --who (the author recorded on the source)", file=sys.stderr)
|
|
484
517
|
return 2
|
|
@@ -578,6 +611,7 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
|
|
|
578
611
|
reason=args.reason,
|
|
579
612
|
date=args.date or date.today().isoformat(),
|
|
580
613
|
kind=args.kind,
|
|
614
|
+
principle=args.principle,
|
|
581
615
|
expect_sha=args.expect_sha,
|
|
582
616
|
dry_run=args.dry_run,
|
|
583
617
|
)
|
|
@@ -607,6 +641,33 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
|
|
|
607
641
|
return emit_edit(edit, args.dry_run, args.json)
|
|
608
642
|
|
|
609
643
|
|
|
644
|
+
def cmd_revise(args: argparse.Namespace) -> int:
|
|
645
|
+
"""Revise a product principle in place: optional new text, always a fresh revised_at (the ripple).
|
|
646
|
+
|
|
647
|
+
Re-stamping ``revised_at`` is the point — every need withdrawn citing this principle with an
|
|
648
|
+
earlier date comes back up for re-review (surfaced by `lint`). Comment-safe: only the principle's
|
|
649
|
+
own block is spliced, the rest of the file is untouched.
|
|
650
|
+
"""
|
|
651
|
+
cfg_path = Path(args.config)
|
|
652
|
+
if not cfg_path.exists():
|
|
653
|
+
print(f"prodc: no config at {cfg_path}", file=sys.stderr)
|
|
654
|
+
return 2
|
|
655
|
+
try:
|
|
656
|
+
cfg = Config.load(cfg_path)
|
|
657
|
+
edit = revise_principle(
|
|
658
|
+
cfg,
|
|
659
|
+
args.principle_id,
|
|
660
|
+
text=args.text,
|
|
661
|
+
revised_at=args.date or date.today().isoformat(),
|
|
662
|
+
expect_sha=args.expect_sha,
|
|
663
|
+
dry_run=args.dry_run,
|
|
664
|
+
)
|
|
665
|
+
except (ConfigError, LoaderError, WriteError) as exc:
|
|
666
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
667
|
+
return 2
|
|
668
|
+
return emit_edit(edit, args.dry_run, args.json)
|
|
669
|
+
|
|
670
|
+
|
|
610
671
|
def cmd_review(args: argparse.Namespace) -> int:
|
|
611
672
|
"""Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
|
|
612
673
|
|
|
@@ -21,7 +21,7 @@ from ..model import (
|
|
|
21
21
|
Role,
|
|
22
22
|
ears_shape,
|
|
23
23
|
)
|
|
24
|
-
from ..status import Handoff, Recurrence, Summary, rank, state_priority
|
|
24
|
+
from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank, state_priority
|
|
25
25
|
from .common import PortfolioTotals, RepoRow, brief_open
|
|
26
26
|
|
|
27
27
|
_LABEL = {
|
|
@@ -261,6 +261,25 @@ def render_recurrence(recs: list[Recurrence]) -> str:
|
|
|
261
261
|
return "\n".join(out)
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def render_principles(reports: list[PrincipleReport]) -> str:
|
|
265
|
+
"""The product vision as principles, with the decisions resting on each and which have gone stale.
|
|
266
|
+
|
|
267
|
+
An principle is held as given, not validated; ``declined`` are the needs withdrawn citing it, and
|
|
268
|
+
``stale`` (the ripple) are those decided before its latest ``revised_at`` — flagged to re-review.
|
|
269
|
+
"""
|
|
270
|
+
out = ["\nprinciples (the product vision — held as given):"]
|
|
271
|
+
for rep in reports:
|
|
272
|
+
a = rep.principle
|
|
273
|
+
rev = f"revised {a.revised_at}" if a.revised_at else "undated"
|
|
274
|
+
out.append(f" {a.id:<6} {rev} {a.text}")
|
|
275
|
+
if rep.declined:
|
|
276
|
+
out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
|
|
277
|
+
if rep.stale:
|
|
278
|
+
ids = ", ".join(n.id for n in rep.stale)
|
|
279
|
+
out.append(f" ⚠ decided before the latest revision: {ids} — re-review")
|
|
280
|
+
return "\n".join(out)
|
|
281
|
+
|
|
282
|
+
|
|
264
283
|
def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
265
284
|
"""One need as a citable answer — the artifact you drop into a thread."""
|
|
266
285
|
need = r.need
|
|
@@ -278,7 +297,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
278
297
|
if need.withdrawn:
|
|
279
298
|
w = need.withdrawn
|
|
280
299
|
tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}]"
|
|
281
|
-
|
|
300
|
+
per = f" per {w.principle}" if w.principle else ""
|
|
301
|
+
lines.append(f" withdrawn{tag}{per}: {w.reason} ({w.date})")
|
|
282
302
|
if need.description:
|
|
283
303
|
lines.append(f" description: {need.description}")
|
|
284
304
|
if need.importance is not None:
|
|
@@ -13,7 +13,7 @@ from ..board import need_payload
|
|
|
13
13
|
from ..board import warrant as _warrant
|
|
14
14
|
from ..dedup import DupPair, VariantGroup
|
|
15
15
|
from ..model import Goal, Model, NeedResult, NeedState, Release, Role
|
|
16
|
-
from ..status import Handoff, Recurrence, Summary, rank
|
|
16
|
+
from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
|
|
17
17
|
from .common import brief_open
|
|
18
18
|
|
|
19
19
|
|
|
@@ -43,6 +43,7 @@ def to_json(
|
|
|
43
43
|
role_cov: list[tuple[Role, Summary]],
|
|
44
44
|
unroled: list[NeedResult],
|
|
45
45
|
hos: list[Handoff],
|
|
46
|
+
principles: list[PrincipleReport] | None = None,
|
|
46
47
|
) -> str:
|
|
47
48
|
payload = {
|
|
48
49
|
"summary": _summary_payload(summary, orphan_map),
|
|
@@ -100,6 +101,16 @@ def to_json(
|
|
|
100
101
|
for goal, gsum in goal_cov
|
|
101
102
|
],
|
|
102
103
|
"ungoaled": [r.need.id for r in ungoaled],
|
|
104
|
+
"principles": [
|
|
105
|
+
{
|
|
106
|
+
"id": rep.principle.id,
|
|
107
|
+
"text": rep.principle.text,
|
|
108
|
+
"revised_at": rep.principle.revised_at,
|
|
109
|
+
"declined": [n.id for n in rep.declined],
|
|
110
|
+
"stale": [n.id for n in rep.stale],
|
|
111
|
+
}
|
|
112
|
+
for rep in (principles or [])
|
|
113
|
+
],
|
|
103
114
|
"needs": [need_payload(r) for r in results],
|
|
104
115
|
}
|
|
105
116
|
return json.dumps(payload, indent=2)
|
|
@@ -25,6 +25,7 @@ from .model import (
|
|
|
25
25
|
Goal,
|
|
26
26
|
Model,
|
|
27
27
|
Need,
|
|
28
|
+
Principle,
|
|
28
29
|
Release,
|
|
29
30
|
Role,
|
|
30
31
|
Source,
|
|
@@ -131,12 +132,13 @@ def _answer(raw: object, where: str) -> Answer | None:
|
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
134
|
-
"""Parse ``withdrawn: {reason, date, kind?}`` into a :class:`Withdrawal` (#74).
|
|
135
|
+
"""Parse ``withdrawn: {reason, date, kind?, principle?}`` into a :class:`Withdrawal` (#74).
|
|
135
136
|
|
|
136
137
|
``reason`` and ``date`` are required when the key is present — a decision against a need must
|
|
137
138
|
say why and when, the same way an attestation needs who and when. ``kind`` is optional and
|
|
138
139
|
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.
|
|
140
|
+
as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns. ``principle`` is
|
|
141
|
+
an optional Principle id, checked against the declared principles once every file is loaded.
|
|
140
142
|
"""
|
|
141
143
|
if raw is None:
|
|
142
144
|
return None
|
|
@@ -153,7 +155,8 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
|
153
155
|
raise LoaderError(
|
|
154
156
|
f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})"
|
|
155
157
|
) from None
|
|
156
|
-
|
|
158
|
+
principle = _opt_str(tbl.get("principle"), f"{where}.principle")
|
|
159
|
+
return Withdrawal(reason=reason, date=date, kind=kind, principle=principle)
|
|
157
160
|
|
|
158
161
|
|
|
159
162
|
# Old source-kind names (pre-0.3.58) mapped to the primary/secondary/assumption ladder, so
|
|
@@ -304,6 +307,20 @@ def _role(raw: object, where: str) -> Role:
|
|
|
304
307
|
)
|
|
305
308
|
|
|
306
309
|
|
|
310
|
+
def _principle(raw: object, where: str) -> Principle:
|
|
311
|
+
"""Parse one ``principles:`` entry — a product commitment with its provenance."""
|
|
312
|
+
tbl = _as_mapping(raw, where)
|
|
313
|
+
return Principle(
|
|
314
|
+
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
315
|
+
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
316
|
+
revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
|
|
317
|
+
sources=tuple(
|
|
318
|
+
_source(s, f"{where}.sources[{i}]")
|
|
319
|
+
for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
307
324
|
def _arg_node(raw: object, where: str) -> ArgNode:
|
|
308
325
|
tbl = _as_mapping(raw, where)
|
|
309
326
|
kind_name = _as_str(tbl.get("kind"), f"{where}.kind")
|
|
@@ -336,11 +353,12 @@ class _Loaded:
|
|
|
336
353
|
goals: list[Goal] = field(default_factory=list[Goal])
|
|
337
354
|
releases: list[Release] = field(default_factory=list[Release])
|
|
338
355
|
roles: list[Role] = field(default_factory=list[Role])
|
|
356
|
+
principles: list[Principle] = field(default_factory=list[Principle])
|
|
339
357
|
argument: list[ArgNode] = field(default_factory=list[ArgNode])
|
|
340
358
|
|
|
341
359
|
|
|
342
360
|
def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
|
|
343
|
-
"""Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``
|
|
361
|
+
"""Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``principles:``/…)."""
|
|
344
362
|
where = path.name
|
|
345
363
|
try:
|
|
346
364
|
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
@@ -376,6 +394,10 @@ def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
|
|
|
376
394
|
role = _role(raw, f"{where}.roles[{i}]")
|
|
377
395
|
_unique("role", role.id)
|
|
378
396
|
out.roles.append(role)
|
|
397
|
+
for i, raw in enumerate(_as_list(tbl.get("principles"), f"{where}.principles")):
|
|
398
|
+
principle = _principle(raw, f"{where}.principles[{i}]")
|
|
399
|
+
_unique("principle", principle.id)
|
|
400
|
+
out.principles.append(principle)
|
|
379
401
|
for i, raw in enumerate(_as_list(tbl.get("argument"), f"{where}.argument")):
|
|
380
402
|
node = _arg_node(raw, f"{where}.argument[{i}]")
|
|
381
403
|
_unique("argument node", node.id)
|
|
@@ -387,10 +409,11 @@ def load(config: Config) -> Model:
|
|
|
387
409
|
"""Read every prodc file once into a :class:`~prodc.model.Model`, then validate cross-refs.
|
|
388
410
|
|
|
389
411
|
One loader for every noun: ids are unique within their kind, and each string reference
|
|
390
|
-
(``variant_of``, ``children``, ``goal``, ``release``, ``role``) is
|
|
391
|
-
error — a dangling reference to something declared in these files is a
|
|
392
|
-
runtime unknown. ``role`` is checked unconditionally like
|
|
393
|
-
field, so (unlike free-text ``who``) there is no migration
|
|
412
|
+
(``variant_of``, ``children``, ``goal``, ``release``, ``role``, a withdrawal's ``principle``) is
|
|
413
|
+
checked here as a hard error — a dangling reference to something declared in these files is a
|
|
414
|
+
typo you own, not a runtime unknown. ``role`` is checked unconditionally like
|
|
415
|
+
``goal``/``release``: it is a new field, so (unlike free-text ``who``) there is no migration
|
|
416
|
+
reason to make it lax.
|
|
394
417
|
"""
|
|
395
418
|
acc = _Loaded()
|
|
396
419
|
seen: dict[str, dict[str, Path]] = {
|
|
@@ -398,6 +421,7 @@ def load(config: Config) -> Model:
|
|
|
398
421
|
"goal": {},
|
|
399
422
|
"release": {},
|
|
400
423
|
"role": {},
|
|
424
|
+
"principle": {},
|
|
401
425
|
"argument node": {},
|
|
402
426
|
}
|
|
403
427
|
for pattern in config.need_globs:
|
|
@@ -407,19 +431,27 @@ def load(config: Config) -> Model:
|
|
|
407
431
|
acc.goals.extend(f.goals)
|
|
408
432
|
acc.releases.extend(f.releases)
|
|
409
433
|
acc.roles.extend(f.roles)
|
|
434
|
+
acc.principles.extend(f.principles)
|
|
410
435
|
acc.argument.extend(f.argument)
|
|
411
436
|
_check_variants(acc.needs)
|
|
412
437
|
_check_children(acc.needs)
|
|
413
438
|
_check_ref(acc.needs, "goal", lambda n: n.goal, {g.id for g in acc.goals})
|
|
414
439
|
_check_ref(acc.needs, "release", lambda n: n.release, {r.id for r in acc.releases})
|
|
415
440
|
_check_ref(acc.needs, "role", lambda n: n.role, {p.id for p in acc.roles})
|
|
416
|
-
|
|
441
|
+
_check_ref(
|
|
442
|
+
acc.needs,
|
|
443
|
+
"principle",
|
|
444
|
+
lambda n: n.withdrawn.principle if n.withdrawn else None,
|
|
445
|
+
{a.id for a in acc.principles},
|
|
446
|
+
)
|
|
447
|
+
_check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
|
|
417
448
|
_check_argument(acc.argument)
|
|
418
449
|
return Model(
|
|
419
450
|
needs=tuple(acc.needs),
|
|
420
451
|
goals=tuple(acc.goals),
|
|
421
452
|
releases=tuple(acc.releases),
|
|
422
453
|
roles=tuple(acc.roles),
|
|
454
|
+
principles=tuple(acc.principles),
|
|
423
455
|
argument=tuple(acc.argument),
|
|
424
456
|
)
|
|
425
457
|
|
|
@@ -439,18 +471,21 @@ def _check_ref(
|
|
|
439
471
|
raise LoaderError(f"need {need.id!r}: {role} {ref!r} is not a known {role} id")
|
|
440
472
|
|
|
441
473
|
|
|
442
|
-
def _check_source_roles(needs: list[Need], known: set[str]) -> None:
|
|
474
|
+
def _check_source_roles(needs: list[Need], principles: list[Principle], known: set[str]) -> None:
|
|
443
475
|
"""Every ``source.role`` that is set must name a declared role id (like ``need.role``, #52).
|
|
444
476
|
|
|
445
|
-
Validated unconditionally: ``role`` is a new, opt-in
|
|
446
|
-
be lax — a dangling role on a source is a typo the
|
|
447
|
-
unknown that only shows as missing provenance later.
|
|
477
|
+
Validated unconditionally, on a need's sources and a principle's alike: ``role`` is a new, opt-in
|
|
478
|
+
field, so there is no migration reason to be lax — a dangling role on a source is a typo the
|
|
479
|
+
author owns, surfaced at load, not a silent unknown that only shows as missing provenance later.
|
|
448
480
|
"""
|
|
449
|
-
|
|
450
|
-
|
|
481
|
+
owners = [("need", n.id, n.sources) for n in needs] + [
|
|
482
|
+
("principle", a.id, a.sources) for a in principles
|
|
483
|
+
]
|
|
484
|
+
for noun, oid, sources in owners:
|
|
485
|
+
for i, s in enumerate(sources):
|
|
451
486
|
if s.role is not None and s.role not in known:
|
|
452
487
|
raise LoaderError(
|
|
453
|
-
f"
|
|
488
|
+
f"{noun} {oid!r}: source[{i}] role {s.role!r} is not a known role id"
|
|
454
489
|
)
|
|
455
490
|
|
|
456
491
|
|
|
@@ -139,6 +139,29 @@ class Role:
|
|
|
139
139
|
text: str
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class Principle:
|
|
144
|
+
"""A founding product commitment — the vision, held as given rather than validated.
|
|
145
|
+
|
|
146
|
+
In the logic-system reading prodc already uses (a need is a proposition, a proof its
|
|
147
|
+
answer-with-evidence, an ``assumption`` a conjecture taken on faith *to be tested*), a principle
|
|
148
|
+
differs in intent: it is a chosen frame the product is built on, not a bet pending validation.
|
|
149
|
+
It is what decides between clients who want opposite things — a need that contradicts it is
|
|
150
|
+
withdrawn ``out_of_scope`` citing it (:attr:`Withdrawal.principle`).
|
|
151
|
+
|
|
152
|
+
Principles are discovered, not decreed: ``sources`` keeps the signals that surfaced one (often a
|
|
153
|
+
client), and ``revised_at`` dates its latest statement. A decision taken under an older version
|
|
154
|
+
of the principle is flagged for re-review when the principle is revised — the ripple. A separate noun
|
|
155
|
+
(like :class:`Role`), not a Need marker: a principle is a stance, not what a person wants, so it
|
|
156
|
+
never enters coverage, the plan, or the opinion check.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
id: str
|
|
160
|
+
text: str
|
|
161
|
+
revised_at: str | None = None # ISO date the principle was last stated or revised
|
|
162
|
+
sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
|
|
163
|
+
|
|
164
|
+
|
|
142
165
|
class AttestationKind(StrEnum):
|
|
143
166
|
"""Who reviewed the warrant — a ladder of strength, human strongest (cf. SourceKind)."""
|
|
144
167
|
|
|
@@ -185,11 +208,14 @@ class Withdrawal:
|
|
|
185
208
|
Unlike a draft (staged, not yet decided), a withdrawal is a decision *against*: the need is
|
|
186
209
|
taken off the active board and out of coverage, with its ``reason``, ``date`` and ``kind`` kept
|
|
187
210
|
on the record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
|
|
211
|
+
``principle`` names the :class:`Principle` the decision rests on (typically for ``out_of_scope``), so a
|
|
212
|
+
later revision of that principle can flag this decision for re-review.
|
|
188
213
|
"""
|
|
189
214
|
|
|
190
215
|
reason: str
|
|
191
216
|
date: str
|
|
192
217
|
kind: WithdrawalKind = WithdrawalKind.WONT_DO
|
|
218
|
+
principle: str | None = None # id of the Principle this decision rests on
|
|
193
219
|
|
|
194
220
|
|
|
195
221
|
@dataclass(frozen=True)
|
|
@@ -345,6 +371,7 @@ class Model:
|
|
|
345
371
|
goals: tuple[Goal, ...] = ()
|
|
346
372
|
releases: tuple[Release, ...] = () # in declared order = ship sequence
|
|
347
373
|
roles: tuple[Role, ...] = () # the roles needs' `who` play (#52)
|
|
374
|
+
principles: tuple[Principle, ...] = () # the product vision, held as given
|
|
348
375
|
argument: tuple[ArgNode, ...] = () # the GSN assurance graph (#28)
|
|
349
376
|
|
|
350
377
|
|
|
@@ -23,6 +23,7 @@ from .model import (
|
|
|
23
23
|
Need,
|
|
24
24
|
NeedResult,
|
|
25
25
|
NeedState,
|
|
26
|
+
Principle,
|
|
26
27
|
ProofState,
|
|
27
28
|
Release,
|
|
28
29
|
Role,
|
|
@@ -631,6 +632,53 @@ def role_report(
|
|
|
631
632
|
return coverage, unroled
|
|
632
633
|
|
|
633
634
|
|
|
635
|
+
@dataclass(frozen=True)
|
|
636
|
+
class PrincipleReport:
|
|
637
|
+
"""One principle and the decisions that rest on it — the ripple, made visible."""
|
|
638
|
+
|
|
639
|
+
principle: Principle
|
|
640
|
+
declined: tuple[Need, ...] # needs withdrawn citing this principle
|
|
641
|
+
stale: tuple[Need, ...] # of those, decided before the principle's latest revision — re-review
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _decided_before(decided: str, revised: str | None) -> bool:
|
|
645
|
+
"""True when a decision dated ``decided`` predates a principle revised on ``revised``.
|
|
646
|
+
|
|
647
|
+
Both are ISO dates. An undated principle, or a malformed date on either side, is never stale —
|
|
648
|
+
there is no revision to compare against, and the loader does not own date format here.
|
|
649
|
+
"""
|
|
650
|
+
if revised is None:
|
|
651
|
+
return False
|
|
652
|
+
try:
|
|
653
|
+
return date.fromisoformat(decided) < date.fromisoformat(revised)
|
|
654
|
+
except ValueError:
|
|
655
|
+
return False
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def principle_report(needs: list[Need], principles: list[Principle]) -> list[PrincipleReport]:
|
|
659
|
+
"""Each declared principle with the needs withdrawn under it, and which of those are stale.
|
|
660
|
+
|
|
661
|
+
Reads the *whole* need list, withdrawn needs included — a declined need is off the board, so
|
|
662
|
+
the board-filtered list would hide exactly what this reports. A decision is stale when the
|
|
663
|
+
withdrawal is dated before the principle's ``revised_at``: the frame it was judged against has
|
|
664
|
+
changed since, so it comes back up for re-review. Records-only — prodc flags, the PM re-decides.
|
|
665
|
+
"""
|
|
666
|
+
by_principle: dict[str, list[Need]] = {a.id: [] for a in principles}
|
|
667
|
+
for n in needs:
|
|
668
|
+
if n.withdrawn is not None and n.withdrawn.principle in by_principle:
|
|
669
|
+
by_principle[n.withdrawn.principle].append(n)
|
|
670
|
+
out: list[PrincipleReport] = []
|
|
671
|
+
for a in principles:
|
|
672
|
+
declined = sorted(by_principle[a.id], key=lambda n: n.id)
|
|
673
|
+
stale = [
|
|
674
|
+
n
|
|
675
|
+
for n in declined
|
|
676
|
+
if n.withdrawn is not None and _decided_before(n.withdrawn.date, a.revised_at)
|
|
677
|
+
]
|
|
678
|
+
out.append(PrincipleReport(a, tuple(declined), tuple(stale)))
|
|
679
|
+
return out
|
|
680
|
+
|
|
681
|
+
|
|
634
682
|
@dataclass(frozen=True)
|
|
635
683
|
class Handoff:
|
|
636
684
|
"""A derived step in a cross-role journey: one flow child hands off to the next (#52)."""
|