prodc 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {prodc-0.4.0 → prodc-0.4.2}/PKG-INFO +1 -1
  2. {prodc-0.4.0 → prodc-0.4.2}/pyproject.toml +1 -1
  3. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/render.py +4 -1
  4. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/serialize.py +1 -1
  5. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/loader.py +54 -6
  6. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/model.py +16 -1
  7. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/status.py +6 -6
  8. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/writer.py +222 -145
  9. {prodc-0.4.0 → prodc-0.4.2}/tests/test_loader.py +41 -0
  10. {prodc-0.4.0 → prodc-0.4.2}/tests/test_models.py +2 -2
  11. {prodc-0.4.0 → prodc-0.4.2}/tests/test_status.py +7 -7
  12. {prodc-0.4.0 → prodc-0.4.2}/tests/test_writer.py +27 -0
  13. {prodc-0.4.0 → prodc-0.4.2}/tests/test_writer_comments.py +46 -1
  14. {prodc-0.4.0 → prodc-0.4.2}/.gitignore +0 -0
  15. {prodc-0.4.0 → prodc-0.4.2}/CHANGELOG.md +0 -0
  16. {prodc-0.4.0 → prodc-0.4.2}/LICENSE +0 -0
  17. {prodc-0.4.0 → prodc-0.4.2}/README.md +0 -0
  18. {prodc-0.4.0 → prodc-0.4.2}/examples/quickstart/features/orders.feature +0 -0
  19. {prodc-0.4.0 → prodc-0.4.2}/examples/quickstart/needs/jordan.yaml +0 -0
  20. {prodc-0.4.0 → prodc-0.4.2}/examples/quickstart/prodc.toml +0 -0
  21. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/__init__.py +0 -0
  22. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/adapters.py +0 -0
  23. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/agent.md +0 -0
  24. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/board.py +0 -0
  25. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/callbacks.py +0 -0
  26. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/__init__.py +0 -0
  27. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/app.py +0 -0
  28. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/commands.py +0 -0
  29. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/common.py +0 -0
  30. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/config.py +0 -0
  31. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/dedup.py +0 -0
  32. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/guide.md +0 -0
  33. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/py.typed +0 -0
  34. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/report.py +0 -0
  35. {prodc-0.4.0 → prodc-0.4.2}/src/prodc/report_html.py +0 -0
  36. {prodc-0.4.0 → prodc-0.4.2}/tests/test_adapters.py +0 -0
  37. {prodc-0.4.0 → prodc-0.4.2}/tests/test_answer.py +0 -0
  38. {prodc-0.4.0 → prodc-0.4.2}/tests/test_callbacks.py +0 -0
  39. {prodc-0.4.0 → prodc-0.4.2}/tests/test_citation.py +0 -0
  40. {prodc-0.4.0 → prodc-0.4.2}/tests/test_claim_gating.py +0 -0
  41. {prodc-0.4.0 → prodc-0.4.2}/tests/test_cli.py +0 -0
  42. {prodc-0.4.0 → prodc-0.4.2}/tests/test_command_hook.py +0 -0
  43. {prodc-0.4.0 → prodc-0.4.2}/tests/test_contradicts.py +0 -0
  44. {prodc-0.4.0 → prodc-0.4.2}/tests/test_decisions.py +0 -0
  45. {prodc-0.4.0 → prodc-0.4.2}/tests/test_dedup.py +0 -0
  46. {prodc-0.4.0 → prodc-0.4.2}/tests/test_description.py +0 -0
  47. {prodc-0.4.0 → prodc-0.4.2}/tests/test_drafts.py +0 -0
  48. {prodc-0.4.0 → prodc-0.4.2}/tests/test_evalgate.py +0 -0
  49. {prodc-0.4.0 → prodc-0.4.2}/tests/test_external_adapter.py +0 -0
  50. {prodc-0.4.0 → prodc-0.4.2}/tests/test_gitlab_source.py +0 -0
  51. {prodc-0.4.0 → prodc-0.4.2}/tests/test_importance.py +0 -0
  52. {prodc-0.4.0 → prodc-0.4.2}/tests/test_needref.py +0 -0
  53. {prodc-0.4.0 → prodc-0.4.2}/tests/test_principles.py +0 -0
  54. {prodc-0.4.0 → prodc-0.4.2}/tests/test_relink.py +0 -0
  55. {prodc-0.4.0 → prodc-0.4.2}/tests/test_report.py +0 -0
  56. {prodc-0.4.0 → prodc-0.4.2}/tests/test_report_html.py +0 -0
  57. {prodc-0.4.0 → prodc-0.4.2}/tests/test_review.py +0 -0
  58. {prodc-0.4.0 → prodc-0.4.2}/tests/test_roles.py +0 -0
  59. {prodc-0.4.0 → prodc-0.4.2}/tests/test_source_role.py +0 -0
  60. {prodc-0.4.0 → prodc-0.4.2}/tests/test_sources.py +0 -0
  61. {prodc-0.4.0 → prodc-0.4.2}/tests/test_staleness.py +0 -0
  62. {prodc-0.4.0 → prodc-0.4.2}/tests/test_suggest.py +0 -0
  63. {prodc-0.4.0 → prodc-0.4.2}/tests/test_variants.py +0 -0
  64. {prodc-0.4.0 → prodc-0.4.2}/tests/test_who_variants.py +0 -0
  65. {prodc-0.4.0 → prodc-0.4.2}/tests/test_withdrawn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Product management as code: roles, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -382,7 +382,10 @@ def render_show(
382
382
  else:
383
383
  lines.append(" sources: (none — opinion)")
384
384
  if need.children:
385
- lines.append(f" children: {', '.join(need.children)}")
385
+ rendered = ", ".join(
386
+ c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children
387
+ )
388
+ lines.append(f" children: {rendered}")
386
389
  if need.contradicts:
387
390
  lines.append(f" contradicts: {', '.join(need.contradicts)}")
388
391
  if need.seam:
@@ -160,7 +160,7 @@ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
160
160
  for s in need.sources
161
161
  ],
162
162
  "proofs": list(need.proofs),
163
- "children": list(need.children),
163
+ "children": [{"id": c.id, "step": c.step} for c in need.children],
164
164
  "seam": list(need.seam),
165
165
  }
166
166
  )
@@ -8,6 +8,7 @@ IDs are globally unique (children reference ids across files), so a duplicate is
8
8
  from __future__ import annotations
9
9
 
10
10
  import datetime
11
+ import re
11
12
  from collections.abc import Callable
12
13
  from dataclasses import dataclass, field, replace
13
14
  from pathlib import Path
