prodc 0.3.78__tar.gz → 0.3.80__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.80}/PKG-INFO +1 -1
- {prodc-0.3.78 → prodc-0.3.80}/pyproject.toml +1 -1
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/agent.md +3 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/board.py +10 -1
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/app.py +42 -4
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/commands.py +74 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/render.py +24 -2
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/serialize.py +12 -1
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/loader.py +90 -17
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/model.py +30 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/status.py +94 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/writer.py +121 -21
- prodc-0.3.80/tests/test_contradicts.py +170 -0
- prodc-0.3.80/tests/test_principles.py +332 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_withdrawn.py +1 -0
- {prodc-0.3.78 → prodc-0.3.80}/.gitignore +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/CHANGELOG.md +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/LICENSE +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/README.md +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/__init__.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/adapters.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/config.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/dedup.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/py.typed +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/report.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/src/prodc/report_html.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_adapters.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_answer.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_callbacks.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_citation.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_cli.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_command_hook.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_dedup.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_description.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_drafts.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_evalgate.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_importance.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_loader.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_models.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_needref.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_relink.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_report.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_report_html.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_review.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_roles.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_source_role.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_sources.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_staleness.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_status.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_suggest.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_variants.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_who_variants.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/tests/test_writer.py +0 -0
- {prodc-0.3.78 → prodc-0.3.80}/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.80
|
|
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,8 +72,11 @@ 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#…`) |
|
|
79
|
+
| 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 |
|
|
77
80
|
| propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
|
|
78
81
|
| relink a BROKEN proof (renamed test) | `prodc suggest --relink [<id>] [--apply] [--dry-run]` — proposes the nearest live inventory entry to replace the broken locator |
|
|
79
82
|
| link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
|
|
@@ -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,11 +108,13 @@ 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
|
|
112
115
|
),
|
|
113
116
|
"proofs": list(need.proofs),
|
|
117
|
+
"contradicts": list(need.contradicts),
|
|
114
118
|
"detail": list(r.detail),
|
|
115
119
|
"warnings": list(r.warnings),
|
|
116
120
|
"attestation": (
|
|
@@ -152,12 +156,14 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
152
156
|
"release": need.release,
|
|
153
157
|
"product": need.product,
|
|
154
158
|
"draft": need.draft,
|
|
159
|
+
"contradicts": list(need.contradicts),
|
|
155
160
|
"answer": need.answer.value if need.answer else None,
|
|
156
161
|
"withdrawn": (
|
|
157
162
|
{
|
|
158
163
|
"reason": need.withdrawn.reason,
|
|
159
164
|
"date": need.withdrawn.date,
|
|
160
165
|
"kind": need.withdrawn.kind.value,
|
|
166
|
+
"principle": need.withdrawn.principle,
|
|
161
167
|
}
|
|
162
168
|
if need.withdrawn
|
|
163
169
|
else None
|
|
@@ -202,6 +208,7 @@ class ResolvedBoard:
|
|
|
202
208
|
handoffs: list[Handoff]
|
|
203
209
|
recurrence: list[Recurrence]
|
|
204
210
|
orphans: dict[str, list[str]]
|
|
211
|
+
principles: list[PrincipleReport] # over the whole model — declined needs are off the board
|
|
205
212
|
|
|
206
213
|
|
|
207
214
|
def gather(
|
|
@@ -218,7 +225,8 @@ def gather(
|
|
|
218
225
|
appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
|
|
219
226
|
the markdown/HTML reports leave it off, exactly as they did before this was factored out.
|
|
220
227
|
The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
|
|
221
|
-
are computed over the resolved ``needs``.
|
|
228
|
+
are computed over the resolved ``needs``. The principle report reads the whole ``model.needs``:
|
|
229
|
+
the needs declined under a principle are withdrawn, so the filtered ``needs`` never holds them.
|
|
222
230
|
"""
|
|
223
231
|
use_needs = list(model.needs) if needs is None else needs
|
|
224
232
|
results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
|
|
@@ -239,4 +247,5 @@ def gather(
|
|
|
239
247
|
handoffs=handoffs(use_needs),
|
|
240
248
|
recurrence=recurrence(use_needs),
|
|
241
249
|
orphans=orphans(use_needs, cfg.adapters),
|
|
250
|
+
principles=principle_report(list(model.needs), list(model.principles)),
|
|
242
251
|
)
|
|
@@ -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
|
)
|
|
@@ -36,8 +36,10 @@ from ..status import (
|
|
|
36
36
|
RelinkSuggestion,
|
|
37
37
|
applicable_needs,
|
|
38
38
|
argument_defects,
|
|
39
|
+
contradictions,
|
|
39
40
|
evidence_age_days,
|
|
40
41
|
importance_revisit_due,
|
|
42
|
+
principle_report,
|
|
41
43
|
proof_suggestions,
|
|
42
44
|
recurrence,
|
|
43
45
|
relink_suggestions,
|
|
@@ -49,6 +51,7 @@ from ..writer import (
|
|
|
49
51
|
WriteError,
|
|
50
52
|
accept,
|
|
51
53
|
add_need,
|
|
54
|
+
add_principle,
|
|
52
55
|
add_proof,
|
|
53
56
|
add_role,
|
|
54
57
|
attest,
|
|
@@ -57,6 +60,7 @@ from ..writer import (
|
|
|
57
60
|
link_variant_group,
|
|
58
61
|
reinstate,
|
|
59
62
|
relink_proof,
|
|
63
|
+
revise_principle,
|
|
60
64
|
set_field,
|
|
61
65
|
set_importance,
|
|
62
66
|
withdraw,
|
|
@@ -83,6 +87,7 @@ from .render import (
|
|
|
83
87
|
render_out_of_scope,
|
|
84
88
|
render_plan,
|
|
85
89
|
render_portfolio,
|
|
90
|
+
render_principles,
|
|
86
91
|
render_recurrence,
|
|
87
92
|
render_releases,
|
|
88
93
|
render_roles,
|
|
@@ -232,6 +237,28 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
232
237
|
_annotate_importance_warnings(results, cfg)
|
|
233
238
|
_annotate_draft_age_warnings(results, cfg)
|
|
234
239
|
_annotate_label_deprecation(results)
|
|
240
|
+
# The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
|
|
241
|
+
# comes back up — the frame it was judged against has changed since. A warning, never a hard
|
|
242
|
+
# error; prodc flags, the PM re-decides.
|
|
243
|
+
for rep in principle_report(needs, list(model.principles)):
|
|
244
|
+
for n in rep.stale:
|
|
245
|
+
if n.id in by_id and n.withdrawn is not None:
|
|
246
|
+
by_id[n.id].warnings.append(
|
|
247
|
+
f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
|
|
248
|
+
f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
|
|
249
|
+
)
|
|
250
|
+
# Unresolved contradiction: two needs oppose each other yet BOTH are still live — the product
|
|
251
|
+
# vision has not decided between them. A warning on both sides (withdraw one out_of_scope citing
|
|
252
|
+
# the principle that decides, or reconcile them).
|
|
253
|
+
for c in contradictions(needs):
|
|
254
|
+
if not c.unresolved:
|
|
255
|
+
continue
|
|
256
|
+
for near, far in ((c.a, c.b), (c.b, c.a)):
|
|
257
|
+
if near.id in by_id:
|
|
258
|
+
by_id[near.id].warnings.append(
|
|
259
|
+
f"unresolved contradiction with {far.id} — both live, the vision has not "
|
|
260
|
+
"decided yet (withdraw one out_of_scope citing the principle, or reconcile)"
|
|
261
|
+
)
|
|
235
262
|
errors = _lint_errors(results)
|
|
236
263
|
warnings = [
|
|
237
264
|
(r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
|
|
@@ -318,6 +345,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
318
345
|
role_cov,
|
|
319
346
|
unroled,
|
|
320
347
|
hos,
|
|
348
|
+
principles=board.principles,
|
|
321
349
|
)
|
|
322
350
|
)
|
|
323
351
|
else:
|
|
@@ -350,6 +378,8 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
350
378
|
print(render_handoffs(hos))
|
|
351
379
|
if recs:
|
|
352
380
|
print(render_recurrence(recs))
|
|
381
|
+
if model.principles:
|
|
382
|
+
print(render_principles(board.principles))
|
|
353
383
|
if orphan_map:
|
|
354
384
|
print(render_orphans(orphan_map))
|
|
355
385
|
|
|
@@ -479,6 +509,22 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
479
509
|
print(f"prodc: {exc}", file=sys.stderr)
|
|
480
510
|
return 2
|
|
481
511
|
return emit_edit(edit, args.dry_run, args.json)
|
|
512
|
+
if args.noun == "principle":
|
|
513
|
+
try:
|
|
514
|
+
cfg = Config.load(cfg_path)
|
|
515
|
+
edit = add_principle(
|
|
516
|
+
cfg,
|
|
517
|
+
args.need_id,
|
|
518
|
+
args.text,
|
|
519
|
+
file=args.file,
|
|
520
|
+
revised_at=date.today().isoformat(),
|
|
521
|
+
expect_sha=args.expect_sha,
|
|
522
|
+
dry_run=args.dry_run,
|
|
523
|
+
)
|
|
524
|
+
except (ConfigError, LoaderError, WriteError) as exc:
|
|
525
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
526
|
+
return 2
|
|
527
|
+
return emit_edit(edit, args.dry_run, args.json)
|
|
482
528
|
if args.who is None:
|
|
483
529
|
print("prodc: add need requires --who (the author recorded on the source)", file=sys.stderr)
|
|
484
530
|
return 2
|
|
@@ -578,6 +624,7 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
|
|
|
578
624
|
reason=args.reason,
|
|
579
625
|
date=args.date or date.today().isoformat(),
|
|
580
626
|
kind=args.kind,
|
|
627
|
+
principle=args.principle,
|
|
581
628
|
expect_sha=args.expect_sha,
|
|
582
629
|
dry_run=args.dry_run,
|
|
583
630
|
)
|
|
@@ -607,6 +654,33 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
|
|
|
607
654
|
return emit_edit(edit, args.dry_run, args.json)
|
|
608
655
|
|
|
609
656
|
|
|
657
|
+
def cmd_revise(args: argparse.Namespace) -> int:
|
|
658
|
+
"""Revise a product principle in place: optional new text, always a fresh revised_at (the ripple).
|
|
659
|
+
|
|
660
|
+
Re-stamping ``revised_at`` is the point — every need withdrawn citing this principle with an
|
|
661
|
+
earlier date comes back up for re-review (surfaced by `lint`). Comment-safe: only the principle's
|
|
662
|
+
own block is spliced, the rest of the file is untouched.
|
|
663
|
+
"""
|
|
664
|
+
cfg_path = Path(args.config)
|
|
665
|
+
if not cfg_path.exists():
|
|
666
|
+
print(f"prodc: no config at {cfg_path}", file=sys.stderr)
|
|
667
|
+
return 2
|
|
668
|
+
try:
|
|
669
|
+
cfg = Config.load(cfg_path)
|
|
670
|
+
edit = revise_principle(
|
|
671
|
+
cfg,
|
|
672
|
+
args.principle_id,
|
|
673
|
+
text=args.text,
|
|
674
|
+
revised_at=args.date or date.today().isoformat(),
|
|
675
|
+
expect_sha=args.expect_sha,
|
|
676
|
+
dry_run=args.dry_run,
|
|
677
|
+
)
|
|
678
|
+
except (ConfigError, LoaderError, WriteError) as exc:
|
|
679
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
680
|
+
return 2
|
|
681
|
+
return emit_edit(edit, args.dry_run, args.json)
|
|
682
|
+
|
|
683
|
+
|
|
610
684
|
def cmd_review(args: argparse.Namespace) -> int:
|
|
611
685
|
"""Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
|
|
612
686
|
|
|
@@ -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:
|
|
@@ -320,6 +340,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
320
340
|
lines.append(" sources: (none — opinion)")
|
|
321
341
|
if need.children:
|
|
322
342
|
lines.append(f" children: {', '.join(need.children)}")
|
|
343
|
+
if need.contradicts:
|
|
344
|
+
lines.append(f" contradicts: {', '.join(need.contradicts)}")
|
|
323
345
|
if need.seam:
|
|
324
346
|
lines.append(f" seam: {', '.join(need.seam)}")
|
|
325
347
|
if need.proofs:
|
|
@@ -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)
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import datetime
|
|
11
11
|
from collections.abc import Callable
|
|
12
|
-
from dataclasses import dataclass, field
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import cast
|
|
15
15
|
|
|
@@ -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
|
|
@@ -241,6 +244,10 @@ def _need(
|
|
|
241
244
|
_as_str(d, f"{where}.not_variant_of[{i}]")
|
|
242
245
|
for i, d in enumerate(_as_list(tbl.get("not_variant_of"), f"{where}.not_variant_of"))
|
|
243
246
|
)
|
|
247
|
+
contradicts = tuple(
|
|
248
|
+
_as_str(c, f"{where}.contradicts[{i}]")
|
|
249
|
+
for i, c in enumerate(_as_list(tbl.get("contradicts"), f"{where}.contradicts"))
|
|
250
|
+
)
|
|
244
251
|
if children and proofs:
|
|
245
252
|
raise LoaderError(
|
|
246
253
|
f"{where} ({nid}): a flow (need with children) proves its steps via the "
|
|
@@ -271,6 +278,7 @@ def _need(
|
|
|
271
278
|
product=product,
|
|
272
279
|
not_duplicate_of=not_duplicate_of,
|
|
273
280
|
not_variant_of=not_variant_of,
|
|
281
|
+
contradicts=contradicts,
|
|
274
282
|
importance_set_at=importance_set_at,
|
|
275
283
|
draft=draft,
|
|
276
284
|
draft_added=draft_added,
|
|
@@ -304,6 +312,20 @@ def _role(raw: object, where: str) -> Role:
|
|
|
304
312
|
)
|
|
305
313
|
|
|
306
314
|
|
|
315
|
+
def _principle(raw: object, where: str) -> Principle:
|
|
316
|
+
"""Parse one ``principles:`` entry — a product commitment with its provenance."""
|
|
317
|
+
tbl = _as_mapping(raw, where)
|
|
318
|
+
return Principle(
|
|
319
|
+
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
320
|
+
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
321
|
+
revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
|
|
322
|
+
sources=tuple(
|
|
323
|
+
_source(s, f"{where}.sources[{i}]")
|
|
324
|
+
for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
307
329
|
def _arg_node(raw: object, where: str) -> ArgNode:
|
|
308
330
|
tbl = _as_mapping(raw, where)
|
|
309
331
|
kind_name = _as_str(tbl.get("kind"), f"{where}.kind")
|
|
@@ -336,11 +358,12 @@ class _Loaded:
|
|
|
336
358
|
goals: list[Goal] = field(default_factory=list[Goal])
|
|
337
359
|
releases: list[Release] = field(default_factory=list[Release])
|
|
338
360
|
roles: list[Role] = field(default_factory=list[Role])
|
|
361
|
+
principles: list[Principle] = field(default_factory=list[Principle])
|
|
339
362
|
argument: list[ArgNode] = field(default_factory=list[ArgNode])
|
|
340
363
|
|
|
341
364
|
|
|
342
365
|
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:``/``
|
|
366
|
+
"""Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``principles:``/…)."""
|
|
344
367
|
where = path.name
|
|
345
368
|
try:
|
|
346
369
|
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
@@ -376,6 +399,10 @@ def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
|
|
|
376
399
|
role = _role(raw, f"{where}.roles[{i}]")
|
|
377
400
|
_unique("role", role.id)
|
|
378
401
|
out.roles.append(role)
|
|
402
|
+
for i, raw in enumerate(_as_list(tbl.get("principles"), f"{where}.principles")):
|
|
403
|
+
principle = _principle(raw, f"{where}.principles[{i}]")
|
|
404
|
+
_unique("principle", principle.id)
|
|
405
|
+
out.principles.append(principle)
|
|
379
406
|
for i, raw in enumerate(_as_list(tbl.get("argument"), f"{where}.argument")):
|
|
380
407
|
node = _arg_node(raw, f"{where}.argument[{i}]")
|
|
381
408
|
_unique("argument node", node.id)
|
|
@@ -387,10 +414,11 @@ def load(config: Config) -> Model:
|
|
|
387
414
|
"""Read every prodc file once into a :class:`~prodc.model.Model`, then validate cross-refs.
|
|
388
415
|
|
|
389
416
|
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
|
|
417
|
+
(``variant_of``, ``children``, ``goal``, ``release``, ``role``, a withdrawal's ``principle``) is
|
|
418
|
+
checked here as a hard error — a dangling reference to something declared in these files is a
|
|
419
|
+
typo you own, not a runtime unknown. ``role`` is checked unconditionally like
|
|
420
|
+
``goal``/``release``: it is a new field, so (unlike free-text ``who``) there is no migration
|
|
421
|
+
reason to make it lax.
|
|
394
422
|
"""
|
|
395
423
|
acc = _Loaded()
|
|
396
424
|
seen: dict[str, dict[str, Path]] = {
|
|
@@ -398,6 +426,7 @@ def load(config: Config) -> Model:
|
|
|
398
426
|
"goal": {},
|
|
399
427
|
"release": {},
|
|
400
428
|
"role": {},
|
|
429
|
+
"principle": {},
|
|
401
430
|
"argument node": {},
|
|
402
431
|
}
|
|
403
432
|
for pattern in config.need_globs:
|
|
@@ -407,19 +436,29 @@ def load(config: Config) -> Model:
|
|
|
407
436
|
acc.goals.extend(f.goals)
|
|
408
437
|
acc.releases.extend(f.releases)
|
|
409
438
|
acc.roles.extend(f.roles)
|
|
439
|
+
acc.principles.extend(f.principles)
|
|
410
440
|
acc.argument.extend(f.argument)
|
|
411
441
|
_check_variants(acc.needs)
|
|
412
442
|
_check_children(acc.needs)
|
|
443
|
+
_check_contradicts(acc.needs)
|
|
413
444
|
_check_ref(acc.needs, "goal", lambda n: n.goal, {g.id for g in acc.goals})
|
|
414
445
|
_check_ref(acc.needs, "release", lambda n: n.release, {r.id for r in acc.releases})
|
|
415
446
|
_check_ref(acc.needs, "role", lambda n: n.role, {p.id for p in acc.roles})
|
|
416
|
-
|
|
447
|
+
_check_ref(
|
|
448
|
+
acc.needs,
|
|
449
|
+
"principle",
|
|
450
|
+
lambda n: n.withdrawn.principle if n.withdrawn else None,
|
|
451
|
+
{a.id for a in acc.principles},
|
|
452
|
+
)
|
|
453
|
+
_check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
|
|
417
454
|
_check_argument(acc.argument)
|
|
455
|
+
acc.needs = _symmetrize_contradicts(acc.needs)
|
|
418
456
|
return Model(
|
|
419
457
|
needs=tuple(acc.needs),
|
|
420
458
|
goals=tuple(acc.goals),
|
|
421
459
|
releases=tuple(acc.releases),
|
|
422
460
|
roles=tuple(acc.roles),
|
|
461
|
+
principles=tuple(acc.principles),
|
|
423
462
|
argument=tuple(acc.argument),
|
|
424
463
|
)
|
|
425
464
|
|
|
@@ -439,18 +478,21 @@ def _check_ref(
|
|
|
439
478
|
raise LoaderError(f"need {need.id!r}: {role} {ref!r} is not a known {role} id")
|
|
440
479
|
|
|
441
480
|
|
|
442
|
-
def _check_source_roles(needs: list[Need], known: set[str]) -> None:
|
|
481
|
+
def _check_source_roles(needs: list[Need], principles: list[Principle], known: set[str]) -> None:
|
|
443
482
|
"""Every ``source.role`` that is set must name a declared role id (like ``need.role``, #52).
|
|
444
483
|
|
|
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.
|
|
484
|
+
Validated unconditionally, on a need's sources and a principle's alike: ``role`` is a new, opt-in
|
|
485
|
+
field, so there is no migration reason to be lax — a dangling role on a source is a typo the
|
|
486
|
+
author owns, surfaced at load, not a silent unknown that only shows as missing provenance later.
|
|
448
487
|
"""
|
|
449
|
-
|
|
450
|
-
|
|
488
|
+
owners = [("need", n.id, n.sources) for n in needs] + [
|
|
489
|
+
("principle", a.id, a.sources) for a in principles
|
|
490
|
+
]
|
|
491
|
+
for noun, oid, sources in owners:
|
|
492
|
+
for i, s in enumerate(sources):
|
|
451
493
|
if s.role is not None and s.role not in known:
|
|
452
494
|
raise LoaderError(
|
|
453
|
-
f"
|
|
495
|
+
f"{noun} {oid!r}: source[{i}] role {s.role!r} is not a known role id"
|
|
454
496
|
)
|
|
455
497
|
|
|
456
498
|
|
|
@@ -479,6 +521,37 @@ def _check_children(needs: list[Need]) -> None:
|
|
|
479
521
|
raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
|
|
480
522
|
|
|
481
523
|
|
|
524
|
+
def _check_contradicts(needs: list[Need]) -> None:
|
|
525
|
+
"""Every id in a need's ``contradicts`` must name a known need, and never the need itself."""
|
|
526
|
+
known = {n.id for n in needs}
|
|
527
|
+
for need in needs:
|
|
528
|
+
for cid in need.contradicts:
|
|
529
|
+
if cid == need.id:
|
|
530
|
+
raise LoaderError(f"need {need.id!r}: contradicts itself")
|
|
531
|
+
if cid not in known:
|
|
532
|
+
raise LoaderError(f"need {need.id!r}: contradicts {cid!r} is not a known need id")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _symmetrize_contradicts(needs: list[Need]) -> list[Need]:
|
|
536
|
+
"""Close the ``contradicts`` relation symmetrically: if A declares B, B contradicts A too.
|
|
537
|
+
|
|
538
|
+
The file stays single-declaration — declare the edge once, so the comment-safe writer never has
|
|
539
|
+
to touch two needs — but the loaded model carries the full symmetric neighbour set on both
|
|
540
|
+
needs, so every reader (show, lint, the JSON payload) sees the one relation. Run after
|
|
541
|
+
:func:`_check_contradicts`, so every target id is known.
|
|
542
|
+
"""
|
|
543
|
+
edges: dict[str, set[str]] = {n.id: set(n.contradicts) for n in needs}
|
|
544
|
+
for n in needs:
|
|
545
|
+
for t in n.contradicts:
|
|
546
|
+
edges[t].add(n.id)
|
|
547
|
+
return [
|
|
548
|
+
replace(n, contradicts=tuple(sorted(edges[n.id])))
|
|
549
|
+
if edges[n.id] != set(n.contradicts)
|
|
550
|
+
else n
|
|
551
|
+
for n in needs
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
|
|
482
555
|
def _check_variants(needs: list[Need]) -> None:
|
|
483
556
|
"""Every ``variant_of`` must point at a known baseline need (one that is not itself a variant)."""
|
|
484
557
|
by_id = {n.id: n for n in needs}
|