prodc 0.3.80__tar.gz → 0.3.82__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.80 → prodc-0.3.82}/PKG-INFO +1 -1
- {prodc-0.3.80 → prodc-0.3.82}/pyproject.toml +1 -1
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/agent.md +2 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/app.py +49 -4
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/commands.py +89 -2
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/render.py +43 -2
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/serialize.py +29 -2
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/loader.py +68 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/model.py +28 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/status.py +50 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/writer.py +79 -3
- prodc-0.3.82/tests/test_decisions.py +325 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_principles.py +114 -0
- {prodc-0.3.80 → prodc-0.3.82}/.gitignore +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/CHANGELOG.md +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/LICENSE +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/README.md +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/__init__.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/adapters.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/board.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/config.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/dedup.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/py.typed +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/report.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/src/prodc/report_html.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_adapters.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_answer.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_callbacks.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_citation.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_cli.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_command_hook.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_contradicts.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_dedup.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_description.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_drafts.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_evalgate.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_importance.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_loader.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_models.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_needref.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_relink.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_report.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_report_html.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_review.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_roles.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_source_role.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_sources.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_staleness.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_status.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_suggest.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_variants.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_who_variants.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/tests/test_writer.py +0 -0
- {prodc-0.3.80 → prodc-0.3.82}/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.82
|
|
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
|
|
@@ -74,6 +74,8 @@ Read first, then act, then check.
|
|
|
74
74
|
| declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
|
|
75
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
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 |
|
|
77
|
+
| promote a need you commit to into a principle | `prodc add principle <id> --from-need <need-id> --file needs/<f>.yaml` — copies the need's text (unless `--text` overrides) and its sources (the client signals that surfaced it); the need stays on the board, so a need and the principle it inspired coexist |
|
|
78
|
+
| record how a need gets built (the chosen solution) | `prodc add decision <id> --text "<the solution>" --needs <ids> [--alternative "<rejected>" …] [--consequences "…"] [--principles <ids>] --file needs/<f>.yaml` — the OST node: which solution you picked for a need, over what, resting on which principle. `--needs` is required (a decision with no need is refused); `lint` flags one left serving only withdrawn needs, or decided before a principle it cites was revised |
|
|
77
79
|
| 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) |
|
|
78
80
|
| set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
|
|
79
81
|
| mark two needs as opposing each other | `prodc set <id> contradicts+=<other-id>` — symmetric (declare once); `lint` flags a pair left both-live (clients want opposite things and the vision has not decided), and `withdraw … --kind out_of_scope --principle <id>` resolves it |
|
|
@@ -167,14 +167,59 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
167
167
|
|
|
168
168
|
ad = sub.add_parser(
|
|
169
169
|
"add",
|
|
170
|
-
help="create a
|
|
170
|
+
help="create a need, or declare a role, a principle, or a decision (writes YAML)",
|
|
171
171
|
)
|
|
172
|
-
ad.add_argument("noun", choices=["need", "role", "principle"], help="what to add")
|
|
172
|
+
ad.add_argument("noun", choices=["need", "role", "principle", "decision"], help="what to add")
|
|
173
173
|
ad.add_argument(
|
|
174
174
|
"need_id",
|
|
175
|
-
help="the new
|
|
175
|
+
help="the new item's id — need (N-9), role, principle (P-1), or decision (D-1)",
|
|
176
|
+
)
|
|
177
|
+
ad.add_argument(
|
|
178
|
+
"--text",
|
|
179
|
+
default=None,
|
|
180
|
+
help="the need text (or the role / principle text); required except `add principle "
|
|
181
|
+
"--from-need`, which takes the text from the need",
|
|
182
|
+
)
|
|
183
|
+
ad.add_argument(
|
|
184
|
+
"--from-need",
|
|
185
|
+
default=None,
|
|
186
|
+
metavar="ID",
|
|
187
|
+
help="principle only: promote a need into a principle — copy its text (unless --text "
|
|
188
|
+
"overrides) and its sources as the provenance that surfaced it; the need stays on the board",
|
|
189
|
+
)
|
|
190
|
+
ad.add_argument(
|
|
191
|
+
"--needs",
|
|
192
|
+
default=None,
|
|
193
|
+
metavar="IDS",
|
|
194
|
+
help="decision only: comma-separated ids of the needs this decision serves (required; "
|
|
195
|
+
"validated at load)",
|
|
196
|
+
)
|
|
197
|
+
ad.add_argument(
|
|
198
|
+
"--alternative",
|
|
199
|
+
action="append",
|
|
200
|
+
default=None,
|
|
201
|
+
dest="alternatives",
|
|
202
|
+
metavar="TEXT",
|
|
203
|
+
help="decision only: an option that was rejected (repeatable) — what makes it a choice",
|
|
204
|
+
)
|
|
205
|
+
ad.add_argument(
|
|
206
|
+
"--consequences",
|
|
207
|
+
default=None,
|
|
208
|
+
help="decision only: what the chosen solution costs / rules out",
|
|
209
|
+
)
|
|
210
|
+
ad.add_argument(
|
|
211
|
+
"--principles",
|
|
212
|
+
default=None,
|
|
213
|
+
metavar="IDS",
|
|
214
|
+
help="decision only: comma-separated principle ids the decision rests on (validated); "
|
|
215
|
+
"revising one flags the decision stale",
|
|
216
|
+
)
|
|
217
|
+
ad.add_argument(
|
|
218
|
+
"--date",
|
|
219
|
+
default=None,
|
|
220
|
+
metavar="ISO",
|
|
221
|
+
help="decision only: when it was decided (default: today) — the comparand for the ripple",
|
|
176
222
|
)
|
|
177
|
-
ad.add_argument("--text", required=True, help="the need text (or the role / principle text)")
|
|
178
223
|
ad.add_argument(
|
|
179
224
|
"--who",
|
|
180
225
|
default=None,
|
|
@@ -37,19 +37,23 @@ from ..status import (
|
|
|
37
37
|
applicable_needs,
|
|
38
38
|
argument_defects,
|
|
39
39
|
contradictions,
|
|
40
|
+
decisions_for,
|
|
40
41
|
evidence_age_days,
|
|
41
42
|
importance_revisit_due,
|
|
43
|
+
orphaned_decisions,
|
|
42
44
|
principle_report,
|
|
43
45
|
proof_suggestions,
|
|
44
46
|
recurrence,
|
|
45
47
|
relink_suggestions,
|
|
46
48
|
resolve,
|
|
49
|
+
stale_decisions,
|
|
47
50
|
summarize,
|
|
48
51
|
)
|
|
49
52
|
from ..writer import (
|
|
50
53
|
Edit,
|
|
51
54
|
WriteError,
|
|
52
55
|
accept,
|
|
56
|
+
add_decision,
|
|
53
57
|
add_need,
|
|
54
58
|
add_principle,
|
|
55
59
|
add_proof,
|
|
@@ -81,6 +85,7 @@ from .render import (
|
|
|
81
85
|
render_argument,
|
|
82
86
|
render_brief,
|
|
83
87
|
render_columns,
|
|
88
|
+
render_decisions,
|
|
84
89
|
render_goals,
|
|
85
90
|
render_handoffs,
|
|
86
91
|
render_orphans,
|
|
@@ -263,6 +268,24 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
263
268
|
warnings = [
|
|
264
269
|
(r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
|
|
265
270
|
]
|
|
271
|
+
# Decision drift (keyed by the decision id, not a need): a decision left serving only
|
|
272
|
+
# withdrawn/non-product needs is engineering with no live product reason; a decision decided
|
|
273
|
+
# under a principle that was revised later comes back up (the same ripple a withdrawal gets).
|
|
274
|
+
decisions = list(model.decisions)
|
|
275
|
+
for d in orphaned_decisions(decisions, needs):
|
|
276
|
+
warnings.append(
|
|
277
|
+
(
|
|
278
|
+
d.id,
|
|
279
|
+
"decision serves only withdrawn/non-product needs — re-tie it to a live need or withdraw it",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
for d, pid in stale_decisions(decisions, list(model.principles)):
|
|
283
|
+
warnings.append(
|
|
284
|
+
(
|
|
285
|
+
d.id,
|
|
286
|
+
f"decided {d.decided_at} under principle {pid}, revised later — re-review this solution",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
266
289
|
if args.json:
|
|
267
290
|
print(
|
|
268
291
|
json.dumps(
|
|
@@ -346,6 +369,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
346
369
|
unroled,
|
|
347
370
|
hos,
|
|
348
371
|
principles=board.principles,
|
|
372
|
+
decisions=list(model.decisions),
|
|
349
373
|
)
|
|
350
374
|
)
|
|
351
375
|
else:
|
|
@@ -380,6 +404,8 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
380
404
|
print(render_recurrence(recs))
|
|
381
405
|
if model.principles:
|
|
382
406
|
print(render_principles(board.principles))
|
|
407
|
+
if model.decisions:
|
|
408
|
+
print(render_decisions(list(model.decisions), needs=list(model.needs)))
|
|
383
409
|
if orphan_map:
|
|
384
410
|
print(render_orphans(orphan_map))
|
|
385
411
|
|
|
@@ -397,7 +423,12 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
397
423
|
if r is None:
|
|
398
424
|
print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
|
|
399
425
|
return 2
|
|
400
|
-
|
|
426
|
+
decisions = decisions_for(args.need_id, list(model.decisions)) # the 'how' behind this need
|
|
427
|
+
print(
|
|
428
|
+
show_json(r, decisions=decisions)
|
|
429
|
+
if args.json
|
|
430
|
+
else render_show(r, cfg.importance_label(r.need.importance), decisions=decisions)
|
|
431
|
+
)
|
|
401
432
|
return 0
|
|
402
433
|
|
|
403
434
|
|
|
@@ -495,6 +526,9 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
495
526
|
print(f"prodc: no config at {cfg_path}", file=sys.stderr)
|
|
496
527
|
return 2
|
|
497
528
|
if args.noun == "role":
|
|
529
|
+
if args.text is None:
|
|
530
|
+
print("prodc: add role requires --text", file=sys.stderr)
|
|
531
|
+
return 2
|
|
498
532
|
try:
|
|
499
533
|
cfg = Config.load(cfg_path)
|
|
500
534
|
edit = add_role(
|
|
@@ -512,12 +546,62 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
512
546
|
if args.noun == "principle":
|
|
513
547
|
try:
|
|
514
548
|
cfg = Config.load(cfg_path)
|
|
549
|
+
text, sources = args.text, ()
|
|
550
|
+
if args.from_need:
|
|
551
|
+
# Promote a need into a principle (the discovery lifecycle): the signals that
|
|
552
|
+
# surfaced it travel with it as provenance, so the history stays honest. The origin
|
|
553
|
+
# need stays on the board unchanged — a need and the principle it inspired coexist.
|
|
554
|
+
origin = next((n for n in load(cfg).needs if n.id == args.from_need), None)
|
|
555
|
+
if origin is None:
|
|
556
|
+
print(f"prodc: no need '{args.from_need}' to promote", file=sys.stderr)
|
|
557
|
+
return 2
|
|
558
|
+
text = args.text or origin.text
|
|
559
|
+
sources = origin.sources
|
|
560
|
+
if text is None:
|
|
561
|
+
print(
|
|
562
|
+
"prodc: add principle requires --text (or --from-need to take it from a need)",
|
|
563
|
+
file=sys.stderr,
|
|
564
|
+
)
|
|
565
|
+
return 2
|
|
515
566
|
edit = add_principle(
|
|
516
567
|
cfg,
|
|
517
568
|
args.need_id,
|
|
518
|
-
|
|
569
|
+
text,
|
|
519
570
|
file=args.file,
|
|
520
571
|
revised_at=date.today().isoformat(),
|
|
572
|
+
sources=sources,
|
|
573
|
+
expect_sha=args.expect_sha,
|
|
574
|
+
dry_run=args.dry_run,
|
|
575
|
+
)
|
|
576
|
+
except (ConfigError, LoaderError, WriteError) as exc:
|
|
577
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
578
|
+
return 2
|
|
579
|
+
return emit_edit(edit, args.dry_run, args.json)
|
|
580
|
+
if args.noun == "decision":
|
|
581
|
+
if args.text is None:
|
|
582
|
+
print("prodc: add decision requires --text (the chosen solution)", file=sys.stderr)
|
|
583
|
+
return 2
|
|
584
|
+
needs = tuple(n.strip() for n in (args.needs or "").split(",") if n.strip())
|
|
585
|
+
if not needs:
|
|
586
|
+
print(
|
|
587
|
+
"prodc: add decision requires --needs (the need(s) the solution serves)",
|
|
588
|
+
file=sys.stderr,
|
|
589
|
+
)
|
|
590
|
+
return 2
|
|
591
|
+
principles = tuple(p.strip() for p in (args.principles or "").split(",") if p.strip())
|
|
592
|
+
try:
|
|
593
|
+
cfg = Config.load(cfg_path)
|
|
594
|
+
edit = add_decision(
|
|
595
|
+
cfg,
|
|
596
|
+
args.need_id,
|
|
597
|
+
args.text,
|
|
598
|
+
file=args.file,
|
|
599
|
+
needs=needs,
|
|
600
|
+
decided_at=args.date or date.today().isoformat(),
|
|
601
|
+
alternatives=tuple(args.alternatives or ()),
|
|
602
|
+
consequences=args.consequences,
|
|
603
|
+
principles=principles,
|
|
604
|
+
draft=args.draft,
|
|
521
605
|
expect_sha=args.expect_sha,
|
|
522
606
|
dry_run=args.dry_run,
|
|
523
607
|
)
|
|
@@ -528,6 +612,9 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
528
612
|
if args.who is None:
|
|
529
613
|
print("prodc: add need requires --who (the author recorded on the source)", file=sys.stderr)
|
|
530
614
|
return 2
|
|
615
|
+
if args.text is None:
|
|
616
|
+
print("prodc: add need requires --text", file=sys.stderr)
|
|
617
|
+
return 2
|
|
531
618
|
try:
|
|
532
619
|
cfg = Config.load(cfg_path)
|
|
533
620
|
if not args.no_dupe_check:
|
|
@@ -12,6 +12,7 @@ from collections.abc import Callable
|
|
|
12
12
|
|
|
13
13
|
from ..board import warrant as _warrant
|
|
14
14
|
from ..model import (
|
|
15
|
+
Decision,
|
|
15
16
|
Goal,
|
|
16
17
|
Model,
|
|
17
18
|
Need,
|
|
@@ -21,7 +22,15 @@ from ..model import (
|
|
|
21
22
|
Role,
|
|
22
23
|
ears_shape,
|
|
23
24
|
)
|
|
24
|
-
from ..status import
|
|
25
|
+
from ..status import (
|
|
26
|
+
Handoff,
|
|
27
|
+
PrincipleReport,
|
|
28
|
+
Recurrence,
|
|
29
|
+
Summary,
|
|
30
|
+
orphaned_decisions,
|
|
31
|
+
rank,
|
|
32
|
+
state_priority,
|
|
33
|
+
)
|
|
25
34
|
from .common import PortfolioTotals, RepoRow, brief_open
|
|
26
35
|
|
|
27
36
|
_LABEL = {
|
|
@@ -280,7 +289,34 @@ def render_principles(reports: list[PrincipleReport]) -> str:
|
|
|
280
289
|
return "\n".join(out)
|
|
281
290
|
|
|
282
291
|
|
|
283
|
-
def
|
|
292
|
+
def render_decisions(decisions: list[Decision], needs: list[Need]) -> str:
|
|
293
|
+
"""The solution chosen for each need (OST), what it rejected, and the needs it serves.
|
|
294
|
+
|
|
295
|
+
Shows the decisions still in force (a superseded one is off the board, its record kept). A
|
|
296
|
+
decision left serving only withdrawn/non-product needs is flagged orphaned — the 'how' lost its
|
|
297
|
+
'what'.
|
|
298
|
+
"""
|
|
299
|
+
orphans = {d.id for d in orphaned_decisions(decisions, needs)}
|
|
300
|
+
active = sorted((d for d in decisions if d.withdrawn is None), key=lambda x: x.id)
|
|
301
|
+
out = ["\ndecisions (the solution chosen for a need — how it gets built):"]
|
|
302
|
+
for d in active:
|
|
303
|
+
tag = " (proposed)" if d.draft else ""
|
|
304
|
+
out.append(f" {d.id:<6}{tag} {d.text}")
|
|
305
|
+
out.append(f" serves: {', '.join(d.needs)}")
|
|
306
|
+
if d.alternatives:
|
|
307
|
+
out.append(f" over: {', '.join(d.alternatives)}")
|
|
308
|
+
if d.principles:
|
|
309
|
+
out.append(f" rests on: {', '.join(d.principles)}")
|
|
310
|
+
if d.id in orphans:
|
|
311
|
+
out.append(" ⚠ serves only withdrawn/non-product needs — re-tie or withdraw")
|
|
312
|
+
return "\n".join(out)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render_show(
|
|
316
|
+
r: NeedResult,
|
|
317
|
+
importance_label: str | None = None,
|
|
318
|
+
decisions: list[Decision] | None = None,
|
|
319
|
+
) -> str:
|
|
284
320
|
"""One need as a citable answer — the artifact you drop into a thread."""
|
|
285
321
|
need = r.need
|
|
286
322
|
who = (need.who + (f" ({need.label})" if need.label else "")) if need.who else "(unassigned)"
|
|
@@ -349,6 +385,11 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
349
385
|
lines += [f" - {d}" for d in r.detail]
|
|
350
386
|
elif not need.children:
|
|
351
387
|
lines.append(" proofs: (none — HOLE)")
|
|
388
|
+
if decisions:
|
|
389
|
+
lines.append(" decisions (how it's built):")
|
|
390
|
+
for d in decisions:
|
|
391
|
+
alt = f" (over {', '.join(d.alternatives)})" if d.alternatives else ""
|
|
392
|
+
lines.append(f" - {d.id}: {d.text}{alt}")
|
|
352
393
|
lines += [f" ⚠ {w}" for w in r.warnings]
|
|
353
394
|
return "\n".join(lines)
|
|
354
395
|
|
|
@@ -12,11 +12,35 @@ import json
|
|
|
12
12
|
from ..board import need_payload
|
|
13
13
|
from ..board import warrant as _warrant
|
|
14
14
|
from ..dedup import DupPair, VariantGroup
|
|
15
|
-
from ..model import Goal, Model, NeedResult, NeedState, Release, Role
|
|
15
|
+
from ..model import Decision, Goal, Model, NeedResult, NeedState, Release, Role
|
|
16
16
|
from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
|
|
17
17
|
from .common import brief_open
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _decision_payload(d: Decision) -> dict[str, object]:
|
|
21
|
+
"""One decision as a JSON record — the OST chosen-solution node and its edges."""
|
|
22
|
+
return {
|
|
23
|
+
"id": d.id,
|
|
24
|
+
"text": d.text,
|
|
25
|
+
"needs": list(d.needs),
|
|
26
|
+
"alternatives": list(d.alternatives),
|
|
27
|
+
"consequences": d.consequences,
|
|
28
|
+
"principles": list(d.principles),
|
|
29
|
+
"decided_at": d.decided_at,
|
|
30
|
+
"draft": d.draft,
|
|
31
|
+
"withdrawn": (
|
|
32
|
+
{
|
|
33
|
+
"reason": d.withdrawn.reason,
|
|
34
|
+
"date": d.withdrawn.date,
|
|
35
|
+
"kind": d.withdrawn.kind.value,
|
|
36
|
+
"principle": d.withdrawn.principle,
|
|
37
|
+
}
|
|
38
|
+
if d.withdrawn
|
|
39
|
+
else None
|
|
40
|
+
),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
20
44
|
def _summary_payload(summary: Summary, orphan_map: dict[str, list[str]]) -> dict[str, object]:
|
|
21
45
|
return {
|
|
22
46
|
"total": summary.total,
|
|
@@ -44,6 +68,7 @@ def to_json(
|
|
|
44
68
|
unroled: list[NeedResult],
|
|
45
69
|
hos: list[Handoff],
|
|
46
70
|
principles: list[PrincipleReport] | None = None,
|
|
71
|
+
decisions: list[Decision] | None = None,
|
|
47
72
|
) -> str:
|
|
48
73
|
payload = {
|
|
49
74
|
"summary": _summary_payload(summary, orphan_map),
|
|
@@ -111,17 +136,19 @@ def to_json(
|
|
|
111
136
|
}
|
|
112
137
|
for rep in (principles or [])
|
|
113
138
|
],
|
|
139
|
+
"decisions": [_decision_payload(d) for d in (decisions or [])],
|
|
114
140
|
"needs": [need_payload(r) for r in results],
|
|
115
141
|
}
|
|
116
142
|
return json.dumps(payload, indent=2)
|
|
117
143
|
|
|
118
144
|
|
|
119
|
-
def show_json(r: NeedResult) -> str:
|
|
145
|
+
def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
|
|
120
146
|
need = r.need
|
|
121
147
|
payload = need_payload(r) # the shared core — never drifts from the board
|
|
122
148
|
payload.update(
|
|
123
149
|
{
|
|
124
150
|
"label": need.label,
|
|
151
|
+
"decisions": [_decision_payload(d) for d in (decisions or [])],
|
|
125
152
|
"sources": [
|
|
126
153
|
{
|
|
127
154
|
"kind": s.kind.value,
|
|
@@ -22,6 +22,7 @@ from .model import (
|
|
|
22
22
|
ArgNode,
|
|
23
23
|
Attestation,
|
|
24
24
|
AttestationKind,
|
|
25
|
+
Decision,
|
|
25
26
|
Goal,
|
|
26
27
|
Model,
|
|
27
28
|
Need,
|
|
@@ -326,6 +327,39 @@ def _principle(raw: object, where: str) -> Principle:
|
|
|
326
327
|
)
|
|
327
328
|
|
|
328
329
|
|
|
330
|
+
def _decision(raw: object, where: str) -> Decision:
|
|
331
|
+
"""Parse one ``decisions:`` entry — the solution chosen for a need, and what was rejected.
|
|
332
|
+
|
|
333
|
+
``needs`` is required and must be non-empty: a decision with no need it serves is engineering
|
|
334
|
+
with no product reason, refused here rather than nagged at by a soft lint. ``withdrawn`` reuses
|
|
335
|
+
the Need withdrawal (a superseded decision keeps its record).
|
|
336
|
+
"""
|
|
337
|
+
tbl = _as_mapping(raw, where)
|
|
338
|
+
needs = tuple(
|
|
339
|
+
_as_str(n, f"{where}.needs[{i}]")
|
|
340
|
+
for i, n in enumerate(_as_list(tbl.get("needs"), f"{where}.needs"))
|
|
341
|
+
)
|
|
342
|
+
if not needs:
|
|
343
|
+
raise LoaderError(f"{where}: a decision must serve at least one need (needs: [...])")
|
|
344
|
+
return Decision(
|
|
345
|
+
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
346
|
+
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
347
|
+
needs=needs,
|
|
348
|
+
alternatives=tuple(
|
|
349
|
+
_as_str(a, f"{where}.alternatives[{i}]")
|
|
350
|
+
for i, a in enumerate(_as_list(tbl.get("alternatives"), f"{where}.alternatives"))
|
|
351
|
+
),
|
|
352
|
+
consequences=_opt_str(tbl.get("consequences"), f"{where}.consequences"),
|
|
353
|
+
principles=tuple(
|
|
354
|
+
_as_str(p, f"{where}.principles[{i}]")
|
|
355
|
+
for i, p in enumerate(_as_list(tbl.get("principles"), f"{where}.principles"))
|
|
356
|
+
),
|
|
357
|
+
decided_at=_opt_str(tbl.get("decided_at"), f"{where}.decided_at"),
|
|
358
|
+
draft=_as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False,
|
|
359
|
+
withdrawn=_withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn"),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
329
363
|
def _arg_node(raw: object, where: str) -> ArgNode:
|
|
330
364
|
tbl = _as_mapping(raw, where)
|
|
331
365
|
kind_name = _as_str(tbl.get("kind"), f"{where}.kind")
|
|
@@ -359,6 +393,7 @@ class _Loaded:
|
|
|
359
393
|
releases: list[Release] = field(default_factory=list[Release])
|
|
360
394
|
roles: list[Role] = field(default_factory=list[Role])
|
|
361
395
|
principles: list[Principle] = field(default_factory=list[Principle])
|
|
396
|
+
decisions: list[Decision] = field(default_factory=list[Decision])
|
|
362
397
|
argument: list[ArgNode] = field(default_factory=list[ArgNode])
|
|
363
398
|
|
|
364
399
|
|
|
@@ -403,6 +438,10 @@ def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
|
|
|
403
438
|
principle = _principle(raw, f"{where}.principles[{i}]")
|
|
404
439
|
_unique("principle", principle.id)
|
|
405
440
|
out.principles.append(principle)
|
|
441
|
+
for i, raw in enumerate(_as_list(tbl.get("decisions"), f"{where}.decisions")):
|
|
442
|
+
decision = _decision(raw, f"{where}.decisions[{i}]")
|
|
443
|
+
_unique("decision", decision.id)
|
|
444
|
+
out.decisions.append(decision)
|
|
406
445
|
for i, raw in enumerate(_as_list(tbl.get("argument"), f"{where}.argument")):
|
|
407
446
|
node = _arg_node(raw, f"{where}.argument[{i}]")
|
|
408
447
|
_unique("argument node", node.id)
|
|
@@ -427,6 +466,7 @@ def load(config: Config) -> Model:
|
|
|
427
466
|
"release": {},
|
|
428
467
|
"role": {},
|
|
429
468
|
"principle": {},
|
|
469
|
+
"decision": {},
|
|
430
470
|
"argument node": {},
|
|
431
471
|
}
|
|
432
472
|
for pattern in config.need_globs:
|
|
@@ -437,6 +477,7 @@ def load(config: Config) -> Model:
|
|
|
437
477
|
acc.releases.extend(f.releases)
|
|
438
478
|
acc.roles.extend(f.roles)
|
|
439
479
|
acc.principles.extend(f.principles)
|
|
480
|
+
acc.decisions.extend(f.decisions)
|
|
440
481
|
acc.argument.extend(f.argument)
|
|
441
482
|
_check_variants(acc.needs)
|
|
442
483
|
_check_children(acc.needs)
|
|
@@ -451,6 +492,7 @@ def load(config: Config) -> Model:
|
|
|
451
492
|
{a.id for a in acc.principles},
|
|
452
493
|
)
|
|
453
494
|
_check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
|
|
495
|
+
_check_decisions(acc.decisions, {n.id for n in acc.needs}, {p.id for p in acc.principles})
|
|
454
496
|
_check_argument(acc.argument)
|
|
455
497
|
acc.needs = _symmetrize_contradicts(acc.needs)
|
|
456
498
|
return Model(
|
|
@@ -459,6 +501,7 @@ def load(config: Config) -> Model:
|
|
|
459
501
|
releases=tuple(acc.releases),
|
|
460
502
|
roles=tuple(acc.roles),
|
|
461
503
|
principles=tuple(acc.principles),
|
|
504
|
+
decisions=tuple(acc.decisions),
|
|
462
505
|
argument=tuple(acc.argument),
|
|
463
506
|
)
|
|
464
507
|
|
|
@@ -521,6 +564,31 @@ def _check_children(needs: list[Need]) -> None:
|
|
|
521
564
|
raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
|
|
522
565
|
|
|
523
566
|
|
|
567
|
+
def _check_decisions(
|
|
568
|
+
decisions: list[Decision], known_needs: set[str], known_principles: set[str]
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Every ``needs`` / ``principles`` id on a decision must name a known need / principle.
|
|
571
|
+
|
|
572
|
+
``needs`` is already guaranteed non-empty by :func:`_decision`; here each id is checked to
|
|
573
|
+
exist, the same hard-error treatment the other cross-references get. A superseded decision's
|
|
574
|
+
``withdrawn.principle`` is checked too, so a stale citation cannot hide on a retired record.
|
|
575
|
+
"""
|
|
576
|
+
for d in decisions:
|
|
577
|
+
for nid in d.needs:
|
|
578
|
+
if nid not in known_needs:
|
|
579
|
+
raise LoaderError(f"decision {d.id!r}: needs {nid!r} is not a known need id")
|
|
580
|
+
for pid in d.principles:
|
|
581
|
+
if pid not in known_principles:
|
|
582
|
+
raise LoaderError(
|
|
583
|
+
f"decision {d.id!r}: principle {pid!r} is not a known principle id"
|
|
584
|
+
)
|
|
585
|
+
if d.withdrawn and d.withdrawn.principle and d.withdrawn.principle not in known_principles:
|
|
586
|
+
raise LoaderError(
|
|
587
|
+
f"decision {d.id!r}: withdrawn principle {d.withdrawn.principle!r} "
|
|
588
|
+
"is not a known principle id"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
524
592
|
def _check_contradicts(needs: list[Need]) -> None:
|
|
525
593
|
"""Every id in a need's ``contradicts`` must name a known need, and never the need itself."""
|
|
526
594
|
known = {n.id for n in needs}
|
|
@@ -162,6 +162,33 @@ class Principle:
|
|
|
162
162
|
sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class Decision:
|
|
167
|
+
"""How a need gets built: the solution chosen for it, and the alternatives rejected.
|
|
168
|
+
|
|
169
|
+
Not an ADR (an engineer's record) but the Opportunity-Solution-Tree node (Torres): a need is
|
|
170
|
+
the opportunity, and a decision is *the solution picked for it over the ones considered*. prodc
|
|
171
|
+
pushes solutions out of :class:`Need` (the solution-verb lint), so this is their only honest
|
|
172
|
+
home. ``text`` is the chosen solution; ``alternatives`` are what was rejected — without them a
|
|
173
|
+
decision is a memo, not a choice. ``needs`` is required (a decision with no need it serves is
|
|
174
|
+
engineering with no product reason, which the loader refuses), and ``principles`` ties the
|
|
175
|
+
choice to the vision it rests on, so revising a principle after ``decided_at`` flags the
|
|
176
|
+
decision stale — the same ripple a withdrawal gets. Like a Principle it never enters coverage,
|
|
177
|
+
the plan, or the opinion check; its lifecycle reuses the Need verbs (``draft`` = proposed,
|
|
178
|
+
``withdrawn`` = superseded, keeping the record) rather than a status enum of its own.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
id: str
|
|
182
|
+
text: str # the chosen solution
|
|
183
|
+
needs: tuple[str, ...] # the needs this decision serves — required, validated at load
|
|
184
|
+
alternatives: tuple[str, ...] = () # the options rejected — what makes it a choice, not a memo
|
|
185
|
+
consequences: str | None = None # what the choice costs / rules out
|
|
186
|
+
principles: tuple[str, ...] = () # the principle(s) it rests on; revising one ripples to here
|
|
187
|
+
decided_at: str | None = None # ISO date decided — the comparand for the principle ripple
|
|
188
|
+
draft: bool = False # proposed, not yet committed (reuses the Need draft lifecycle)
|
|
189
|
+
withdrawn: Withdrawal | None = None # superseded / abandoned — the record stays as its trace
|
|
190
|
+
|
|
191
|
+
|
|
165
192
|
class AttestationKind(StrEnum):
|
|
166
193
|
"""Who reviewed the warrant — a ladder of strength, human strongest (cf. SourceKind)."""
|
|
167
194
|
|
|
@@ -375,6 +402,7 @@ class Model:
|
|
|
375
402
|
releases: tuple[Release, ...] = () # in declared order = ship sequence
|
|
376
403
|
roles: tuple[Role, ...] = () # the roles needs' `who` play (#52)
|
|
377
404
|
principles: tuple[Principle, ...] = () # the product vision, held as given
|
|
405
|
+
decisions: tuple[Decision, ...] = () # the solutions chosen for needs (OST), with alternatives
|
|
378
406
|
argument: tuple[ArgNode, ...] = () # the GSN assurance graph (#28)
|
|
379
407
|
|
|
380
408
|
|
|
@@ -19,6 +19,7 @@ from .model import (
|
|
|
19
19
|
ArgKind,
|
|
20
20
|
ArgNode,
|
|
21
21
|
AttestationKind,
|
|
22
|
+
Decision,
|
|
22
23
|
Goal,
|
|
23
24
|
Need,
|
|
24
25
|
NeedResult,
|
|
@@ -679,6 +680,55 @@ def principle_report(needs: list[Need], principles: list[Principle]) -> list[Pri
|
|
|
679
680
|
return out
|
|
680
681
|
|
|
681
682
|
|
|
683
|
+
def active_decisions(decisions: list[Decision]) -> list[Decision]:
|
|
684
|
+
"""The decisions still in force — neither superseded (``withdrawn``) nor a staged ``draft``."""
|
|
685
|
+
return [d for d in decisions if d.withdrawn is None and not d.draft]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def decisions_for(need_id: str, decisions: list[Decision]) -> list[Decision]:
|
|
689
|
+
"""The in-force decisions whose chosen solution serves ``need_id`` — the 'how' behind a need."""
|
|
690
|
+
return [d for d in active_decisions(decisions) if need_id in d.needs]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def stale_decisions(
|
|
694
|
+
decisions: list[Decision], principles: list[Principle]
|
|
695
|
+
) -> list[tuple[Decision, str]]:
|
|
696
|
+
"""Each in-force decision paired with a principle it cites that was revised after it was decided.
|
|
697
|
+
|
|
698
|
+
The same ripple a withdrawal gets (:func:`principle_report`), the other way round: a solution
|
|
699
|
+
chosen under an older statement of a principle comes back up when the principle moves. Reuses
|
|
700
|
+
:func:`_decided_before`; an undated decision or principle never counts (nothing to compare).
|
|
701
|
+
"""
|
|
702
|
+
revised = {p.id: p.revised_at for p in principles}
|
|
703
|
+
out: list[tuple[Decision, str]] = []
|
|
704
|
+
for d in active_decisions(decisions):
|
|
705
|
+
for pid in d.principles:
|
|
706
|
+
if pid in revised and _decided_before(d.decided_at or "", revised[pid]):
|
|
707
|
+
out.append((d, pid))
|
|
708
|
+
return out
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def orphaned_decisions(decisions: list[Decision], needs: list[Need]) -> list[Decision]:
|
|
712
|
+
"""In-force decisions whose every served need is withdrawn or non-product — the real orphan.
|
|
713
|
+
|
|
714
|
+
A decision must name a need at load, but that need can later be withdrawn (``out_of_scope``) or
|
|
715
|
+
marked non-product. A decision left serving only such needs is engineering with no live product
|
|
716
|
+
reason — re-tie it to a live need, or withdraw it. (A need not in ``needs`` at all is a load
|
|
717
|
+
error, never reached here.)
|
|
718
|
+
"""
|
|
719
|
+
by_id = {n.id: n for n in needs}
|
|
720
|
+
out: list[Decision] = []
|
|
721
|
+
for d in active_decisions(decisions):
|
|
722
|
+
live = [
|
|
723
|
+
nid
|
|
724
|
+
for nid in d.needs
|
|
725
|
+
if (n := by_id.get(nid)) is not None and not n.is_withdrawn and n.product
|
|
726
|
+
]
|
|
727
|
+
if not live:
|
|
728
|
+
out.append(d)
|
|
729
|
+
return out
|
|
730
|
+
|
|
731
|
+
|
|
682
732
|
@dataclass(frozen=True)
|
|
683
733
|
class Contradiction:
|
|
684
734
|
"""Two needs in tension — clients wanting opposite things. ``unresolved`` = both still live.
|