@@ -20,6 +21,7 @@ from .model import (
20
21
  Answer,
21
22
  Attestation,
22
23
  AttestationKind,
24
+ Child,
23
25
  Decision,
24
26
  Goal,
25
27
  Model,
@@ -202,6 +204,21 @@ def _source(raw: object, where: str) -> Source:
202
204
  )
203
205
 
204
206
 
207
+ def _child(raw: object, where: str) -> Child:
208
+ """Parse one flow edge: a bare id string, or a ``{id, step}`` mapping (#90).
209
+
210
+ A bare ``US-050`` stays valid (``Child("US-050")`` with no step), so existing flows load
211
+ unchanged; a mapping adds the optional ``step`` the edge plays in the journey.
212
+ """
213
+ if isinstance(raw, str):
214
+ return Child(id=raw)
215
+ tbl = _as_mapping(raw, where)
216
+ return Child(
217
+ id=_as_str(tbl.get("id"), f"{where}.id"),
218
+ step=_opt_str(tbl.get("step"), f"{where}.step"),
219
+ )
220
+
221
+
205
222
  def _need(
206
223
  raw: object,
207
224
  where: str,
@@ -242,7 +259,7 @@ def _need(
242
259
  for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
243
260
  )
244
261
  children = tuple(
245
- _as_str(c, f"{where}.children[{i}]")
262
+ _child(c, f"{where}.children[{i}]")
246
263
  for i, c in enumerate(_as_list(tbl.get("children"), f"{where}.children"))
247
264
  )
248
265
  seam = tuple(
@@ -383,13 +400,44 @@ class _Loaded:
383
400
  decisions: list[Decision] = field(default_factory=list[Decision])
384
401
 
385
402
 
403
+ def yaml_error_message(where: str, text: str, exc: yaml.YAMLError) -> str:
404
+ """Turn a raw PyYAML parse error into a prodc message naming the file, line and nearest need (#84).
405
+
406
+ PyYAML points at ``"<unicode string>", line N`` with no file or need id; on a 200-line board that
407
+ leaves the author hunting by hand. We name the file and line, scan back to the nearest ``id:`` so
408
+ the offending need is identified, and — for the common ``": "``-in-prose case — spell out the fix.
409
+ """
410
+ mark = getattr(exc, "problem_mark", None)
411
+ problem = getattr(exc, "problem", None) or "invalid YAML"
412
+ if mark is None:
413
+ return f"{where}: invalid YAML: {exc}"
414
+ lines = text.splitlines()
415
+ loc = f"{where}: line {mark.line + 1}"
416
+ nid: str | None = None
417
+ for j in range(min(mark.line, len(lines) - 1), -1, -1):
418
+ m = re.match(r"^\s*-?\s*id:\s*(\S+)", lines[j])
419
+ if m:
420
+ nid = m.group(1).strip("\"'")
421
+ break
422
+ if nid is not None:
423
+ loc += f" (need {nid!r})"
424
+ msg = f"{loc}: {problem}"
425
+ if "mapping values are not allowed here" in problem:
426
+ msg += (
427
+ " — a ': ' in an unquoted claim/text reads as a nested mapping; "
428
+ "quote the value in single quotes or rephrase"
429
+ )
430
+ return msg
431
+
432
+
386
433
  def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
387
434
  """Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``principles:``/…)."""
388
435
  where = path.name
436
+ text = path.read_text(encoding="utf-8")
389
437
  try:
390
- doc = yaml.safe_load(path.read_text(encoding="utf-8"))
438
+ doc = yaml.safe_load(text)
391
439
  except yaml.YAMLError as exc:
392
- raise LoaderError(f"{where}: invalid YAML: {exc}") from exc
440
+ raise LoaderError(yaml_error_message(where, text, exc)) from exc
393
441
  out = _Loaded()
394
442
  if doc is None:
395
443
  return out
@@ -532,9 +580,9 @@ def _check_children(needs: list[Need]) -> None:
532
580
  """Every id in a need's ``children`` must name a known need."""
533
581
  known = {n.id for n in needs}
534
582
  for need in needs:
535
- for cid in need.children:
536
- if cid not in known:
537
- raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
583
+ for c in need.children:
584
+ if c.id not in known:
585
+ raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
538
586
 
539
587
 
540
588
  def _check_withdrawal_of(needs: list[Need]) -> None:
@@ -257,6 +257,21 @@ class Withdrawal:
257
257
  of: str | None = None # id of the need this one duplicates (required when kind is duplicate)
258
258
 
259
259
 
260
+ @dataclass(frozen=True)
261
+ class Child:
262
+ """One edge of a flow: the sub-need and, optionally, the ``step`` it plays in the journey (#90).
263
+
264
+ A flow's ``children`` are ordered, and a use case ("job") is just a flow with a ``role`` set and
265
+ a ``step`` on each edge (locate / execute / conclude). The step annotates the *edge*, not the
266
+ sub-need — the same need can be the ``execute`` step of one flow and the ``locate`` of another —
267
+ so it lives here, not on :class:`Need`. A bare ``US-050`` in YAML loads as ``Child("US-050")``
268
+ with no step, so every existing flow keeps working unchanged.
269
+ """
270
+
271
+ id: str
272
+ step: str | None = None
273
+
274
+
260
275
  @dataclass(frozen=True)
261
276
  class Need:
262
277
  """What a named person wants, with its provenance and its proofs."""
@@ -269,7 +284,7 @@ class Need:
269
284
  must: bool = False
270
285
  sources: tuple[Source, ...] = ()
271
286
  proofs: tuple[str, ...] = () # locators, "<adapter>:<payload>" (leaf needs)
272
- children: tuple[str, ...] = () # ids of sub-needs (a flow is a Need with children)
287
+ children: tuple[Child, ...] = () # ordered sub-need edges (a flow is a Need with children)
273
288
  seam: tuple[str, ...] = () # end-to-end proof locators for a flow's between-steps behaviour
274
289
  client: str | None = None # which client this instance is for; None = shared baseline
275
290
  variant_of: str | None = None # id of the baseline need this specialises; None = standalone
@@ -293,11 +293,11 @@ class Resolver:
293
293
  warnings: list[str] = []
294
294
  child_states: list[NeedState] = []
295
295
  declared = False
296
- for cid in need.children:
297
- if cid not in self._by_id:
298
- warnings.append(f"unknown child id '{cid}'")
296
+ for c in need.children:
297
+ if c.id not in self._by_id:
298
+ warnings.append(f"unknown child id '{c.id}'")
299
299
  continue
300
- cr = self.resolve(cid)
300
+ cr = self.resolve(c.id)
301
301
  child_states.append(cr.state)
302
302
  declared = declared or cr.declared
303
303
  delivered = sum(1 for s in child_states if s is NeedState.DELIVERED)
@@ -806,7 +806,7 @@ def handoffs(needs: list[Need]) -> list[Handoff]:
806
806
  for flow in needs:
807
807
  if not flow.children:
808
808
  continue
809
- steps = [by_id[c] for c in flow.children if c in by_id]
809
+ steps = [by_id[c.id] for c in flow.children if c.id in by_id]
810
810
  for a, b in pairwise(steps):
811
811
  if a.role and b.role and a.role != b.role:
812
812
  out.append(Handoff(flow, a, b, a.role, b.role))
@@ -844,7 +844,7 @@ def flow_orphan_warnings(needs: list[Need]) -> set[str]:
844
844
  return set()
845
845
  in_a_flow: set[str] = set()
846
846
  for flow in flows:
847
- in_a_flow.update(flow.children)
847
+ in_a_flow.update(c.id for c in flow.children)
848
848
  return {n.id for n in needs if not n.children and n.id not in in_a_flow}
849
849
 
850
850
 
@@ -14,6 +14,7 @@ import datetime
14
14
  import difflib
15
15
  import hashlib
16
16
  import re
17
+ from collections.abc import Callable
17
18
  from dataclasses import dataclass
18
19
  from pathlib import Path
19
20
  from typing import TYPE_CHECKING, cast
@@ -24,7 +25,7 @@ from .config import Config
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from .model import Source
27
- from .loader import LoaderError, load
28
+ from .loader import LoaderError, load, yaml_error_message
28
29
 
29
30
  # Fields an agent may `set` on a need. Unknown keys are silently ignored by the loader, so the
30
31
  # write path allowlists instead — a typo'd key is an error here, not junk written to disk.
@@ -106,23 +107,24 @@ def _need_block_span(lines: list[str], need_id: str) -> tuple[int, int, int] | N
106
107
  return _block_span(lines, "needs", need_id)
107
108
 
108
109
 
109
- def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int] | None:
110
- """Line span [start, end) and item indent of the top-level ``key:`` list item ``item_id``.
110
+ def _scan_list(lines: list[str], key: str) -> tuple[list[tuple[int, str | None]], int, int] | None:
111
+ """Scan the top-level ``key:`` list; returns ``(item_starts, region_end, item_indent)`` or None.
111
112
 
112
- Line-based and deliberately conservative: it understands the common prodc layout (a
113
+ ``item_starts`` is ``(start_line, id)`` per list item, ``region_end`` is the line index just
114
+ past the list (before trailing blanks/comments are trimmed), and ``item_indent`` is the ``- ``
115
+ indent. Line-based and deliberately conservative: it understands the common prodc layout (a
113
116
  top-level list key — ``needs:``, ``principles:`` — whose items are ``- `` at a fixed indent) and
114
- returns None for anything it is not sure about, so the caller can fall back to a full
115
- re-dump. Trailing blank and comment lines are left out of the span, so a comment that
116
- introduces the *next* item is never swallowed.
117
+ returns None for anything it is not sure about, so the caller can fall back to a full re-dump.
118
+ The single scanner behind both the edit splice (:func:`_block_span`) and the append (#85).
117
119
  """
118
120
  key_line = re.compile(rf"^{re.escape(key)}:\s*(#.*)?$")
119
- needs_at = next((i for i, ln in enumerate(lines) if key_line.match(ln)), None)
120
- if needs_at is None:
121
+ key_at = next((i for i, ln in enumerate(lines) if key_line.match(ln)), None)
122
+ if key_at is None:
121
123
  return None
122
124
  item_re = None
123
125
  item_indent = -1
124
- i = needs_at + 1
125
- spans: list[tuple[int, int, int, str | None]] = [] # start, end, indent, id
126
+ i = key_at + 1
127
+ starts: list[tuple[int, str | None]] = []
126
128
  while i < len(lines):
127
129
  line = lines[i]
128
130
  stripped = line.strip()
@@ -133,19 +135,33 @@ def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int
133
135
  m = re.match(r"^(\s*)-\s", line)
134
136
  if item_re is None:
135
137
  if m is None:
136
- return None # first non-blank line under needs: isn't a list item — unknown shape
138
+ return None # first non-blank line under the key isn't a list item — unknown shape
137
139
  item_indent = len(m.group(1))
138
140
  item_re = re.compile(rf"^\s{{{item_indent}}}-\s")
139
141
  if indent < item_indent and m is None:
140
- break # dedented past the list — end of needs:
142
+ break # dedented past the list — end of the key's region
141
143
  if item_re.match(line):
142
- spans.append((i, i, item_indent, _item_id(lines, i, item_indent)))
144
+ starts.append((i, _item_id(lines, i, item_indent)))
143
145
  elif indent <= item_indent and m is None:
144
146
  break
145
147
  i += 1
146
- # close each span at the start of the next item (or `i`, the end of the list region)
147
- bounds = [s[0] for s in spans] + [i]
148
- for idx, (start, _e, indent, iid) in enumerate(spans):
148
+ return starts, i, item_indent
149
+
150
+
151
+ def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int] | None:
152
+ """Line span [start, end) and item indent of the top-level ``key:`` list item ``item_id``.
153
+
154
+ Trailing blank and comment lines are left out of the span, so a comment that introduces the
155
+ *next* item is never swallowed. None when the key is absent, the shape is unrecognised, or no
156
+ item carries ``item_id`` — the caller then falls back to a full re-dump.
157
+ """
158
+ scanned = _scan_list(lines, key)
159
+ if scanned is None:
160
+ return None
161
+ starts, region_end, item_indent = scanned
162
+ # close each span at the start of the next item (or `region_end`, the end of the list region)
163
+ bounds = [s[0] for s in starts] + [region_end]
164
+ for idx, (start, iid) in enumerate(starts):
149
165
  if iid != item_id:
150
166
  continue
151
167
  end = bounds[idx + 1]
@@ -153,7 +169,7 @@ def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int
153
169
  lines[end - 1].strip() == "" or lines[end - 1].strip().startswith("#")
154
170
  ):
155
171
  end -= 1
156
- return start, end, indent
172
+ return start, end, item_indent
157
173
  return None
158
174
 
159
175
 
@@ -234,6 +250,48 @@ def _render_item_edit(old_text: str, doc: dict[str, object], key: str, item_id:
234
250
  return "".join(lines[:start]) + block + "".join(lines[end:])
235
251
 
236
252
 
253
+ def _render_item_append(old_text: str, doc: dict[str, object], key: str, item_id: str) -> str:
254
+ """Append the new ``key:`` list item ``item_id`` to ``old_text``, every comment intact (#85).
255
+
256
+ The insert mirror of :func:`_render_item_edit`: dump only the new item and splice it in after
257
+ the last existing item of the list, so the header, the other items, and their comments stay
258
+ byte-for-byte. Falls back to a full re-dump when the file has no recognised ``key:`` list to
259
+ append to (a brand-new file or key, where there are no in-list comments to lose); the reload
260
+ gate in :func:`_validated` guards correctness either way.
261
+ """
262
+ lines = old_text.splitlines(keepends=True)
263
+ scanned = _scan_list(lines, key)
264
+ items = doc.get(key)
265
+ if scanned is None or not isinstance(items, list):
266
+ return _dump(doc)
267
+ starts, region_end, item_indent = scanned
268
+ if not starts: # the key exists but holds no recognised item — re-dump rather than guess indent
269
+ return _dump(doc)
270
+ item = next(
271
+ (
272
+ cast("dict[str, object]", n)
273
+ for n in cast("list[object]", items)
274
+ if isinstance(n, dict) and cast("dict[str, object]", n).get("id") == item_id
275
+ ),
276
+ None,
277
+ )
278
+ if item is None:
279
+ return _dump(doc)
280
+ # insert after the last item's content, before any trailing blank/comment lines of the section
281
+ insert_at = region_end
282
+ first_start = starts[0][0]
283
+ while insert_at - 1 >= first_start and (
284
+ lines[insert_at - 1].strip() == "" or lines[insert_at - 1].strip().startswith("#")
285
+ ):
286
+ insert_at -= 1
287
+ dumped = yaml.safe_dump([item], sort_keys=False, allow_unicode=True, default_flow_style=False)
288
+ pad = " " * item_indent
289
+ block = "".join(pad + ln if ln.strip() else ln for ln in dumped.splitlines(keepends=True))
290
+ if insert_at > 0 and not lines[insert_at - 1].endswith("\n"):
291
+ block = "\n" + block # the item before the insert point had no trailing newline
292
+ return "".join(lines[:insert_at]) + block + "".join(lines[insert_at:])
293
+
294
+
237
295
  def _render_delete(old_text: str, doc: dict[str, object], need_id: str) -> str:
238
296
  """Re-render after removing a need, excising only its block to keep every other comment (#60).
239
297
 
@@ -255,7 +313,7 @@ def _read_doc(path: Path) -> tuple[dict[str, object], str]:
255
313
  try:
256
314
  doc = yaml.safe_load(text)
257
315
  except yaml.YAMLError as exc:
258
- raise WriteError(f"{path.name}: invalid YAML: {exc}") from exc
316
+ raise WriteError(yaml_error_message(path.name, text, exc)) from exc
259
317
  if doc is None:
260
318
  doc = {}
261
319
  if not isinstance(doc, dict):
@@ -270,6 +328,14 @@ def _need_list(doc: dict[str, object], path: Path) -> list[object]:
270
328
  return cast("list[object]", raw)
271
329
 
272
330
 
331
+ def _proof_list(need: dict[str, object], path: Path) -> list[object]:
332
+ """A fresh copy of a need's ``proofs`` list, erroring if the field is present but not a list."""
333
+ current = need.get("proofs", [])
334
+ if not isinstance(current, list):
335
+ raise WriteError(f"{path.name}: 'proofs' is not a list")
336
+ return list(cast("list[object]", current))
337
+
338
+
273
339
  def _config_files(config: Config) -> list[Path]:
274
340
  seen: set[Path] = set()
275
341
  out: list[Path] = []
@@ -299,6 +365,19 @@ def _coerce(value: str) -> object:
299
365
  return value
300
366
 
301
367
 
368
+ def _child_item(raw: str) -> object:
369
+ """Parse a ``children+=`` value into a flow edge: ``id`` stays a bare string, ``id@step`` a map.
370
+
371
+ Mirrors the loader's dual shape (#90): ``children+=US-050`` appends the bare id (unstepped),
372
+ ``children+=US-052@execute`` appends ``{id: US-052, step: execute}``. A bare id keeps the file's
373
+ existing plain-string style; the ``@`` suffix mirrors the ``need:A@yes`` proof-locator idiom.
374
+ """
375
+ head, sep, step = raw.partition("@")
376
+ if not sep:
377
+ return raw
378
+ return {"id": head.strip(), "step": step.strip()}
379
+
380
+
302
381
  def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> None:
303
382
  if "+=" in assignment:
304
383
  key, _, raw = assignment.partition("+=")
@@ -311,7 +390,7 @@ def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> N
311
390
  if not isinstance(current, list):
312
391
  raise WriteError(f"{path.name}: {key!r} is not a list")
313
392
  items = cast("list[object]", current)
314
- items.append(raw.strip())
393
+ items.append(_child_item(raw.strip()) if key == "children" else raw.strip())
315
394
  need[key] = items
316
395
  return
317
396
  key, sep, raw = assignment.partition("=")
@@ -364,21 +443,46 @@ def _validated(config: Config, path: Path, old_text: str, new_text: str, dry_run
364
443
  return Edit(path=path, old_text=old_text, new_text=new_text)
365
444
 
366
445
 
367
- def set_field(
368
- config: Config, need_id: str, assignment: str, *, expect_sha: str | None, dry_run: bool
446
+ def _edit_one_need(
447
+ config: Config,
448
+ need_id: str,
449
+ *,
450
+ expect_sha: str | None,
451
+ dry_run: bool,
452
+ mutate: Callable[[dict[str, object], Path], None],
369
453
  ) -> Edit:
370
- """Set or list-append one field on an existing need, validated against the loader."""
454
+ """Run ``mutate`` on one existing need's raw mapping, comment-safe and reload-gated (#82).
455
+
456
+ The shared skeleton of every single-need writer: locate the file → read it → check the
457
+ optimistic lock → find the need by id → apply ``mutate`` (the only part that differs) → splice
458
+ just that need's block back (#53) → re-load as the schema and revert on failure (:func:`_validated`).
459
+ ``mutate`` receives the need's mapping and the file path (for field-level errors) and edits the
460
+ mapping in place; raising :class:`WriteError` from it aborts before anything is written.
461
+ """
371
462
  path, _ = _locate(config, need_id)
372
463
  doc, old_text = _read_doc(path)
373
464
  if expect_sha is not None and _sha256(old_text) != expect_sha:
374
465
  raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
375
466
  for raw in _need_list(doc, path):
376
467
  if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
377
- _apply_assignment(cast("dict[str, object]", raw), assignment, path)
468
+ mutate(cast("dict[str, object]", raw), path)
378
469
  break
379
470
  return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
380
471
 
381
472
 
473
+ def set_field(
474
+ config: Config, need_id: str, assignment: str, *, expect_sha: str | None, dry_run: bool
475
+ ) -> Edit:
476
+ """Set or list-append one field on an existing need, validated against the loader."""
477
+ return _edit_one_need(
478
+ config,
479
+ need_id,
480
+ expect_sha=expect_sha,
481
+ dry_run=dry_run,
482
+ mutate=lambda need, path: _apply_assignment(need, assignment, path),
483
+ )
484
+
485
+
382
486
  def set_importance(
383
487
  config: Config, need_id: str, level: int, when: str, *, expect_sha: str | None, dry_run: bool
384
488
  ) -> Edit:
@@ -387,17 +491,14 @@ def set_importance(
387
491
  The level is already resolved against the rubric by the caller; this just writes both fields
388
492
  together so ranking never refines on a number nobody has revisited.
389
493
  """
390
- path, _ = _locate(config, need_id)
391
- doc, old_text = _read_doc(path)
392
- if expect_sha is not None and _sha256(old_text) != expect_sha:
393
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
394
- for raw in _need_list(doc, path):
395
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
396
- need = cast("dict[str, object]", raw)
397
- need["importance"] = level
398
- need["importance_set_at"] = when
399
- break
400
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
494
+
495
+ def _mutate(need: dict[str, object], _path: Path) -> None:
496
+ need["importance"] = level
497
+ need["importance_set_at"] = when
498
+
499
+ return _edit_one_need(
500
+ config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
501
+ )
401
502
 
402
503
 
403
504
  def capture_source(
@@ -423,10 +524,6 @@ def capture_source(
423
524
  next load) — whose signal it is, orthogonal to the evidence tier (#52 follow-up).
424
525
  """
425
526
  kind = _canonical_kind(kind)
426
- path, _ = _locate(config, need_id)
427
- doc, old_text = _read_doc(path)
428
- if expect_sha is not None and _sha256(old_text) != expect_sha:
429
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
430
527
  source: dict[str, object] = {"kind": kind}
431
528
  for key, value in (
432
529
  ("who", who),
@@ -438,17 +535,18 @@ def capture_source(
438
535
  ):
439
536
  if value is not None:
440
537
  source[key] = value
441
- for raw in _need_list(doc, path):
442
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
443
- need = cast("dict[str, object]", raw)
444
- current = need.get("sources", [])
445
- if not isinstance(current, list):
446
- raise WriteError(f"{path.name}: 'sources' is not a list")
447
- sources = cast("list[object]", current)
448
- sources.append(source)
449
- need["sources"] = sources
450
- break
451
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
538
+
539
+ def _mutate(need: dict[str, object], path: Path) -> None:
540
+ current = need.get("sources", [])
541
+ if not isinstance(current, list):
542
+ raise WriteError(f"{path.name}: 'sources' is not a list")
543
+ sources = cast("list[object]", current)
544
+ sources.append(source)
545
+ need["sources"] = sources
546
+
547
+ return _edit_one_need(
548
+ config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
549
+ )
452
550
 
453
551
 
454
552
  def attest(
@@ -475,15 +573,13 @@ def attest(
475
573
  att["verdict"] = verdict
476
574
  if comment is not None:
477
575
  att["comment"] = comment
478
- path, _ = _locate(config, need_id)
479
- doc, old_text = _read_doc(path)
480
- if expect_sha is not None and _sha256(old_text) != expect_sha:
481
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
482
- for raw in _need_list(doc, path):
483
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
484
- cast("dict[str, object]", raw)["attestation"] = att
485
- break
486
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
576
+ return _edit_one_need(
577
+ config,
578
+ need_id,
579
+ expect_sha=expect_sha,
580
+ dry_run=dry_run,
581
+ mutate=lambda need, _path: need.__setitem__("attestation", att),
582
+ )
487
583
 
488
584
 
489
585
  def withdraw(
@@ -515,15 +611,13 @@ def withdraw(
515
611
  mark["principle"] = principle
516
612
  if of is not None:
517
613
  mark["of"] = of
518
- path, _ = _locate(config, need_id)
519
- doc, old_text = _read_doc(path)
520
- if expect_sha is not None and _sha256(old_text) != expect_sha:
521
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
522
- for raw in _need_list(doc, path):
523
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
524
- cast("dict[str, object]", raw)["withdrawn"] = mark
525
- break
526
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
614
+ return _edit_one_need(
615
+ config,
616
+ need_id,
617
+ expect_sha=expect_sha,
618
+ dry_run=dry_run,
619
+ mutate=lambda need, _path: need.__setitem__("withdrawn", mark),
620
+ )
527
621
 
528
622
 
529
623
  def reinstate(config: Config, need_id: str, *, expect_sha: str | None, dry_run: bool) -> Edit:
@@ -532,18 +626,15 @@ def reinstate(config: Config, need_id: str, *, expect_sha: str | None, dry_run:
532
626
  Errors if the need is not currently withdrawn — reinstating a live need is a mistake worth
533
627
  surfacing, not a silent no-op.
534
628
  """
535
- path, _ = _locate(config, need_id)
536
- doc, old_text = _read_doc(path)
537
- if expect_sha is not None and _sha256(old_text) != expect_sha:
538
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
539
- for raw in _need_list(doc, path):
540
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
541
- need = cast("dict[str, object]", raw)
542
- if "withdrawn" not in need:
543
- raise WriteError(f"need {need_id!r} is not withdrawn")
544
- del need["withdrawn"]
545
- break
546
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
629
+
630
+ def _mutate(need: dict[str, object], _path: Path) -> None:
631
+ if "withdrawn" not in need:
632
+ raise WriteError(f"need {need_id!r} is not withdrawn")
633
+ del need["withdrawn"]
634
+
635
+ return _edit_one_need(
636
+ config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
637
+ )
547
638
 
548
639
 
549
640
  def _locate_decision(config: Config, decision_id: str) -> tuple[Path, dict[str, object], str]:
@@ -644,24 +735,19 @@ def accept(
644
735
  one-liner instead of a hand edit. Idempotent: re-accepting the same who/date is a no-op.
645
736
  """
646
737
  locator = f"accept:{who}@{date}"
647
- path, _ = _locate(config, need_id)
648
- doc, old_text = _read_doc(path)
649
- if expect_sha is not None and _sha256(old_text) != expect_sha:
650
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
651
- for raw in _need_list(doc, path):
652
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
653
- need = cast("dict[str, object]", raw)
654
- current = need.get("proofs", [])
655
- if not isinstance(current, list):
656
- raise WriteError(f"{path.name}: 'proofs' is not a list")
657
- proofs = [p for p in cast("list[object]", current)]
658
- if revoke:
659
- proofs = [p for p in proofs if p != locator]
660
- elif locator not in proofs:
738
+
739
+ def _mutate(need: dict[str, object], path: Path) -> None:
740
+ proofs = _proof_list(need, path)
741
+ if revoke:
742
+ need["proofs"] = [p for p in proofs if p != locator]
743
+ else:
744
+ if locator not in proofs:
661
745
  proofs.append(locator)
662
746
  need["proofs"] = proofs
663
- break
664
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
747
+
748
+ return _edit_one_need(
749
+ config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
750
+ )
665
751
 
666
752
 
667
753
  def add_proof(
@@ -673,22 +759,15 @@ def add_proof(
673
759
  batch apply can call it per need and show each need's diff. Re-adding a locator already on the
674
760
  need is a no-op, so applying the same suggestion twice does not duplicate it (#54).
675
761
  """
676
- path, _ = _locate(config, need_id)
677
- doc, old_text = _read_doc(path)
678
- if expect_sha is not None and _sha256(old_text) != expect_sha:
679
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
680
- for raw in _need_list(doc, path):
681
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
682
- need = cast("dict[str, object]", raw)
683
- current = need.get("proofs", [])
684
- if not isinstance(current, list):
685
- raise WriteError(f"{path.name}: 'proofs' is not a list")
686
- proofs = list(cast("list[object]", current))
687
- if locator not in proofs:
688
- proofs.append(locator)
689
- need["proofs"] = proofs
690
- break
691
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
762
+ def _mutate(need: dict[str, object], path: Path) -> None:
763
+ proofs = _proof_list(need, path)
764
+ if locator not in proofs:
765
+ proofs.append(locator)
766
+ need["proofs"] = proofs
767
+
768
+ return _edit_one_need(
769
+ config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
770
+ )
692
771
 
693
772
 
694
773
  def relink_proof(
@@ -708,27 +787,20 @@ def relink_proof(
708
787
  old locator is not actually on the need. One reload-gated single-file splice, like
709
788
  :func:`add_proof`.
710
789
  """
711
- path, _ = _locate(config, need_id)
712
- doc, old_text = _read_doc(path)
713
- if expect_sha is not None and _sha256(old_text) != expect_sha:
714
- raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
715
- for raw in _need_list(doc, path):
716
- if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
717
- need = cast("dict[str, object]", raw)
718
- current = need.get("proofs", [])
719
- if not isinstance(current, list):
720
- raise WriteError(f"{path.name}: 'proofs' is not a list")
721
- proofs = list(cast("list[object]", current))
722
- if old_locator not in proofs:
723
- raise WriteError(f"{old_locator!r} is not a proof on {need_id!r}")
724
- swapped = [new_locator if p == old_locator else p for p in proofs]
725
- deduped: list[object] = []
726
- for p in swapped:
727
- if p not in deduped:
728
- deduped.append(p)
729
- need["proofs"] = deduped
730
- break
731
- return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
790
+ def _mutate(need: dict[str, object], path: Path) -> None:
791
+ proofs = _proof_list(need, path)
792
+ if old_locator not in proofs:
793
+ raise WriteError(f"{old_locator!r} is not a proof on {need_id!r}")
794
+ swapped = [new_locator if p == old_locator else p for p in proofs]
795
+ deduped: list[object] = []
796
+ for p in swapped:
797
+ if p not in deduped:
798
+ deduped.append(p)
799
+ need["proofs"] = deduped
800
+
801
+ return _edit_one_need(
802
+ config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
803
+ )
732
804
 
733
805
 
734
806
  def add_need(
@@ -748,7 +820,8 @@ def add_need(
748
820
  An agent-authored need must carry a source — and never ``primary``, which only a real user
749
821
  signal earns. ``kind`` is restricted to ``assumption`` (the agent's own guess) or ``secondary``
750
822
  (citing an existing document). A ``draft`` need is staged: it loads but is kept off the counted
751
- board until promoted (#60).
823
+ board until promoted (#60). The need is appended with the comment-preserving splice (#85), so
824
+ existing needs and their comments stay byte-for-byte; a brand-new file is dumped fresh.
752
825
  """
753
826
  kind = _SOURCE_KIND_ALIASES.get(kind, kind)
754
827
  if kind not in ("assumption", "secondary"):
@@ -778,7 +851,8 @@ def add_need(
778
851
  new_need["draft"] = True
779
852
  new_need["draft_added"] = today # stamped so a lint age-gate can fire later (#60)
780
853
  needs.append(new_need)
781
- return _validated(config, target, old_text, _dump(doc), dry_run)
854
+ new_text = _render_item_append(old_text, doc, "needs", need_id)
855
+ return _validated(config, target, old_text, new_text, dry_run)
782
856
 
783
857
 
784
858
  def add_role(
@@ -793,9 +867,9 @@ def add_role(
793
867
  """Declare a new Role in ``file``'s top-level ``roles:`` list (#52 follow-up).
794
868
 
795
869
  The dedicated write-back for a role, so a roster entry is a one-liner instead of a hand
796
- edit. Like :func:`add_need`, a new/edited file is re-dumped whole (comments in that file are
797
- not preserved — use `set` on an existing need for a comment-safe splice); the reload gate
798
- still rejects anything that does not load. A duplicate role id is refused.
870
+ edit. Like :func:`add_need`, the new role is appended with the comment-preserving splice (#85),
871
+ so an existing file's comments stay byte-for-byte and only a brand-new file is dumped fresh;
872
+ the reload gate still rejects anything that does not load. A duplicate role id is refused.
799
873
  """
800
874
  target = config.root / file
801
875
  if target.exists():
@@ -813,7 +887,8 @@ def add_role(
813
887
  ):
814
888
  raise WriteError(f"role {role_id!r} already exists in {target.name}")
815
889
  roles.append({"id": role_id, "text": text})
816
- return _validated(config, target, old_text, _dump(doc), dry_run)
890
+ new_text = _render_item_append(old_text, doc, "roles", role_id)
891
+ return _validated(config, target, old_text, new_text, dry_run)
817
892
 
818
893
 
819
894
  def _source_dict(s: Source) -> dict[str, object]:
@@ -845,9 +920,9 @@ def add_principle(
845
920
  ) -> Edit:
846
921
  """Declare a new Principle in ``file``'s top-level ``principles:`` list, stamped ``revised_at``.
847
922
 
848
- Mirrors :func:`add_role`: a new/edited file is re-dumped whole, the reload gate rejects
849
- anything that does not load (including an id already declared in another file), and a
850
- duplicate id in this file is refused. ``revised_at`` is stamped at creation so the first
923
+ Mirrors :func:`add_role`: the new principle is appended with the comment-preserving splice (#85),
924
+ the reload gate rejects anything that does not load (including an id already declared in another
925
+ file), and a duplicate id in this file is refused. ``revised_at`` is stamped at creation so the first
851
926
  revision has something to ripple against. ``sources`` carry the provenance that surfaced the
852
927
  principle — the signals (often a client) a ``--from-need`` promotion copies across, so the
853
928
  discovery history stays honest: the principle was discovered, not decreed.
@@ -872,7 +947,8 @@ def add_principle(
872
947
  if sources:
873
948
  entry["sources"] = [_source_dict(s) for s in sources]
874
949
  principles.append(entry)
875
- return _validated(config, target, old_text, _dump(doc), dry_run)
950
+ new_text = _render_item_append(old_text, doc, "principles", principle_id)
951
+ return _validated(config, target, old_text, new_text, dry_run)
876
952
 
877
953
 
878
954
  def _locate_principle(config: Config, principle_id: str) -> tuple[Path, dict[str, object], str]:
@@ -934,7 +1010,7 @@ def add_decision(
934
1010
  ) -> Edit:
935
1011
  """Declare a new Decision (the solution chosen for ``needs``) in ``file``'s ``decisions:`` list.
936
1012
 
937
- Mirrors :func:`add_principle` (whole-file re-dump, reload-gated, duplicate id refused). ``needs``
1013
+ Mirrors :func:`add_principle` (comment-preserving append #85, reload-gated, duplicate id refused). ``needs``
938
1014
  is required — the loader rejects a decision that serves none, and the reload gate rejects an id
939
1015
  that is not a known need. Empty optional lists are omitted so the written block stays clean.
940
1016
  """
@@ -965,7 +1041,8 @@ def add_decision(
965
1041
  if draft:
966
1042
  entry["draft"] = True
967
1043
  decisions.append(entry)
968
- return _validated(config, target, old_text, _dump(doc), dry_run)
1044
+ new_text = _render_item_append(old_text, doc, "decisions", decision_id)
1045
+ return _validated(config, target, old_text, new_text, dry_run)
969
1046
 
970
1047
 
971
1048
  def _snapshot(paths: list[Path]) -> dict[Path, str | None]:
@@ -109,6 +109,26 @@ def test_flow_with_proofs_raises(tmp_path: Path) -> None:
109
109
  load_needs(cfg)
110
110
 
111
111
 
112
+ def test_children_load_as_bare_id_or_stepped_mapping(tmp_path: Path) -> None:
113
+ # #90: a flow's children take either a bare id (unstepped) or a {id, step} mapping, mixed.
114
+ cfg = _config(
115
+ tmp_path,
116
+ (
117
+ "a.yaml",
118
+ "needs:\n"
119
+ " - id: F-1\n"
120
+ " text: a flow\n"
121
+ " children:\n"
122
+ " - C-1\n"
123
+ " - {id: C-2, step: execute}\n"
124
+ " - id: C-1\n text: one\n"
125
+ " - id: C-2\n text: two\n",
126
+ ),
127
+ )
128
+ flow = next(n for n in load_needs(cfg) if n.id == "F-1")
129
+ assert [(c.id, c.step) for c in flow.children] == [("C-1", None), ("C-2", "execute")]
130
+
131
+
112
132
  def test_variant_scope_shared_baseline_across_clients(tmp_path: Path) -> None:
113
133
  cfg = _config(
114
134
  tmp_path,
@@ -366,3 +386,24 @@ def test_seam_only_on_flows(tmp_path: Path) -> None:
366
386
  cfg = _config(tmp_path, ("a.yaml", "needs:\n - id: N-1\n text: t\n seam: [web:x#y]\n"))
367
387
  with pytest.raises(LoaderError, match="'seam' is for a flow"):
368
388
  load_needs(cfg)
389
+
390
+
391
+ def test_unquoted_colon_in_claim_names_the_need_and_the_fix(tmp_path: Path) -> None:
392
+ # #84: a ': ' in an unquoted prose claim must give a field-level error, not a raw YAML trace.
393
+ cfg = _config(
394
+ tmp_path,
395
+ (
396
+ "a.yaml",
397
+ "needs:\n"
398
+ " - id: N-5\n"
399
+ " text: original\n"
400
+ " claim: clear BOTH conditions with margin: (1) a and (2) b\n",
401
+ ),
402
+ )
403
+ with pytest.raises(LoaderError) as exc:
404
+ load_needs(cfg)
405
+ msg = str(exc.value)
406
+ assert "a.yaml" in msg
407
+ assert "N-5" in msg # the offending need, not just a line/column
408
+ assert "line 4" in msg
409
+ assert "quote the value" in msg # the actionable hint
@@ -14,7 +14,7 @@ from dataclasses import dataclass, replace
14
14
  from musil import Action, Model, check, explore, reaches
15
15
 
16
16
  from prodc.adapters import Adapter, ProofResult
17
- from prodc.model import Decision, Need, NeedState, ProofState, Withdrawal, board_needs
17
+ from prodc.model import Child, Decision, Need, NeedState, ProofState, Withdrawal, board_needs
18
18
  from prodc.status import Resolver, active_decisions
19
19
 
20
20
 
@@ -141,7 +141,7 @@ def flow_state(f: Flow) -> NeedState:
141
141
  """The NeedState prodc's real aggregate gives this set of children + seam."""
142
142
  children = [_child_need(f"c{i}", s) for i, s in enumerate(f.children)]
143
143
  seam = tuple(f"stub:{s}" for s in f.seam if s != "none")
144
- flow = Need(id="flow", text="f", children=tuple(c.id for c in children), seam=seam)
144
+ flow = Need(id="flow", text="f", children=tuple(Child(c.id) for c in children), seam=seam)
145
145
  return Resolver([flow, *children], _ADAPTERS).resolve("flow").state
146
146
 
147
147
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
8
  from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
9
- from prodc.model import Need, NeedState, ProofState, Source, SourceKind
9
+ from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
10
10
  from prodc.status import resolve
11
11
 
12
12
 
@@ -52,7 +52,7 @@ def test_flow_aggregates_two_of_three() -> None:
52
52
  Need(id="C-2", text="two", proofs=("web:2",)),
53
53
  Need(id="C-3", text="three", proofs=()), # a HOLE
54
54
  )
55
- flow = Need(id="F-1", text="a flow", children=("C-1", "C-2", "C-3"))
55
+ flow = Need(id="F-1", text="a flow", children=(Child("C-1"), Child("C-2"), Child("C-3")))
56
56
  results = {r.need.id: r for r in resolve([flow, *children], {"web": _delivered()})}
57
57
  flow_result = results["F-1"]
58
58
  assert flow_result.state is NeedState.PENDING # not all delivered → pending
@@ -234,7 +234,7 @@ def test_coverage_counts_leaves_not_flow_parents() -> None:
234
234
  from prodc.status import summarize
235
235
 
236
236
  needs = [
237
- Need(id="F-1", text="roast day flow", children=("L-1", "L-2")),
237
+ Need(id="F-1", text="roast day flow", children=(Child("L-1"), Child("L-2"))),
238
238
  Need(id="L-1", text="step one", proofs=("x:1",)),
239
239
  Need(id="L-2", text="step two", proofs=("x:1",)),
240
240
  ]
@@ -296,7 +296,7 @@ def test_flow_orphan_warning() -> None:
296
296
  from prodc.status import flow_orphan_warnings
297
297
 
298
298
  needs = [
299
- Need(id="F-1", text="a flow", children=("L-1",)),
299
+ Need(id="F-1", text="a flow", children=(Child("L-1"),)),
300
300
  Need(id="L-1", text="in the flow"),
301
301
  Need(id="L-2", text="in no flow"),
302
302
  ]
@@ -354,7 +354,7 @@ def test_flow_seam_proof_gates_delivered() -> None:
354
354
  # all children delivered but the seam proof pending → flow is PENDING, not DELIVERED (#44)
355
355
  from prodc.adapters import Adapter
356
356
 
357
- flow = Need(id="F-1", text="roast day", children=("L-1", "L-2"), seam=("web:seam#E2E",))
357
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"), Child("L-2")), seam=("web:seam#E2E",))
358
358
  children = [
359
359
  Need(id="L-1", text="a", proofs=("x:1",)),
360
360
  Need(id="L-2", text="b", proofs=("x:1",)),
@@ -368,14 +368,14 @@ def test_flow_seam_proof_gates_delivered() -> None:
368
368
 
369
369
 
370
370
  def test_flow_seam_green_delivers() -> None:
371
- flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("web:seam#E2E",))
371
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("web:seam#E2E",))
372
372
  child = Need(id="L-1", text="a", proofs=("x:1",))
373
373
  by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered(), "web": _delivered()})}
374
374
  assert by_id["F-1"].state is NeedState.DELIVERED
375
375
 
376
376
 
377
377
  def test_flow_seam_broken_breaks_flow() -> None:
378
- flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("nope:x",))
378
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("nope:x",))
379
379
  child = Need(id="L-1", text="a", proofs=("x:1",))
380
380
  by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered()})}
381
381
  assert by_id["F-1"].state is NeedState.BROKEN # no 'nope' adapter → seam BROKEN
@@ -275,6 +275,33 @@ def test_delete_need_removes_the_block_keeping_others(tmp_path: Path) -> None:
275
275
  assert "# keep me" in text # header comment survives the splice
276
276
 
277
277
 
278
+ def test_children_append_stepped_edge(tmp_path: Path) -> None:
279
+ # #90: `children+=id@step` appends a {id, step} edge; a bare id stays a plain string.
280
+ body = (
281
+ "needs:\n"
282
+ " - id: F-1\n text: a flow\n children: [N-2]\n"
283
+ " - id: N-2\n text: step two\n proofs: ['x:1']\n"
284
+ " - id: N-3\n text: step three\n proofs: ['x:2']\n"
285
+ )
286
+ cfg = _proj(tmp_path, body)
287
+ set_field(cfg, "F-1", "children+=N-3@conclude", expect_sha=None, dry_run=False)
288
+ flow = next(n for n in load(cfg).needs if n.id == "F-1")
289
+ assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", "conclude")]
290
+
291
+
292
+ def test_children_append_bare_id(tmp_path: Path) -> None:
293
+ body = (
294
+ "needs:\n"
295
+ " - id: F-1\n text: a flow\n children: [N-2]\n"
296
+ " - id: N-2\n text: step two\n proofs: ['x:1']\n"
297
+ " - id: N-3\n text: step three\n proofs: ['x:2']\n"
298
+ )
299
+ cfg = _proj(tmp_path, body)
300
+ set_field(cfg, "F-1", "children+=N-3", expect_sha=None, dry_run=False)
301
+ flow = next(n for n in load(cfg).needs if n.id == "F-1")
302
+ assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
303
+
304
+
278
305
  def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
279
306
  cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
280
307
  with pytest.raises(WriteError, match="no need 'N-9'"):
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from prodc.config import Config
8
- from prodc.writer import accept, capture_source, set_field
8
+ from prodc.writer import accept, add_need, capture_source, set_field
9
9
 
10
10
  _TOML = """
11
11
  [prodc]
@@ -111,3 +111,48 @@ def test_inline_list_file_falls_back_but_still_edits(tmp_path: Path) -> None:
111
111
  set_field(cfg, "N-1", "text=edited via fallback", expect_sha=None, dry_run=False)
112
112
  out = _read(tmp_path)
113
113
  assert "edited via fallback" in out
114
+
115
+
116
+ def test_add_appends_and_preserves_comments(tmp_path: Path) -> None:
117
+ # #85: `add` must splice the new need in, not re-dump the whole file and drop every comment.
118
+ cfg = _project(tmp_path)
119
+ add_need(
120
+ cfg,
121
+ "N-3",
122
+ "a freshly added need",
123
+ who="agent",
124
+ kind="assumption",
125
+ file="needs/a.yaml",
126
+ expect_sha=None,
127
+ dry_run=False,
128
+ )
129
+ out = _read(tmp_path)
130
+ # every authored comment survives
131
+ assert "# Provenance contract: interview quotes are VERBATIM" in out
132
+ assert "# ids are minted in the N-16xx range." in out
133
+ assert "# this comment introduces the second need" in out
134
+ # the existing needs are untouched and the 2-space item indent is matched, not reflowed
135
+ assert " - id: N-1\n" in out
136
+ assert " text: second need stays untouched" in out
137
+ assert " - id: N-3\n" in out
138
+ assert "a freshly added need" in out
139
+ # the new need lands after N-2, not before it
140
+ assert out.index("id: N-3") > out.index("id: N-2")
141
+
142
+
143
+ def test_add_into_new_file_dumps_fresh(tmp_path: Path) -> None:
144
+ # No existing list to append to: fall back to a whole-file dump, which must still load.
145
+ cfg = _project(tmp_path)
146
+ add_need(
147
+ cfg,
148
+ "N-9",
149
+ "a need in a brand-new file",
150
+ who="agent",
151
+ kind="assumption",
152
+ file="needs/b.yaml",
153
+ expect_sha=None,
154
+ dry_run=False,
155
+ )
156
+ out = (tmp_path / "needs" / "b.yaml").read_text(encoding="utf-8")
157
+ assert "id: N-9" in out
158
+ assert "a need in a brand-new file" in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes