deepcell-cli 0.6.1__py3-none-any.whl

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 (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2981 @@
1
+ """CLI subcommands for Reasoning section (spec §6.3).
2
+
3
+ Every command here addresses a file by its WORKSPACE filename, the same way
4
+ `deepcell query` / `cat` / `ls` do. The `claim` / `assumption` inspection
5
+ groups used to take a local `--file` path instead, which made them unusable
6
+ from MCP (no checkout) and collided with the root `-f/--format`; they now
7
+ fetch the XML through the API and parse it in-process. `reasoning-diff` is
8
+ the one exception and is local by nature — it compares a working-tree file
9
+ against its own git HEAD.
10
+
11
+ Implementation note: parsing is done directly with stdlib xml.etree.ElementTree
12
+ rather than importing the full backend DeepCellDocument stack, which carries
13
+ heavy optional dependencies (defusedxml, etc.) not installed in the CLI venv.
14
+ The logic mirrors backend/src/core/document/queries.py (get_claims,
15
+ get_arguments, get_supersedes_chain, get_assumption_impact) and
16
+ backend/src/core/validation/reasoning_diff.py (compute_reasoning_diff).
17
+
18
+ KNOWN DUPLICATION (Phase 4 / I5)
19
+ ================================
20
+ This module re-implements ~300 lines of XML traversal that lives canonically in
21
+ backend/src/core/document/queries.py and backend/src/core/validation/reasoning_diff.py.
22
+ Phase 4 deliberately defers the full collapse — the JS-side SDK
23
+ (@deepcell/spreadsheet-sdk) does not yet publish a Python-native helper, and
24
+ adding the backend stack as a CLI dependency carries heavy optional deps
25
+ (defusedxml, lxml, calc-engine) that double the install footprint.
26
+
27
+ Migration plan once a thin shared helper exists:
28
+ 1. Publish a small `deepcell_reasoning` python package (or factor a
29
+ dependency-light subset of `src.core.document.queries` that only needs
30
+ stdlib XML) that exposes get_claims / get_arguments / get_supersedes_chain
31
+ / get_assumption_impact and compute_reasoning_diff.
32
+ 2. Replace the helpers in this file with calls into that package; keep the
33
+ click command wiring unchanged.
34
+ 3. The pinning golden-file contract test in tests/test_reasoning_commands.py
35
+ will detect any text-output drift introduced by the swap.
36
+
37
+ Until then, every change here MUST be mirrored in the canonical backend code
38
+ (or vice versa). The Phase 4 codegen pipeline locks the *typed schema* shared
39
+ between Python and TypeScript; the *traversal logic* still lives in two places.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import subprocess
46
+ import sys
47
+ import xml.etree.ElementTree as ET
48
+ from dataclasses import dataclass
49
+ from pathlib import Path
50
+ from typing import Any
51
+
52
+ import click
53
+
54
+ from ._swapped_args import FileFirstCommand, FileFirstGroup
55
+ from ._write_opts import FileFirstWriteCommand, WriteCommand, write_message
56
+
57
+ from deepcell_cli.commands.query import _get_xml
58
+ from deepcell_cli._findings import cited_rules_footer, provenance, rule_citation
59
+ from deepcell_cli.context import Ctx, pass_ctx
60
+ from deepcell_cli.output import (
61
+ echo_info,
62
+ echo_success,
63
+ echo_validation,
64
+ echo_warning,
65
+ output,
66
+ print_plain,
67
+ )
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # File / stdin transport for free-text options
72
+ # ---------------------------------------------------------------------------
73
+
74
+ # Path type for every `--<option>-file` companion: `-` must pass validation
75
+ # (allow_dash) so stdin transport works.
76
+ _TEXT_FILE = click.Path(exists=True, dir_okay=False, allow_dash=True)
77
+
78
+
79
+ def _resolve_text_option(
80
+ inline: str | None, file_path: str | None, option: str,
81
+ *, required: bool = False,
82
+ ) -> str | None:
83
+ """Resolve a free-text option that may arrive inline or from a file.
84
+
85
+ File / stdin ('-') transport exists because inline shell arguments mangle
86
+ dollar amounts: inside double quotes bash expands "$0" to the shell path
87
+ and "$35" to "5", which run 20260812-062931 showed corrupting stored
88
+ reasoning text while every deterministic check stayed green.
89
+ """
90
+ if inline is not None and file_path is not None:
91
+ raise click.UsageError(
92
+ f"--{option} and --{option}-file are mutually exclusive"
93
+ )
94
+ if file_path is not None:
95
+ if file_path == "-":
96
+ return sys.stdin.read().rstrip("\n")
97
+ with open(file_path, "r", encoding="utf-8") as handle:
98
+ return handle.read().rstrip("\n")
99
+ if required and inline is None:
100
+ raise click.UsageError(
101
+ f"one of --{option} or --{option}-file is required"
102
+ )
103
+ return inline
104
+
105
+
106
+ def _check_single_stdin(*file_paths: str | None) -> None:
107
+ """Refuse two '-' file options on one invocation — stdin can only be
108
+ consumed once, so the second would silently receive empty text."""
109
+ if sum(1 for p in file_paths if p == "-") > 1:
110
+ raise click.UsageError(
111
+ "only one option may read stdin ('-') per invocation"
112
+ )
113
+
114
+
115
+ def _text_file_help(noun: str, inline_option: str) -> str:
116
+ """Help text for a `--<option>-file` companion option."""
117
+ return (
118
+ f"File holding the {noun} text; '-' reads stdin. Prefer this over "
119
+ f"--{inline_option} for text containing '$' — inline shell arguments "
120
+ "mangle dollar amounts."
121
+ )
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Lightweight local XML reader — mirrors DeepCellDocument query methods
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _attrib(el: ET.Element, name: str) -> str:
130
+ """Return attribute value, checking both 'name' and '@name' forms."""
131
+ return el.get(name) or el.get(f"@{name}") or ""
132
+
133
+
134
+ def _body_text(el: ET.Element) -> str:
135
+ """Extract body text from a <Body> child element."""
136
+ body = el.find("Body")
137
+ if body is None:
138
+ return ""
139
+ return (body.text or "").strip()
140
+
141
+
142
+ def _load_reasoning(xml: str, label: str) -> ET.Element | None:
143
+ """Parse XML text and return the <Reasoning> element, or None if absent."""
144
+ try:
145
+ root = ET.fromstring(xml)
146
+ except ET.ParseError as exc:
147
+ click.echo(f"error: cannot parse '{label}': {exc}", err=True)
148
+ sys.exit(2)
149
+ return root.find("Reasoning")
150
+
151
+
152
+ def _get_claims(reasoning: ET.Element) -> list[ET.Element]:
153
+ return reasoning.findall("Claim")
154
+
155
+
156
+ def _get_arguments(reasoning: ET.Element) -> list[ET.Element]:
157
+ return reasoning.findall("Argument")
158
+
159
+
160
+ def _get_claim_by_id(reasoning: ET.Element, claim_id: str) -> ET.Element | None:
161
+ for c in _get_claims(reasoning):
162
+ if _attrib(c, "id") == claim_id:
163
+ return c
164
+ return None
165
+
166
+
167
+ def _get_supersedes_chain(reasoning: ET.Element, claim_id: str) -> list[ET.Element]:
168
+ """Walk supersedes edges from claim_id back through history. Newest first."""
169
+ # Build supersedes map: from_id -> to_id
170
+ sup: dict[str, str] = {}
171
+ for a in _get_arguments(reasoning):
172
+ if _attrib(a, "rel") == "supersedes":
173
+ src = _attrib(a, "from")
174
+ dst = _attrib(a, "to")
175
+ if src:
176
+ sup[src] = dst
177
+
178
+ chain: list[ET.Element] = []
179
+ seen: set[str] = set()
180
+ cur = claim_id
181
+ while cur and cur not in seen:
182
+ seen.add(cur)
183
+ c = _get_claim_by_id(reasoning, cur)
184
+ if c is not None:
185
+ chain.append(c)
186
+ cur = sup.get(cur, "")
187
+ return chain
188
+
189
+
190
+ def _get_assumption_impact(reasoning: ET.Element, assumption_id: str) -> list[ET.Element]:
191
+ """Return Claims that depend_on the given assumption_id."""
192
+ results: list[ET.Element] = []
193
+ for a in _get_arguments(reasoning):
194
+ if _attrib(a, "rel") == "depends_on" and _attrib(a, "to") == assumption_id:
195
+ src = _attrib(a, "from")
196
+ c = _get_claim_by_id(reasoning, src)
197
+ if c is not None:
198
+ results.append(c)
199
+ return results
200
+
201
+
202
+ def _summary(el: ET.Element, max_len: int = 80) -> str:
203
+ text = _body_text(el)
204
+ return text[:max_len]
205
+
206
+
207
+ def _require_reasoning(ctx: Ctx, filename: str) -> ET.Element:
208
+ """Fetch FILENAME from the workspace, return its <Reasoning>; exit if absent."""
209
+ reasoning = _load_reasoning(_get_xml(ctx, filename), filename)
210
+ if reasoning is None:
211
+ click.echo("(no Reasoning section in file)")
212
+ sys.exit(0)
213
+ return reasoning
214
+
215
+
216
+ # ---------- claim group ----------
217
+
218
+
219
+ @click.group(cls=FileFirstGroup)
220
+ def claim() -> None:
221
+ """Inspect Claims in a workspace .deepcell file.
222
+
223
+ Read-only views of one Claim or one status: its supersedes history, its
224
+ variant/consensus pair, and the falsified list. Whole-graph views and
225
+ every write live under `deepcell reasoning`.
226
+ """
227
+
228
+
229
+ @claim.command("history", cls=FileFirstCommand)
230
+ @click.argument("filename")
231
+ @click.argument("claim_id")
232
+ @pass_ctx
233
+ def claim_history(ctx: Ctx, filename: str, claim_id: str) -> None:
234
+ """Walk the supersedes chain backwards from CLAIM_ID (newest first).
235
+
236
+ FILENAME is a file in the active workspace, as `deepcell ls` lists it.
237
+ """
238
+ reasoning = _require_reasoning(ctx, filename)
239
+ chain = _get_supersedes_chain(reasoning, claim_id)
240
+ if not chain:
241
+ click.echo(f"claim '{claim_id}' not found", err=True)
242
+ sys.exit(1)
243
+ for c in chain:
244
+ cid = _attrib(c, "id")
245
+ status = _attrib(c, "status") or "active"
246
+ click.echo(f"{cid} [{status}] {_summary(c)}")
247
+
248
+
249
+ @claim.command("variant", cls=FileFirstCommand)
250
+ @click.argument("filename")
251
+ @click.argument("claim_id")
252
+ @pass_ctx
253
+ def claim_variant(ctx: Ctx, filename: str, claim_id: str) -> None:
254
+ """Show your view of CLAIM_ID beside the consensus it is a variant of.
255
+
256
+ Follows the outgoing `variant_of` Argument from CLAIM_ID in FILENAME and
257
+ prints both sides. Prints only your view when there is no such edge.
258
+ """
259
+ reasoning = _require_reasoning(ctx, filename)
260
+ c = _get_claim_by_id(reasoning, claim_id)
261
+ if c is None:
262
+ click.echo(f"claim '{claim_id}' not found", err=True)
263
+ sys.exit(1)
264
+
265
+ consensus_id: str = ""
266
+ for a in _get_arguments(reasoning):
267
+ if _attrib(a, "from") == claim_id and _attrib(a, "rel") == "variant_of":
268
+ consensus_id = _attrib(a, "to")
269
+ break
270
+
271
+ click.echo(f"My view ({claim_id}): {_summary(c, 120)}")
272
+ if consensus_id:
273
+ cs = _get_claim_by_id(reasoning, consensus_id)
274
+ if cs is not None:
275
+ click.echo(f"Consensus ({consensus_id}): {_summary(cs, 120)}")
276
+ else:
277
+ click.echo(f"Consensus ({consensus_id}): <not found in document>")
278
+ else:
279
+ click.echo("(no variant_of edge from this claim)")
280
+
281
+
282
+ @claim.command("falsified")
283
+ @click.argument("filename")
284
+ @pass_ctx
285
+ def claim_falsified(ctx: Ctx, filename: str) -> None:
286
+ """List every Claim in FILENAME whose status is `falsified`.
287
+
288
+ There is no date filter: a <Claim> carries no timestamp, so when it was
289
+ falsified is not in the document. Use `deepcell log` / `deepcell diff` to
290
+ see when the status changed.
291
+ """
292
+ reasoning = _require_reasoning(ctx, filename)
293
+ found = False
294
+ for c in _get_claims(reasoning):
295
+ if _attrib(c, "status") == "falsified":
296
+ cid = _attrib(c, "id")
297
+ click.echo(f"{cid} {_summary(c, 100)}")
298
+ found = True
299
+ if not found:
300
+ click.echo("(no falsified claims)")
301
+
302
+
303
+ # ---------- assumption group ----------
304
+
305
+
306
+ @click.group(cls=FileFirstGroup)
307
+ def assumption() -> None:
308
+ """Inspect Assumptions in a workspace .deepcell file.
309
+
310
+ Assumptions are written with `deepcell reasoning add-assumption` /
311
+ `update-assumption`; this group only reads them back.
312
+ """
313
+
314
+
315
+ @assumption.command("impact", cls=FileFirstCommand)
316
+ @click.argument("filename")
317
+ @click.argument("assumption_id")
318
+ @pass_ctx
319
+ def assumption_impact(ctx: Ctx, filename: str, assumption_id: str) -> None:
320
+ """List Claims that depend_on ASSUMPTION_ID.
321
+
322
+ Answers "what breaks if this assumption is wrong" for one node. Errors
323
+ when ASSUMPTION_ID is not in FILENAME; prints nothing and exits 0 when it
324
+ exists but nothing depends on it. Continue through calculations,
325
+ backlinks, prose, and slides with `deepcell guide revise/premise-change`.
326
+ """
327
+ reasoning = _require_reasoning(ctx, filename)
328
+ # A typo'd id must error — silent-empty was indistinguishable from a real
329
+ # assumption with no dependents.
330
+ known_ids = {
331
+ node_id
332
+ for node in reasoning.iter()
333
+ if (node_id := _attrib(node, "id"))
334
+ }
335
+ if assumption_id not in known_ids:
336
+ raise click.ClickException(
337
+ f"Reasoning node '{assumption_id}' not found in {filename} "
338
+ f"({len(known_ids)} nodes)."
339
+ )
340
+ impact = _get_assumption_impact(reasoning, assumption_id)
341
+ if not impact:
342
+ # Empty output (exit 0) is intentional — the id exists, it just has
343
+ # no dependents.
344
+ return
345
+ for c in impact:
346
+ cid = _attrib(c, "id")
347
+ status = _attrib(c, "status") or "active"
348
+ click.echo(f"{cid} [{status}] {_summary(c)}")
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # reasoning-diff — stdlib XML implementation (mirrors backend reasoning_diff.py)
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ @dataclass(frozen=True)
357
+ class _ChangedRef:
358
+ drift_kind: str # "structural" | "snapshot" | "semantic" | "logical"
359
+ ref: str # e.g. "value:GM_PCT@Q1/projected", "item:GM", "assumption:a1"
360
+
361
+
362
+ @dataclass
363
+ class _DiffEntry:
364
+ node_id: str
365
+ drift_kind: str
366
+ change_summary: str
367
+
368
+
369
+ def _parse_xml_string(xml_text: str) -> ET.Element:
370
+ """Parse an XML string and return the root element."""
371
+ try:
372
+ return ET.fromstring(xml_text)
373
+ except ET.ParseError as exc:
374
+ click.echo(f"error: cannot parse XML: {exc}", err=True)
375
+ sys.exit(2)
376
+
377
+
378
+ def _parse_custom_dims_attr_et(attr_value: str | None) -> dict[str, str]:
379
+ """Parse a customDimensions attribute string ("k:v;k:v") into a dict.
380
+
381
+ Mirrors backend/src/core/calculation/dimensions.py:parse_custom_dimensions_attr.
382
+ """
383
+ if not attr_value:
384
+ return {}
385
+ out: dict[str, str] = {}
386
+ for pair in attr_value.split(";"):
387
+ pair = pair.strip()
388
+ if ":" in pair:
389
+ k, v = pair.split(":", 1)
390
+ k = k.strip()
391
+ v = v.strip()
392
+ if k:
393
+ out[k] = v
394
+ return out
395
+
396
+
397
+ def _canonical_dims_et(attr_value: str | None) -> str:
398
+ if not attr_value:
399
+ return ""
400
+ dims = _parse_custom_dims_attr_et(attr_value)
401
+ if not dims:
402
+ return ""
403
+ return ";".join(f"{k}={v}" for k, v in sorted(dims.items()))
404
+
405
+
406
+ def _index_values_et(root: ET.Element) -> dict:
407
+ """Return {(item, ctx, status, scenario, dims): value_str} for all Values."""
408
+ out: dict[tuple, str] = {}
409
+ values_el = root.find("Values")
410
+ if values_el is None:
411
+ return out
412
+ # ItemGroup-wrapped form
413
+ for ig in values_el.findall("ItemGroup"):
414
+ item = ig.get("itemRef") or ""
415
+ status = ig.get("statusRef") or ""
416
+ group_scenario = ig.get("scenarioRef") or ""
417
+ group_dims_attr = ig.get("customDimensions")
418
+ for v in ig.findall("Value"):
419
+ ctx = v.get("contextRef") or ""
420
+ v_scen = v.get("scenarioRef")
421
+ scenario = v_scen if v_scen else group_scenario
422
+ v_dims_attr = v.get("customDimensions")
423
+ dims_attr = v_dims_attr if v_dims_attr else group_dims_attr
424
+ dims_canon = _canonical_dims_et(dims_attr)
425
+ txt = (v.text or "").strip()
426
+ if item and ctx:
427
+ out[(item, ctx, status, scenario, dims_canon)] = txt
428
+ # Flat form: <Value itemRef=... contextRef=... statusRef=...>
429
+ for v in values_el.findall("Value"):
430
+ item = v.get("itemRef") or ""
431
+ ctx = v.get("contextRef") or ""
432
+ status = v.get("statusRef") or ""
433
+ scenario = v.get("scenarioRef") or ""
434
+ dims_canon = _canonical_dims_et(v.get("customDimensions"))
435
+ txt = (v.text or "").strip()
436
+ if item and ctx:
437
+ out[(item, ctx, status, scenario, dims_canon)] = txt
438
+ return out
439
+
440
+
441
+ _CALC_IGNORE_ATTRS_ET = frozenset({"description", "displayName"})
442
+ _CALC_IGNORE_CHILDREN_ET = frozenset({"Label", "Notes", "Description"})
443
+
444
+
445
+ def _index_calcs_et(root: ET.Element) -> dict:
446
+ """Return {calc_id: canonical_repr} for all Calculation nodes.
447
+
448
+ Drops reorder-only attrs / children; normalizes Formula whitespace.
449
+ """
450
+ out: dict[str, str] = {}
451
+ calc_defs = root.find("CalculationDefinitions")
452
+ if calc_defs is None:
453
+ return out
454
+ for c in calc_defs.findall("Calculation"):
455
+ cid = c.get("id") or ""
456
+ if not cid:
457
+ continue
458
+ attribs = sorted(
459
+ (k, v) for k, v in c.attrib.items() if k not in _CALC_IGNORE_ATTRS_ET
460
+ )
461
+ # child elements
462
+ child_parts: list[str] = []
463
+ formulas = c.findall("Formula")
464
+ for f in formulas:
465
+ txt = (f.text or "")
466
+ child_parts.append("Formula=" + " ".join(txt.split()))
467
+ other_children: list[str] = []
468
+ for child in c:
469
+ if child.tag in _CALC_IGNORE_CHILDREN_ET or child.tag == "Formula":
470
+ continue
471
+ other_children.append(f"{child.tag}={ET.tostring(child, encoding='unicode').strip()}")
472
+ other_children.sort()
473
+ out[cid] = repr(attribs) + "|" + "|".join(child_parts + other_children)
474
+ return out
475
+
476
+
477
+ def _index_items_et(root: ET.Element) -> set:
478
+ """Return the set of all itemId values defined in ItemDefinitions."""
479
+ item_defs = root.find("ItemDefinitions")
480
+ if item_defs is None:
481
+ return set()
482
+ return {el.get("itemId") for el in item_defs.findall("Item") if el.get("itemId")}
483
+
484
+
485
+ def _index_item_signatures_et(root: ET.Element) -> dict[str, tuple[str, str]]:
486
+ """Return {itemId: (displayName, parentItemRef)} for rename heuristic."""
487
+ out: dict[str, tuple[str, str]] = {}
488
+ item_defs = root.find("ItemDefinitions")
489
+ if item_defs is None:
490
+ return out
491
+ for it in item_defs.findall("Item"):
492
+ iid = it.get("itemId") or ""
493
+ if not iid:
494
+ continue
495
+ display = it.get("displayName") or ""
496
+ if not display:
497
+ label = it.find("Label")
498
+ if label is not None:
499
+ # Match backend `_index_item_signatures` (reasoning_diff.py:230-234):
500
+ # an empty <Label/> yields "" rather than skipping the field.
501
+ display = label.text or ""
502
+ parent = it.get("parentItemRef") or ""
503
+ out[iid] = ((display or "").strip(), parent.strip())
504
+ return out
505
+
506
+
507
+ def _index_contexts_et(root: ET.Element) -> set:
508
+ ctx_defs = root.find("ContextDefinitions")
509
+ if ctx_defs is None:
510
+ return set()
511
+ return {el.get("contextId") for el in ctx_defs.findall("Context") if el.get("contextId")}
512
+
513
+
514
+ def _index_statuses_et(root: ET.Element) -> set:
515
+ st_defs = root.find("StatusDefinitions")
516
+ if st_defs is None:
517
+ return set()
518
+ return {el.get("statusId") for el in st_defs.findall("Status") if el.get("statusId")}
519
+
520
+
521
+ def _index_claim_kinds_et(root: ET.Element) -> dict:
522
+ out: dict[str, str] = {}
523
+ reasoning = root.find("Reasoning")
524
+ if reasoning is None:
525
+ return out
526
+ for c in reasoning.findall("Claim"):
527
+ cid = c.get("id") or ""
528
+ if cid:
529
+ out[cid] = c.get("kind") or ""
530
+ return out
531
+
532
+
533
+ def _index_assumption_status_et(root: ET.Element) -> dict:
534
+ """Return {assumption_id: status_str} for all Assumption nodes."""
535
+ out: dict[str, str] = {}
536
+ reasoning = root.find("Reasoning")
537
+ if reasoning is None:
538
+ return out
539
+ for a in reasoning.findall("Assumption"):
540
+ aid = a.get("id") or ""
541
+ if aid:
542
+ out[aid] = a.get("status") or "holding"
543
+ return out
544
+
545
+
546
+ def _index_reasoning_nodes_et(root: ET.Element, element: str) -> dict:
547
+ """Return {node_id: ET.Element} for Reasoning nodes of the given element type."""
548
+ out: dict[str, ET.Element] = {}
549
+ reasoning = root.find("Reasoning")
550
+ if reasoning is None:
551
+ return out
552
+ for n in reasoning.findall(element):
553
+ nid = n.get("id") or ""
554
+ if nid:
555
+ out[nid] = n
556
+ return out
557
+
558
+
559
+ _ANCHOR_ATTRS_ET = frozenset({
560
+ "itemRefs", "itemRef", "contextRefs", "contextRef", "statusRef",
561
+ "calcRef", "scenarioRef", "customDimensions", "kind", "parentClaimRef",
562
+ })
563
+
564
+
565
+ def _node_anchor_attrs_changed_et(p: ET.Element, w: ET.Element) -> set[str]:
566
+ """Return anchor attrs that differ between parent/working ET nodes."""
567
+ changed: set[str] = set()
568
+ keys = set(p.attrib.keys()) | set(w.attrib.keys())
569
+ for k in keys:
570
+ if k not in _ANCHOR_ATTRS_ET:
571
+ continue
572
+ if p.attrib.get(k) != w.attrib.get(k):
573
+ changed.add(k)
574
+ return changed
575
+
576
+
577
+ def _split_csv_et(s: str | None) -> list:
578
+ if not s:
579
+ return []
580
+ return [x.strip() for x in s.split(",") if x.strip()]
581
+
582
+
583
+ def _build_changed_set(parent_root: ET.Element, working_root: ET.Element) -> set:
584
+ """Compute the set of _ChangedRef entries representing all changes between parent and working."""
585
+ changed: set[_ChangedRef] = set()
586
+
587
+ # Snapshot drift: Value changes (keyed by item, ctx, status, scenario, dims)
588
+ pv = _index_values_et(parent_root)
589
+ wv = _index_values_et(working_root)
590
+ for k, v in wv.items():
591
+ if pv.get(k) != v:
592
+ item, ctx, status, scenario, dims = k
593
+ ref = f"value:{item}@{ctx}/{status}"
594
+ if scenario:
595
+ ref += f"#scen={scenario}"
596
+ if dims:
597
+ ref += f"|dims={dims}"
598
+ changed.add(_ChangedRef("snapshot", ref))
599
+
600
+ # Semantic drift: CalcDef formula or attribute changes
601
+ pc = _index_calcs_et(parent_root)
602
+ wc = _index_calcs_et(working_root)
603
+ for cid, w_repr in wc.items():
604
+ if pc.get(cid) != w_repr:
605
+ changed.add(_ChangedRef("semantic", f"calc:{cid}"))
606
+
607
+ # Structural drift: Item removals (with rename heuristic)
608
+ p_items = _index_items_et(parent_root)
609
+ w_items = _index_items_et(working_root)
610
+ p_sigs = _index_item_signatures_et(parent_root)
611
+ w_sigs = _index_item_signatures_et(working_root)
612
+ removed = p_items - w_items
613
+ added = w_items - p_items
614
+
615
+ suppress_removed: set[str] = set()
616
+ matched_added: set[str] = set()
617
+ for old_id in sorted(removed):
618
+ old_sig = p_sigs.get(old_id)
619
+ if not old_sig or not old_sig[0]:
620
+ continue
621
+ for new_id in sorted(added):
622
+ if new_id in matched_added:
623
+ continue
624
+ if w_sigs.get(new_id) == old_sig:
625
+ changed.add(_ChangedRef("rename", f"item:{old_id}->{new_id}"))
626
+ suppress_removed.add(old_id)
627
+ matched_added.add(new_id)
628
+ break
629
+
630
+ for r in removed - suppress_removed:
631
+ changed.add(_ChangedRef("structural", f"item:{r}"))
632
+
633
+ # Structural drift: ContextDef / StatusDef removals
634
+ for cid in _index_contexts_et(parent_root) - _index_contexts_et(working_root):
635
+ changed.add(_ChangedRef("structural", f"context:{cid}"))
636
+ for sid in _index_statuses_et(parent_root) - _index_statuses_et(working_root):
637
+ changed.add(_ChangedRef("structural", f"status:{sid}"))
638
+
639
+ # Logical drift: Assumption status flipping to "broken"
640
+ pa = _index_assumption_status_et(parent_root)
641
+ wa = _index_assumption_status_et(working_root)
642
+ for aid, w_status in wa.items():
643
+ if w_status == "broken" and pa.get(aid) != "broken":
644
+ changed.add(_ChangedRef("logical", f"assumption:{aid}"))
645
+
646
+ return changed
647
+
648
+
649
+ def _detect_kind_flips_et(parent_root: ET.Element, working_root: ET.Element) -> list:
650
+ pk = _index_claim_kinds_et(parent_root)
651
+ wk = _index_claim_kinds_et(working_root)
652
+ out: list[_DiffEntry] = []
653
+ for cid, new_kind in wk.items():
654
+ old_kind = pk.get(cid)
655
+ if old_kind is None:
656
+ continue
657
+ if old_kind != new_kind:
658
+ out.append(_DiffEntry(cid, "semantic", f"kind changed: {old_kind}->{new_kind}"))
659
+ return out
660
+
661
+
662
+ def _compute_reasoning_diff_pair_et(
663
+ parent_root: ET.Element, working_root: ET.Element
664
+ ) -> list:
665
+ changed = _build_changed_set(parent_root, working_root)
666
+
667
+ # Per-anchor same-commit suppression
668
+ edited_anchor_attrs: dict[str, set[str]] = {}
669
+ for element in ("Claim", "Assumption", "Evidence"):
670
+ p_idx = _index_reasoning_nodes_et(parent_root, element)
671
+ w_idx = _index_reasoning_nodes_et(working_root, element)
672
+ for nid, w_node in w_idx.items():
673
+ p_node = p_idx.get(nid)
674
+ if p_node is None:
675
+ edited_anchor_attrs[nid] = set(_ANCHOR_ATTRS_ET)
676
+ continue
677
+ edited_anchor_attrs[nid] = _node_anchor_attrs_changed_et(p_node, w_node)
678
+
679
+ entries: list[_DiffEntry] = []
680
+ seen: set = set()
681
+
682
+ def _emit(node_id: str, kind: str, summary: str) -> None:
683
+ key = (node_id, kind, summary)
684
+ if key in seen:
685
+ return
686
+ seen.add(key)
687
+ entries.append(_DiffEntry(node_id, kind, summary))
688
+
689
+ def _suppressed(nid: str, anchor_attrs: set[str]) -> bool:
690
+ edited = edited_anchor_attrs.get(nid, set())
691
+ return bool(edited & anchor_attrs)
692
+
693
+ if not changed:
694
+ return entries
695
+
696
+ w_reasoning = working_root.find("Reasoning")
697
+ if w_reasoning is None:
698
+ return entries
699
+
700
+ for element in ("Claim", "Assumption"):
701
+ for n in w_reasoning.findall(element):
702
+ nid = n.get("id")
703
+ if nid is None:
704
+ continue
705
+
706
+ item_refs = _split_csv_et(n.get("itemRefs") or n.get("itemRef"))
707
+ ctx_refs = _split_csv_et(n.get("contextRefs") or n.get("contextRef"))
708
+ status_ref = n.get("statusRef")
709
+ calc_ref = n.get("calcRef")
710
+ anchor_scenario = n.get("scenarioRef")
711
+ anchor_dims = _canonical_dims_et(n.get("customDimensions"))
712
+
713
+ for cr in changed:
714
+ if cr.drift_kind == "snapshot":
715
+ body = cr.ref[len("value:"):]
716
+ cr_dims = ""
717
+ if "|dims=" in body:
718
+ body, cr_dims = body.split("|dims=", 1)
719
+ cr_scen = ""
720
+ if "#scen=" in body:
721
+ body, cr_scen = body.split("#scen=", 1)
722
+ item, rest = body.split("@", 1)
723
+ ctx, status = rest.split("/", 1)
724
+ item_match = item in item_refs
725
+ ctx_match = (not ctx_refs) or (ctx in ctx_refs)
726
+ status_match = (not status_ref) or (status_ref == status)
727
+ scen_match = (not anchor_scenario) or (anchor_scenario == cr_scen)
728
+ dims_match = (not anchor_dims) or (anchor_dims == cr_dims)
729
+ if item_match and ctx_match and status_match and scen_match and dims_match:
730
+ if _suppressed(nid, {"itemRefs", "itemRef", "contextRefs",
731
+ "contextRef", "statusRef",
732
+ "scenarioRef", "customDimensions"}):
733
+ continue
734
+ _emit(nid, "snapshot", cr.ref)
735
+ elif cr.drift_kind == "semantic":
736
+ if calc_ref and cr.ref == f"calc:{calc_ref}":
737
+ if _suppressed(nid, {"calcRef"}):
738
+ continue
739
+ _emit(nid, "semantic", cr.ref)
740
+ elif cr.drift_kind == "structural":
741
+ body = cr.ref
742
+ if body.startswith("item:"):
743
+ target = body[len("item:"):]
744
+ if target in item_refs:
745
+ if _suppressed(nid, {"itemRefs", "itemRef"}):
746
+ continue
747
+ _emit(nid, "structural", cr.ref)
748
+ elif body.startswith("context:"):
749
+ target = body[len("context:"):]
750
+ if target in ctx_refs:
751
+ if _suppressed(nid, {"contextRefs", "contextRef"}):
752
+ continue
753
+ _emit(nid, "structural", cr.ref)
754
+ elif body.startswith("status:"):
755
+ target = body[len("status:"):]
756
+ if status_ref == target:
757
+ if _suppressed(nid, {"statusRef"}):
758
+ continue
759
+ _emit(nid, "structural", cr.ref)
760
+ elif cr.drift_kind == "rename":
761
+ body = cr.ref[len("item:"):]
762
+ old_id, _, _new_id = body.partition("->")
763
+ if old_id in item_refs:
764
+ if _suppressed(nid, {"itemRefs", "itemRef"}):
765
+ continue
766
+ _emit(nid, "rename", cr.ref)
767
+
768
+ broken_assumptions = {
769
+ cr.ref[len("assumption:"):]
770
+ for cr in changed
771
+ if cr.drift_kind == "logical"
772
+ }
773
+ for a in w_reasoning.findall("Argument"):
774
+ if a.get("rel") != "depends_on":
775
+ continue
776
+ target = a.get("to")
777
+ if target in broken_assumptions:
778
+ src = a.get("from")
779
+ if not src:
780
+ continue
781
+ if edited_anchor_attrs.get(src):
782
+ continue
783
+ _emit(src, "logical", f"depended-on assumption '{target}' flipped to broken")
784
+
785
+ return entries
786
+
787
+
788
+ def _compute_reasoning_diff(
789
+ parent_root: ET.Element,
790
+ working_root: ET.Element,
791
+ branch_base_root: ET.Element | None = None,
792
+ ) -> list:
793
+ """Return one _DiffEntry per Reasoning node whose anchor was touched.
794
+
795
+ Per-anchor same-commit suppression and Phase 2 drift coverage (renames,
796
+ Context/Status removals, kind flips). When `branch_base_root` is supplied,
797
+ drift accumulates across both branch_base→parent and parent→working.
798
+ """
799
+ entries = _compute_reasoning_diff_pair_et(parent_root, working_root)
800
+ entries.extend(_detect_kind_flips_et(parent_root, working_root))
801
+
802
+ if branch_base_root is not None:
803
+ base_entries = _compute_reasoning_diff_pair_et(branch_base_root, parent_root)
804
+ base_entries.extend(_detect_kind_flips_et(branch_base_root, parent_root))
805
+ existing = {(e.node_id, e.drift_kind, e.change_summary) for e in entries}
806
+ for e in base_entries:
807
+ key = (e.node_id, e.drift_kind, e.change_summary)
808
+ if key in existing:
809
+ continue
810
+ existing.add(key)
811
+ entries.append(e)
812
+
813
+ return entries
814
+
815
+
816
+ @click.command("reasoning-diff")
817
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
818
+ @click.option(
819
+ "--accept",
820
+ is_flag=True,
821
+ default=False,
822
+ help="Print warnings but exit 0 (acknowledge and continue).",
823
+ )
824
+ @click.option(
825
+ "--since-branch-base",
826
+ "since_branch_base",
827
+ type=click.Path(exists=True, path_type=Path),
828
+ default=None,
829
+ help=(
830
+ "Path to a third .deepcell file representing the branch base. When "
831
+ "supplied, drift is computed across both branch_base->HEAD and "
832
+ "HEAD->working intervals so accumulated commits surface."
833
+ ),
834
+ )
835
+ def reasoning_diff(
836
+ file: Path, accept: bool, since_branch_base: Path | None
837
+ ) -> None:
838
+ """Warn when an edit moved something a Claim was anchored to.
839
+
840
+ Compares FILE at git HEAD against the working tree. Run it before
841
+ committing: it exits non-zero if any Claim is potentially affected, so a
842
+ git pre-commit hook can block. Pass --accept to acknowledge and exit zero
843
+ anyway, or --since-branch-base PATH to also include drift accumulated
844
+ since the supplied branch-base file.
845
+
846
+ Takes a local path, not a workspace filename — it reads git.
847
+ For a workspace premise change and its cross-surface reassessment, read
848
+ `deepcell guide revise/premise-change`.
849
+ """
850
+ # Locate the git repo for FILE
851
+ try:
852
+ repo_root = subprocess.check_output(
853
+ ["git", "-C", str(file.parent), "rev-parse", "--show-toplevel"],
854
+ text=True,
855
+ stderr=subprocess.DEVNULL,
856
+ ).strip()
857
+ except subprocess.CalledProcessError:
858
+ click.echo(
859
+ f"warning: {file} is not in a git repo; skipping reasoning-diff", err=True
860
+ )
861
+ return
862
+
863
+ # Path of FILE relative to repo root
864
+ try:
865
+ rel_path = file.resolve().relative_to(Path(repo_root).resolve())
866
+ except ValueError:
867
+ click.echo(f"warning: {file} is not inside {repo_root}; skipping", err=True)
868
+ return
869
+
870
+ # Read parent (git HEAD) version. If the file has no history, exit cleanly.
871
+ try:
872
+ parent_xml = subprocess.check_output(
873
+ ["git", "-C", repo_root, "show", f"HEAD:{rel_path.as_posix()}"],
874
+ text=True,
875
+ stderr=subprocess.DEVNULL,
876
+ )
877
+ except subprocess.CalledProcessError:
878
+ # File is untracked / new — nothing to diff against
879
+ return
880
+
881
+ working_xml = file.read_text()
882
+
883
+ parent_root = _parse_xml_string(parent_xml)
884
+ working_root = _parse_xml_string(working_xml)
885
+ branch_base_root = None
886
+ if since_branch_base is not None:
887
+ branch_base_xml = since_branch_base.read_text()
888
+ branch_base_root = _parse_xml_string(branch_base_xml)
889
+ entries = _compute_reasoning_diff(parent_root, working_root, branch_base_root)
890
+
891
+ if not entries:
892
+ return
893
+
894
+ click.echo(
895
+ click.style(
896
+ "Reasoning diff — Claims potentially affected by this change:",
897
+ fg="yellow",
898
+ ),
899
+ err=True,
900
+ )
901
+ for e in entries:
902
+ click.echo(f" * {e.node_id} [{e.drift_kind}] {e.change_summary}", err=True)
903
+
904
+ if accept:
905
+ return # exit 0
906
+
907
+ click.echo(
908
+ click.style(
909
+ "Pass --accept to acknowledge and continue, or edit the affected Claims first.",
910
+ fg="red",
911
+ ),
912
+ err=True,
913
+ )
914
+ sys.exit(1)
915
+
916
+
917
+ # ---------------------------------------------------------------------------
918
+ # `deepcell reasoning ...` group — API-backed (workspace FILENAME)
919
+ # ---------------------------------------------------------------------------
920
+ #
921
+ # Every command in this file takes a workspace-relative FILENAME (see the
922
+ # module docstring); the difference here is *where the work happens*. The
923
+ # `claim`/`assumption` groups above fetch the XML and parse it in-process, while
924
+ # this group posts it to the Jingwei API and renders the response.
925
+ # `reasoning-diff` is the only local-path command in the file.
926
+
927
+
928
+ @click.group()
929
+ def reasoning() -> None:
930
+ """Read and edit the Claim / Assumption / Evidence graph in a document.
931
+
932
+ A .deepcell document records why it says what it says: Claims, the
933
+ Assumptions and Evidence they rest on, and the Argument edges between
934
+ them. These subcommands author that graph, inspect it, and check it.
935
+
936
+ \b
937
+ AUTHORING A WHOLE ARGUMENT? Use `reasoning apply`. A finished argument is
938
+ a dozen nodes and two dozen edges, and each add-* below is one round-trip;
939
+ `apply` sends them all in a single atomic call, in the order you list them,
940
+ so an Argument may name a node declared earlier in the same batch:
941
+ deepcell reasoning apply model.deepcell --ops '[{"op": "add_claim", ...}]'
942
+ The single add-* commands stay the right tool for one node, and for
943
+ learning an op's fields from `--help` rather than from JSON.
944
+
945
+ \b
946
+ Declare: set-conclusion (which Claim is the document's answer — point
947
+ <Reasoning> at the apex thesis as soon as add-claim has
948
+ authored it, then build the argument for it)
949
+ Add: add-claim, add-assumption, add-evidence, add-argument
950
+ (or all of them at once: apply)
951
+ Revise: supersede-claim / supersede-assumption (keeps the old node as
952
+ history — the default way to change what a Claim asserts),
953
+ update-* (corrects a node in place)
954
+ Remove: delete-* (erases the record; prefer supersede)
955
+ Inspect: graph (Mermaid/DOT), impact (what breaks if a claim falls)
956
+ Check: lint (structural rules; --strict for hygiene warnings too)
957
+
958
+ `set-conclusion` does not create a Claim — author the apex `thesis` with
959
+ `add-claim`, then point `<Reasoning>` at it.
960
+
961
+ Read `deepcell guide revise/reasoning` before writing your first node, and
962
+ `deepcell guide verify/review` when reviewing a finished document.
963
+ `deepcell claim` and `deepcell assumption` hold the per-node read views.
964
+ """
965
+
966
+
967
+ # --- Mermaid renderer -------------------------------------------------------
968
+
969
+ # Mermaid classDef for each node type. Driven by the `type` field on the
970
+ # /reasoning/graph response.
971
+ _MERMAID_NODE_CLASS = {
972
+ "claim": "claim",
973
+ "assumption": "assumption",
974
+ "evidence": "evidence",
975
+ }
976
+
977
+ _MERMAID_CLASSDEFS = (
978
+ " classDef claim fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;\n"
979
+ " classDef assumption fill:#fef3c7,stroke:#b45309,color:#78350f;\n"
980
+ " classDef evidence fill:#dcfce7,stroke:#15803d,color:#14532d;\n"
981
+ )
982
+
983
+ # Edge styling per Argument @rel — color/dasharray. Cosmetic; safe to extend.
984
+ _MERMAID_LINK_STYLE = {
985
+ "supports": "stroke:#16a34a,stroke-width:2px",
986
+ "refutes": "stroke:#dc2626,stroke-width:2px",
987
+ "contradicts": "stroke:#dc2626,stroke-width:2px,stroke-dasharray:4 2",
988
+ "depends_on": "stroke:#1d4ed8,stroke-width:2px,stroke-dasharray:4 2",
989
+ "derives_from": "stroke:#737373,stroke-width:1.5px",
990
+ "supersedes": "stroke:#525252,stroke-width:1.5px",
991
+ "variant_of": "stroke:#7c3aed,stroke-width:1.5px",
992
+ "references": "stroke:#a3a3a3,stroke-width:1px",
993
+ }
994
+
995
+
996
+ def _mermaid_id(raw: str) -> str:
997
+ """Sanitize a node id so it parses as a Mermaid identifier."""
998
+ out = []
999
+ for ch in raw:
1000
+ if ch.isalnum() or ch == "_":
1001
+ out.append(ch)
1002
+ else:
1003
+ out.append("_")
1004
+ sanitized = "".join(out) or "node"
1005
+ if sanitized[0].isdigit():
1006
+ sanitized = "n_" + sanitized
1007
+ return sanitized
1008
+
1009
+
1010
+ def _mermaid_label(label: str, node_id: str) -> str:
1011
+ """Quote and escape a node label so Mermaid accepts it inside [\"...\"]."""
1012
+ text = label or node_id
1013
+ # Single-line, length-bounded so the graph stays readable.
1014
+ text = " ".join(text.split())
1015
+ if len(text) > 60:
1016
+ text = text[:57] + "..."
1017
+ return text.replace("\\", "\\\\").replace('"', '\\"')
1018
+
1019
+
1020
+ def _arrow_with_label(rel: str) -> str:
1021
+ # Mermaid edge with hover/inline label: -- "rel" -->
1022
+ safe_rel = rel.replace('"', '\\"')
1023
+ return f'-- "{safe_rel}" -->'
1024
+
1025
+
1026
+ def _render_mermaid(graph: dict) -> str:
1027
+ nodes = graph.get("nodes", []) or []
1028
+ edges = graph.get("edges", []) or []
1029
+
1030
+ lines: list[str] = ["flowchart TD"]
1031
+ lines.append(_MERMAID_CLASSDEFS.rstrip("\n"))
1032
+
1033
+ # id_map: original id → sanitized id; collisions resolved by suffixing.
1034
+ id_map: dict[str, str] = {}
1035
+ used: set[str] = set()
1036
+ for n in nodes:
1037
+ raw_id = n.get("id", "")
1038
+ base = _mermaid_id(raw_id)
1039
+ candidate = base
1040
+ i = 2
1041
+ while candidate in used:
1042
+ candidate = f"{base}_{i}"
1043
+ i += 1
1044
+ used.add(candidate)
1045
+ id_map[raw_id] = candidate
1046
+
1047
+ for n in nodes:
1048
+ nid = id_map.get(n.get("id", ""), "")
1049
+ if not nid:
1050
+ continue
1051
+ label = _mermaid_label(n.get("label") or n.get("id") or "", n.get("id", ""))
1052
+ lines.append(f' {nid}["{label}"]')
1053
+
1054
+ # Per-node class assignments — one line is fine but per-node is clearest.
1055
+ for n in nodes:
1056
+ nid = id_map.get(n.get("id", ""), "")
1057
+ cls = _MERMAID_NODE_CLASS.get(n.get("type", ""))
1058
+ if nid and cls:
1059
+ lines.append(f" class {nid} {cls};")
1060
+
1061
+ # Edges + linkStyle. linkStyle is positional, so collect (index, style).
1062
+ link_style_lines: list[str] = []
1063
+ for idx, e in enumerate(edges):
1064
+ src = id_map.get(e.get("source", ""))
1065
+ tgt = id_map.get(e.get("target", ""))
1066
+ rel = e.get("type", "")
1067
+ if not (src and tgt and rel):
1068
+ continue
1069
+ lines.append(f" {src} {_arrow_with_label(rel)} {tgt}")
1070
+ style = _MERMAID_LINK_STYLE.get(rel)
1071
+ if style:
1072
+ link_style_lines.append(f" linkStyle {idx} {style};")
1073
+
1074
+ lines.extend(link_style_lines)
1075
+ return "\n".join(lines) + "\n"
1076
+
1077
+
1078
+ # --- DOT (Graphviz) renderer ------------------------------------------------
1079
+
1080
+ _DOT_NODE_STYLE = {
1081
+ "claim": 'shape=box, style="rounded,filled", fillcolor="#dbeafe", color="#1d4ed8"',
1082
+ "assumption": 'shape=octagon, style=filled, fillcolor="#fef3c7", color="#b45309"',
1083
+ "evidence": 'shape=note, style=filled, fillcolor="#dcfce7", color="#15803d"',
1084
+ }
1085
+
1086
+ _DOT_EDGE_STYLE = {
1087
+ "supports": 'color="#16a34a", penwidth=2',
1088
+ "refutes": 'color="#dc2626", penwidth=2',
1089
+ "contradicts": 'color="#dc2626", penwidth=2, style=dashed',
1090
+ "depends_on": 'color="#1d4ed8", penwidth=2, style=dashed',
1091
+ "derives_from": 'color="#737373"',
1092
+ "supersedes": 'color="#525252"',
1093
+ "variant_of": 'color="#7c3aed"',
1094
+ "references": 'color="#a3a3a3"',
1095
+ }
1096
+
1097
+
1098
+ def _dot_escape(s: str) -> str:
1099
+ return s.replace("\\", "\\\\").replace('"', '\\"')
1100
+
1101
+
1102
+ def _render_dot(graph: dict) -> str:
1103
+ nodes = graph.get("nodes", []) or []
1104
+ edges = graph.get("edges", []) or []
1105
+
1106
+ lines: list[str] = ["digraph reasoning {", ' rankdir="TB";', ' node [fontname="Helvetica", fontsize=10];', ' edge [fontname="Helvetica", fontsize=9];']
1107
+
1108
+ for n in nodes:
1109
+ nid = _dot_escape(n.get("id", ""))
1110
+ if not nid:
1111
+ continue
1112
+ label = _dot_escape(n.get("label") or n.get("id") or "")
1113
+ style = _DOT_NODE_STYLE.get(n.get("type", ""), "")
1114
+ attrs = f'label="{label}"'
1115
+ if style:
1116
+ attrs += f", {style}"
1117
+ lines.append(f' "{nid}" [{attrs}];')
1118
+
1119
+ for e in edges:
1120
+ src = _dot_escape(e.get("source", ""))
1121
+ tgt = _dot_escape(e.get("target", ""))
1122
+ rel = e.get("type", "")
1123
+ if not (src and tgt and rel):
1124
+ continue
1125
+ attrs = [f'label="{_dot_escape(rel)}"']
1126
+ if rel in _DOT_EDGE_STYLE:
1127
+ attrs.append(_DOT_EDGE_STYLE[rel])
1128
+ lines.append(f' "{src}" -> "{tgt}" [{", ".join(attrs)}];')
1129
+
1130
+ lines.append("}")
1131
+ return "\n".join(lines) + "\n"
1132
+
1133
+
1134
+ @reasoning.command("graph")
1135
+ @click.argument("filename")
1136
+ @click.option(
1137
+ "--syntax",
1138
+ "graph_syntax",
1139
+ type=click.Choice(["mermaid", "dot"]),
1140
+ default="mermaid",
1141
+ help="Graph syntax to emit. Use the global -f json for the raw graph payload.",
1142
+ )
1143
+ @pass_ctx
1144
+ def reasoning_graph_cmd(ctx: Ctx, filename: str, graph_syntax: str) -> None:
1145
+ """Render the Argument graph from FILENAME as Mermaid (default) or DOT.
1146
+
1147
+ Pipe the output into a markdown PR comment, a Mermaid renderer, or
1148
+ `dot -Tsvg` to visualize Claims/Assumptions/Evidence and the Arguments
1149
+ that connect them. `-f json` returns the raw graph payload (nodes,
1150
+ edges, metadata).
1151
+ """
1152
+ xml = _get_xml(ctx, filename)
1153
+ data = ctx.client.post("/reasoning/graph", json={"xml": xml})
1154
+ if ctx.fmt == "json":
1155
+ output(data, "json")
1156
+ return
1157
+ if graph_syntax == "dot":
1158
+ print_plain(_render_dot(data))
1159
+ else:
1160
+ print_plain(_render_mermaid(data))
1161
+ # Malformed nodes are dropped server-side with a warning in
1162
+ # metadata.warnings — without echoing it the rendered graph reads as
1163
+ # authoritative while silently incomplete.
1164
+ meta = data.get("metadata") if isinstance(data, dict) else None
1165
+ for w in (meta or {}).get("warnings") or []:
1166
+ echo_warning(str(w))
1167
+
1168
+
1169
+ # --- reasoning lint ---------------------------------------------------------
1170
+
1171
+
1172
+ _SEVERITY_COLOR = {"error": "red", "warn": "yellow"}
1173
+
1174
+
1175
+ def _format_lint_findings_plain(data: dict) -> str:
1176
+ """Format /reasoning/lint findings for human reading.
1177
+
1178
+ One finding per line, plus a final summary. Empty result → empty string
1179
+ (matches `reasoning-diff`'s quiet-success convention). Severity color
1180
+ is suppressed when stdout is not a TTY so piped output stays clean.
1181
+ """
1182
+ findings = data.get("findings", []) or []
1183
+ counts = data.get("counts", {}) or {}
1184
+ if not findings:
1185
+ return ""
1186
+ use_color = sys.stdout.isatty()
1187
+ lines = []
1188
+ for f in findings:
1189
+ sev = f.get("severity", "warn")
1190
+ marker = (
1191
+ click.style(sev, fg=_SEVERITY_COLOR.get(sev, "yellow"))
1192
+ if use_color else sev
1193
+ )
1194
+ rule = f.get("rule", "")
1195
+ tid = f.get("target_id", "")
1196
+ msg = f.get("message", "")
1197
+ lines.append(f"{marker} [{rule}{rule_citation(f)}] {tid}: {msg}")
1198
+ err_n = counts.get("error", 0)
1199
+ warn_n = counts.get("warn", 0)
1200
+ lines.append("")
1201
+ lines.append(f"{err_n} error(s), {warn_n} warning(s)")
1202
+ footer = cited_rules_footer(findings)
1203
+ if footer:
1204
+ lines.append(footer)
1205
+ return "\n".join(lines)
1206
+
1207
+
1208
+ @reasoning.command("lint")
1209
+ @click.argument("filename")
1210
+ @click.option(
1211
+ "--strict",
1212
+ is_flag=True,
1213
+ help="Exit non-zero on warn-level findings too "
1214
+ "(default: only error-level findings fail).",
1215
+ )
1216
+ @pass_ctx
1217
+ def reasoning_lint_cmd(ctx: Ctx, filename: str, strict: bool) -> None:
1218
+ """Lint the <Reasoning> section of FILENAME for structural issues.
1219
+
1220
+ Runs the full read-only rule set (orphan claims, single-evidence funnels,
1221
+ missing required risk attrs, Body↔itemRefs drift, missing @calcRef anchors,
1222
+ unanchored claims/assumptions, attribute
1223
+ typos, exclusivity assertions, dangling sensitivity refs, missing apex
1224
+ recommendation). Exits non-zero when any error-level finding is produced;
1225
+ warn-level findings are hygiene nudges and exit 0 unless --strict. Wire
1226
+ `--strict` into pre-commit hooks the same way as `reasoning-diff`.
1227
+ `-f json` returns the raw payload (findings + counts).
1228
+ """
1229
+ xml = _get_xml(ctx, filename)
1230
+ data = ctx.client.post("/reasoning/lint", json={"xml": xml})
1231
+ if ctx.fmt == "json":
1232
+ output(data, "json")
1233
+ else:
1234
+ text = _format_lint_findings_plain(data)
1235
+ if text:
1236
+ print_plain(text)
1237
+ findings = data.get("findings") or []
1238
+ errors = [f for f in findings if f.get("severity") == "error"]
1239
+ if errors or (strict and findings):
1240
+ sys.exit(1)
1241
+
1242
+
1243
+ # --- reasoning impact -------------------------------------------------------
1244
+
1245
+
1246
+ @reasoning.command("impact", cls=FileFirstCommand)
1247
+ @click.argument("filename")
1248
+ @click.argument("claim_id")
1249
+ @pass_ctx
1250
+ def reasoning_impact_cmd(ctx: Ctx, filename: str, claim_id: str) -> None:
1251
+ """Walk Argument edges from CLAIM_ID and list downstream nodes affected if it's falsified.
1252
+
1253
+ Companion to `assumption impact` (single-hop) — `reasoning impact` is a
1254
+ graph walk that surfaces transitive Claims/Assumptions that depend_on,
1255
+ derive_from, support, or are variant_of the falsified claim. Each
1256
+ returned node carries its `dependency_path` — the chain of @rel labels
1257
+ from the node back to CLAIM_ID.
1258
+
1259
+ Both impact commands are workspace-backed and take a workspace-relative
1260
+ FILENAME (matching the `reasoning graph`/`lint` pattern). The local Git
1261
+ command is `reasoning-diff`. `-f json` returns the raw `{nodes: [...]}`
1262
+ payload. Continue with `deepcell guide revise/premise-change`.
1263
+ """
1264
+ xml = _get_xml(ctx, filename)
1265
+ data = ctx.client.post(
1266
+ "/reasoning/query",
1267
+ json={"xml": xml, "kind": "claim_impact", "id": claim_id},
1268
+ )
1269
+ if ctx.fmt == "json":
1270
+ output(data, "json")
1271
+ return
1272
+ nodes = data.get("nodes", []) if isinstance(data, dict) else []
1273
+ if not nodes:
1274
+ return
1275
+ for n in nodes:
1276
+ nid = n.get("id", "")
1277
+ ntype = n.get("type", "")
1278
+ kind = n.get("kind")
1279
+ label = n.get("label", "")
1280
+ path = "->".join(n.get("dependency_path", []) or [])
1281
+ marker = f"{ntype}/{kind}" if kind else ntype
1282
+ if path:
1283
+ click.echo(f"{nid} [{marker}] {label} ({path})")
1284
+ else:
1285
+ click.echo(f"{nid} [{marker}] {label}")
1286
+
1287
+
1288
+ # --- reasoning add-* --------------------------------------------------------
1289
+ #
1290
+ # Each add-* subcommand reads the .deepcell XML, sends a single
1291
+ # `POST /reasoning/edit` with `op="add_<element>"` and a payload of
1292
+ # camelCase Pydantic field names (see backend/jingwei_api/utils/reasoning_pydantic.py
1293
+ # lines 88-172). The backend's `_pyd_to_canonical` (reasoning.py:242-266)
1294
+ # rewrites the payload into the canonical XML-dict shape, so the CLI just
1295
+ # needs to forward the flat dict verbatim and strip None values.
1296
+ #
1297
+ # Choice values mirror backend/src/core/constants/reasoning.py.
1298
+ # Sync with backend/src/core/constants/reasoning.py — if this list drifts,
1299
+ # `deepcell reasoning add-*` will reject values the backend would accept.
1300
+ _CLAIM_KINDS = (
1301
+ "thesis", "risk", "catalyst", "counter", "question",
1302
+ "market_consensus",
1303
+ )
1304
+ _ARGUMENT_RELS = (
1305
+ "supports", "refutes", "depends_on", "derives_from",
1306
+ "variant_of", "supersedes", "contradicts", "references", "answers",
1307
+ )
1308
+ _SEVERITY_VALUES = ("low", "med", "high")
1309
+
1310
+ #: Mirrors ``NOTATIONS`` in ``backend/src/core/prose/body.py``. Declared here
1311
+ #: as a ``Choice`` so a typo is refused locally rather than coming back as a
1312
+ #: 422, the same standard ``BINDING_KINDS`` is held to in ``deck.py``.
1313
+ #:
1314
+ #: A reasoning body defaults to ``text`` — that is what every existing one
1315
+ #: already was, and re-reading the corpus as markdown would change what 11
1316
+ #: documents say without anyone editing them. Declaring ``markdown`` is how an
1317
+ #: author opts a body in, and it is the only way to stop `**0.397**` rendering
1318
+ #: as four visible asterisks.
1319
+ _BODY_NOTATIONS = ("markdown", "text")
1320
+
1321
+ #: What a Label is *for*, said where the author is about to write one. The
1322
+ #: standard itself is `deepcell ref claim` (per kind) and the agent prompt;
1323
+ #: this is the one-line reminder at the point of use, and it is shared so the
1324
+ #: seven options that take a Label cannot drift into seven standards.
1325
+ _LABEL_HELP = (
1326
+ "The finding itself, not the subject it concerns — \"Working capital is a "
1327
+ "source of cash\", not \"Working capital\". Renders as the reasoning "
1328
+ "outline row, and a Deck may bind it as a slide headline."
1329
+ )
1330
+
1331
+ _BODY_NOTATION_HELP = (
1332
+ "What the <Body> IS: markdown or literal text. Declared, never sniffed — "
1333
+ "no reader guesses from the bytes. Omitted leaves it as it was."
1334
+ )
1335
+ #: `@strength` — how strongly a claim is held, qualitatively. Mirrors
1336
+ #: `src.core.constants.reasoning.CLAIM_STRENGTHS`. Independent of
1337
+ #: `--confidence`, which is the same judgement as a number: the format defines
1338
+ #: both and says they may coexist, so neither is offered as an alias for the
1339
+ #: other.
1340
+ _CLAIM_STRENGTHS = ("low", "med", "high")
1341
+ _CLAIM_STATUSES = ("draft", "active", "superseded", "falsified", "revoked")
1342
+ _ASSUMPTION_STATUSES = ("holding", "uncertain", "broken", "superseded")
1343
+
1344
+ # Assumption lifecycle dates. Both were documented in the Assumption attribute
1345
+ # table with no way to set them, so `--broken-at` was reached for in good faith
1346
+ # and answered with "No such option" (guide eval, `reasoning` task-2). They are
1347
+ # plain ISO date attributes: the backend already allow-lists and ISO-validates
1348
+ # `@verifiedAt` / `@brokenAt`, so this is a CLI-side gap only.
1349
+ _VERIFIED_AT_HELP = (
1350
+ "ISO date (YYYY-MM-DD) this assumption was last confirmed to still hold "
1351
+ "(@verifiedAt). Records WHEN it was checked; --status holding records that "
1352
+ "it stands."
1353
+ )
1354
+ _BROKEN_AT_HELP = (
1355
+ "ISO date (YYYY-MM-DD) this assumption was found no longer to hold "
1356
+ "(@brokenAt). Records WHEN it failed; --status broken records THAT it "
1357
+ "failed — set both when actuals contradict an assumption."
1358
+ )
1359
+
1360
+ def _get_xml_with_revision(ctx: Ctx, filename: str) -> tuple[str, str | None]:
1361
+ """Read the document and the revision it was read at.
1362
+
1363
+ Every `reasoning add-*`/`update-*` is fetch → `/reasoning/edit` →
1364
+ POST-full-content, so the revision has to travel with the XML or the write
1365
+ back is unguarded and overwrites whatever landed in between (#1217).
1366
+ """
1367
+ from deepcell_cli.revision import fetch_xml_with_revision
1368
+
1369
+ return fetch_xml_with_revision(ctx, ctx.require_workspace(), filename)
1370
+
1371
+
1372
+ def _persist_edit_result(
1373
+ ctx: Ctx, filename: str, data: Any, revision: str | None = None
1374
+ ) -> None:
1375
+ """Write the post-edit XML back to the workspace and print the result.
1376
+
1377
+ `/reasoning/edit` and the workspace write are two separate calls; if the
1378
+ second fails after the first succeeds, the user has no persisted state
1379
+ but a successful-looking edit. Surface that explicitly so the user knows
1380
+ to retry the write rather than re-run the whole add-* command (which
1381
+ would re-apply the edit to a stale tree).
1382
+
1383
+ ``revision`` makes the write a compare-and-swap against the fetch, so a
1384
+ concurrent change to this file is refused (409) instead of being silently
1385
+ replaced by our copy of the pre-edit document.
1386
+
1387
+ The ``-m`` / ``--title`` the caller typed travel with it. Without them
1388
+ this path sent no message at all and the server fell through to ``Update
1389
+ <filename>`` — so the family editing the Claims and Assumptions, the one
1390
+ place where "why did this change" IS the subject matter, recorded neither
1391
+ the reason nor even which operation ran (#1717).
1392
+ """
1393
+ if not isinstance(data, dict):
1394
+ raise click.ClickException("Unexpected response shape from /reasoning/edit.")
1395
+ new_xml = data.get("xml", "")
1396
+ if not new_xml:
1397
+ raise click.ClickException("Server returned empty XML after edit.")
1398
+ slug = ctx.require_workspace()
1399
+ from deepcell_cli.errors import APIError
1400
+ from deepcell_cli.revision import raise_if_stale, write_body
1401
+
1402
+ title, rationale = write_message()
1403
+ try:
1404
+ write_result = ctx.client.post(
1405
+ f"/workspaces/{slug}/files/{filename}",
1406
+ json=write_body(new_xml, revision, title=title, rationale=rationale),
1407
+ )
1408
+ except Exception as exc:
1409
+ # A stale-revision 409 is not "the workspace is broken, retry the
1410
+ # write" — replaying this XML would be the lost update. It gets its
1411
+ # own message; everything else keeps the pre-existing one.
1412
+ if isinstance(exc, APIError):
1413
+ raise_if_stale(exc, filename=filename)
1414
+ raise click.ClickException(
1415
+ f"Edit succeeded server-side but persisting to "
1416
+ f"{slug}/{filename} failed: {exc}. The edit was NOT saved — "
1417
+ "re-run the command after resolving the workspace error."
1418
+ ) from exc
1419
+ output(data.get("result", {}), ctx.fmt)
1420
+ # #1171 — a retried add whose first attempt landed server-side succeeds
1421
+ # idempotently; tell the caller no second node was created.
1422
+ if isinstance(data.get("result"), dict) and data["result"].get("already_existed"):
1423
+ echo_info(
1424
+ "node already existed with identical content — nothing new was "
1425
+ "added (idempotent retry)"
1426
+ )
1427
+ # Surface lint findings (warn-level — error-level findings would have
1428
+ # raised an HTTPException with a structured detail before reaching here).
1429
+ # On stderr: stdout carries the structured result (pure JSON in -f json).
1430
+ findings = data.get("findings") or data.get("result", {}).get("findings") or []
1431
+ if findings:
1432
+ click.echo("Findings:", err=True)
1433
+ for f in findings:
1434
+ sev = f.get("severity", "warn")
1435
+ click.echo(
1436
+ f" [{sev}] {f.get('rule')}{rule_citation(f)}: "
1437
+ f"{f.get('message')}{provenance(f)}",
1438
+ err=True,
1439
+ )
1440
+ footer = cited_rules_footer(findings)
1441
+ if footer:
1442
+ click.echo(footer, err=True)
1443
+
1444
+ # What to do next, as opposed to what is wrong now. The server defers a
1445
+ # warning it would be unfair to raise yet (an Argument cannot exist before
1446
+ # the node it points at) and states it forward instead.
1447
+ for step in (data.get("result") or {}).get("next_steps") or []:
1448
+ echo_info(step)
1449
+ errors = echo_validation(write_result)
1450
+ sha = write_result.get("commit_sha") if isinstance(write_result, dict) else None
1451
+ sha_part = f" (commit {str(sha)[:8]})" if sha else ""
1452
+ if errors:
1453
+ echo_warning(
1454
+ f"'{filename}' saved{sha_part} with {len(errors)} validation "
1455
+ "error(s) — fix the errors above"
1456
+ )
1457
+ raise click.exceptions.Exit(1)
1458
+ echo_success(f"'{filename}' saved{sha_part}")
1459
+
1460
+
1461
+ # The coverage anchor, as flags, declared once.
1462
+ #
1463
+ # Coverage is what a node is ABOUT: Item x Context x Status x Scenario x
1464
+ # CustomDimensions -- the same five dimensions a cell has. The format has
1465
+ # always carried all five and the CLI reached three of them: `--context-refs`
1466
+ # was on 2 of the 8 write commands, and `--scenario-ref` / `--custom-dimensions`
1467
+ # on none at all, so a five-dimension anchor could only be written by editing
1468
+ # XML by hand.
1469
+ #
1470
+ # Declared here so a ninth write command cannot quietly ship with three of the
1471
+ # five. `--item-refs` and `--status-ref` stay on their commands: their help
1472
+ # text is command-specific (one explains `unanchored_claim`, the other warns
1473
+ # it is not the lifecycle `--status`).
1474
+ #
1475
+ # These say what a claim RANGES OVER. To state a number the model holds, cite
1476
+ # the cell inside the body -- `[[deepcell:cell/Item[Context]#status]]` through
1477
+ # `--body` -- which needs no flag of its own. See
1478
+ # `deepcell guide orient/surface-ownership`.
1479
+ _CONTEXT_REFS_OPTION = click.option(
1480
+ "--context-refs", "context_refs", default=None,
1481
+ help="Comma-separated context (period) ids this node reasons across.",
1482
+ )
1483
+ _SCENARIO_REF_OPTION = click.option(
1484
+ "--scenario-ref", "scenario_ref", default=None,
1485
+ help="Scenario id this node is about, when it holds only in one world.",
1486
+ )
1487
+ _CUSTOM_DIMENSIONS_OPTION = click.option(
1488
+ "--custom-dimensions", "custom_dimensions", default=None,
1489
+ help="Custom dimension members as `dim:member;dim2:member2` — the fifth "
1490
+ "axis of the anchor (e.g. `region:emea`).",
1491
+ )
1492
+
1493
+
1494
+ def _coverage_anchor_options(f):
1495
+ """`--context-refs`, `--scenario-ref`, `--custom-dimensions`, in that order."""
1496
+ for option in (
1497
+ _CUSTOM_DIMENSIONS_OPTION, _SCENARIO_REF_OPTION, _CONTEXT_REFS_OPTION,
1498
+ ):
1499
+ f = option(f)
1500
+ return f
1501
+
1502
+
1503
+ def _narrow_anchor_options(f):
1504
+ """The two a command that already declares `--context-refs` still lacks."""
1505
+ for option in (_CUSTOM_DIMENSIONS_OPTION, _SCENARIO_REF_OPTION):
1506
+ f = option(f)
1507
+ return f
1508
+
1509
+
1510
+ #: Batch entries name their verb ``op`` (the `/reasoning/edit` spelling) while
1511
+ #: `defs apply` names it ``kind``. The two batches are separate doors — a
1512
+ #: reasoning kind is refused by `defs apply`'s request schema, which is a
1513
+ #: tagged union of the structural ops only — but an agent moving between them
1514
+ #: carries the vocabulary across, and being answered "unknown op: ''" over a
1515
+ #: spelling costs exactly the round the batch just saved. So both spellings are
1516
+ #: accepted here, and the same for the field bag, which `defs apply` calls
1517
+ #: ``defaults`` and this route calls ``payload``.
1518
+ def _normalize_reasoning_ops(payload: Any) -> list[dict[str, Any]]:
1519
+ """Coerce a `reasoning apply` payload into `/reasoning/edit` batch entries."""
1520
+ if isinstance(payload, dict):
1521
+ ops = payload.get("ops")
1522
+ if ops is None:
1523
+ raise click.ClickException(
1524
+ "ops JSON object has no 'ops' key. Pass either a bare array "
1525
+ 'of ops or {"ops": [...]}.'
1526
+ )
1527
+ else:
1528
+ ops = payload
1529
+ if not isinstance(ops, list):
1530
+ raise click.ClickException(
1531
+ 'ops must be a JSON array, or an object with an "ops" array.'
1532
+ )
1533
+ if not ops:
1534
+ raise click.ClickException("ops list is empty — nothing to apply.")
1535
+
1536
+ out: list[dict[str, Any]] = []
1537
+ for i, op in enumerate(ops):
1538
+ if not isinstance(op, dict):
1539
+ raise click.ClickException(f"op {i} is not a JSON object.")
1540
+ entry = dict(op)
1541
+ if "op" not in entry and "kind" in entry:
1542
+ entry["op"] = entry.pop("kind")
1543
+ if "payload" not in entry and "defaults" in entry:
1544
+ entry["payload"] = entry.pop("defaults")
1545
+ if not entry.get("op"):
1546
+ # Deliberately does NOT name a kind: `test_op_reachability` reads
1547
+ # string constants in real code positions out of this package, and
1548
+ # a kind spelled here would be counted as one the CLI emits.
1549
+ raise click.ClickException(
1550
+ f"op {i} has no 'op' — each entry needs one, beside its "
1551
+ "'payload'. Run `deepcell ref op` for the kinds and "
1552
+ "their fields."
1553
+ )
1554
+ out.append(entry)
1555
+ return out
1556
+
1557
+
1558
+ @reasoning.command("apply", cls=WriteCommand)
1559
+ @click.argument("filename")
1560
+ @click.option(
1561
+ "--ops-file",
1562
+ "ops_file",
1563
+ default=None,
1564
+ type=click.File("r"),
1565
+ help="JSON file with an `ops` array (use '-' for stdin).",
1566
+ )
1567
+ @click.option(
1568
+ "--ops",
1569
+ "ops_inline",
1570
+ default=None,
1571
+ help="Inline ops JSON (same shapes as --ops-file).",
1572
+ )
1573
+ @pass_ctx
1574
+ def reasoning_apply_cmd(
1575
+ ctx: Ctx,
1576
+ filename: str,
1577
+ ops_file: Any,
1578
+ ops_inline: str | None,
1579
+ ) -> None:
1580
+ """Apply a batch of reasoning ops atomically.
1581
+
1582
+ \b
1583
+ The whole argument in one call. Each `reasoning add-*` is a separate
1584
+ round-trip, and a real argument is a dozen nodes and two dozen edges —
1585
+ one eval run spent 69 of its 282 commands here, one node at a time.
1586
+
1587
+ \b
1588
+ The payload is a JSON document of either form:
1589
+ [{"op": "add_claim", "payload": {...}}, ...] # bare array
1590
+ {"ops": [{"op": "add_evidence", "payload": {...}}, ...]}
1591
+
1592
+ \b
1593
+ Verbs are the `add_*` / `update_*` / `delete_*` / `supersede_*` /
1594
+ `set_*` op kinds (`deepcell ref op`). Edge ops carry `from_id`,
1595
+ `to_id` and `edge_rel` beside the payload rather than inside it:
1596
+ {"op": "add_argument", "payload": {"from": "c_thesis",
1597
+ "rel": "supports", "to": "c_growth"}}
1598
+
1599
+ \b
1600
+ Order matters and is honoured: declare a node before the Argument that
1601
+ points at it, in the same batch. The batch is atomic — one bad op applies
1602
+ none of them and persists nothing, so a rejection is safe to fix and
1603
+ re-send whole.
1604
+
1605
+ \b
1606
+ `defs apply` is the other door and carries the structural kinds; a
1607
+ reasoning kind sent there is refused by its request schema. Structure and
1608
+ argument are two batched calls, not one mixed one — still two instead of
1609
+ the two hundred a command per node costs.
1610
+ """
1611
+ if (ops_file is None) == (ops_inline is None):
1612
+ raise click.UsageError("Provide exactly one of --ops-file or --ops.")
1613
+
1614
+ raw = ops_inline if ops_inline is not None else ops_file.read()
1615
+ try:
1616
+ parsed = json.loads(raw)
1617
+ except json.JSONDecodeError as exc:
1618
+ raise click.ClickException(f"ops payload is not valid JSON: {exc}") from exc
1619
+
1620
+ ops = _normalize_reasoning_ops(parsed)
1621
+
1622
+ xml, revision = _get_xml_with_revision(ctx, filename)
1623
+ data = ctx.client.post(
1624
+ "/reasoning/edit",
1625
+ json={"op": "batch", "xml": xml, "ops": ops},
1626
+ )
1627
+ _persist_edit_result(ctx, filename, data, revision)
1628
+
1629
+
1630
+ @reasoning.command("add-claim", cls=WriteCommand)
1631
+ @click.argument("filename")
1632
+ @click.option("--id", "claim_id", required=True, help="New Claim id.")
1633
+ @click.option(
1634
+ "--kind",
1635
+ required=True,
1636
+ type=click.Choice(_CLAIM_KINDS),
1637
+ help="What the claim is: thesis (what you conclude), risk / catalyst "
1638
+ "(what moves it, needs --probability), counter (the case against), "
1639
+ "question (open), market_consensus (someone else's view, needs "
1640
+ "--attribution). See `deepcell guide revise/reasoning`.",
1641
+ )
1642
+ @click.option("--label", default=None, help=f"<Label> text. {_LABEL_HELP}")
1643
+ @click.option(
1644
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
1645
+ help=_text_file_help("<Label>", "label"),
1646
+ )
1647
+ @click.option("--body", default=None, help="<Body> text.")
1648
+ @click.option(
1649
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
1650
+ help=_text_file_help("<Body>", "body"),
1651
+ )
1652
+ @click.option(
1653
+ "--body-notation", "body_notation", default=None,
1654
+ type=click.Choice(_BODY_NOTATIONS),
1655
+ help=_BODY_NOTATION_HELP,
1656
+ )
1657
+ @click.option(
1658
+ "--status-ref", default=None,
1659
+ help="Model Status dimension id (e.g. actual / projected) — NOT the "
1660
+ "claim lifecycle state (that is --status).",
1661
+ )
1662
+ @click.option(
1663
+ "--calc-ref", default=None,
1664
+ help="CalcDef id anchoring this claim to a formula in the model.",
1665
+ )
1666
+ @click.option(
1667
+ "--item-refs", default=None,
1668
+ help="Comma-separated item ids anchoring this claim to a Spreadsheet. "
1669
+ "When Items exist, thesis/risk/catalyst claims need --item-refs or "
1670
+ "--calc-ref, else unanchored_claim warns (they become invisible to "
1671
+ "reasoning-diff and item-scoped queries). A qualitative no-grid file "
1672
+ "does not invent Items; question/market_consensus are always exempt.",
1673
+ )
1674
+ @click.option(
1675
+ "--probability", default=None, type=float,
1676
+ help="Likelihood, 0-1 (required for kind=risk / catalyst).",
1677
+ )
1678
+ @click.option(
1679
+ "--severity", default=None, type=click.Choice(_SEVERITY_VALUES),
1680
+ help="Qualitative impact (required for kind=risk).",
1681
+ )
1682
+ @click.option(
1683
+ "--confidence", default=None, type=float,
1684
+ help="How strongly you hold this claim, 0-1.",
1685
+ )
1686
+ @click.option(
1687
+ "--strength", default=None, type=click.Choice(_CLAIM_STRENGTHS),
1688
+ help="How strongly the claim is held, qualitatively. Independent of "
1689
+ "--confidence (the numeric form); a claim may carry both.",
1690
+ )
1691
+ @click.option(
1692
+ "--parent-claim-ref", default=None,
1693
+ help="Claim id this one hangs under (hierarchy, not an Argument edge).",
1694
+ )
1695
+ @click.option(
1696
+ "--attribution", default=None,
1697
+ help="Who holds this view (required for kind=market_consensus), "
1698
+ "e.g. 'Bloomberg consensus, 2026-07'.",
1699
+ )
1700
+ @click.option(
1701
+ "--status", default=None, type=click.Choice(_CLAIM_STATUSES),
1702
+ help="Claim lifecycle state — NOT the model's Status dimension "
1703
+ "(that is --status-ref).",
1704
+ )
1705
+ @_coverage_anchor_options
1706
+ @pass_ctx
1707
+ def reasoning_add_claim_cmd(
1708
+ ctx: Ctx,
1709
+ filename: str,
1710
+ claim_id: str,
1711
+ kind: str,
1712
+ label: str | None,
1713
+ label_file: str | None,
1714
+ body: str | None,
1715
+ body_file: str | None,
1716
+ body_notation: str | None,
1717
+ status_ref: str | None,
1718
+ calc_ref: str | None,
1719
+ item_refs: str | None,
1720
+ context_refs: str | None,
1721
+ scenario_ref: str | None,
1722
+ custom_dimensions: str | None,
1723
+ probability: float | None,
1724
+ severity: str | None,
1725
+ confidence: float | None,
1726
+ strength: str | None,
1727
+ parent_claim_ref: str | None,
1728
+ attribution: str | None,
1729
+ status: str | None,
1730
+ ) -> None:
1731
+ """Add a new <Claim> to FILENAME's <Reasoning> section.
1732
+
1733
+ Anchor the claim to the model with --item-refs or --calc-ref so it can
1734
+ be impact-traced later. See `deepcell ref claim` for what each kind
1735
+ is for.
1736
+ """
1737
+ _check_single_stdin(label_file, body_file)
1738
+ label = _resolve_text_option(label, label_file, "label", required=True)
1739
+ body = _resolve_text_option(body, body_file, "body")
1740
+ xml, revision = _get_xml_with_revision(ctx, filename)
1741
+ payload = {
1742
+ k: v for k, v in {
1743
+ "id": claim_id,
1744
+ "kind": kind,
1745
+ "label_text": label,
1746
+ "body_text": body,
1747
+ "body_notation": body_notation,
1748
+ "statusRef": status_ref,
1749
+ "calcRef": calc_ref,
1750
+ "itemRefs": item_refs,
1751
+ "contextRefs": context_refs,
1752
+ "scenarioRef": scenario_ref,
1753
+ "customDimensions": custom_dimensions,
1754
+ "probability": probability,
1755
+ "severity": severity,
1756
+ "confidence": confidence,
1757
+ "strength": strength,
1758
+ "parentClaimRef": parent_claim_ref,
1759
+ "attribution": attribution,
1760
+ "status": status,
1761
+ }.items() if v is not None
1762
+ }
1763
+ data = ctx.client.post(
1764
+ "/reasoning/edit",
1765
+ json={"xml": xml, "op": "add_claim", "payload": payload},
1766
+ )
1767
+ _persist_edit_result(ctx, filename, data, revision)
1768
+
1769
+
1770
+ @reasoning.command("add-assumption", cls=WriteCommand)
1771
+ @click.argument("filename")
1772
+ @click.option("--id", "assumption_id", required=True, help="New Assumption id.")
1773
+ @click.option("--label", default=None, help=f"<Label> text. {_LABEL_HELP}")
1774
+ @click.option(
1775
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
1776
+ help=_text_file_help("<Label>", "label"),
1777
+ )
1778
+ @click.option("--body", default=None, help="<Body> text.")
1779
+ @click.option(
1780
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
1781
+ help=_text_file_help("<Body>", "body"),
1782
+ )
1783
+ @click.option(
1784
+ "--body-notation", "body_notation", default=None,
1785
+ type=click.Choice(_BODY_NOTATIONS),
1786
+ help=_BODY_NOTATION_HELP,
1787
+ )
1788
+ @click.option(
1789
+ "--status-ref",
1790
+ default=None,
1791
+ help="Model Status dimension id (e.g. actual / projected) — NOT the "
1792
+ "assumption's lifecycle state (that is --status).",
1793
+ )
1794
+ @click.option(
1795
+ "--calc-ref",
1796
+ default=None,
1797
+ help="CalcDef id anchoring this assumption to a formula in the model.",
1798
+ )
1799
+ @click.option(
1800
+ "--item-refs", default=None,
1801
+ help="Comma-separated item ids the assumption constrains. When a "
1802
+ "Spreadsheet exists, omitting it triggers unanchored_assumption because "
1803
+ "the premise cannot be item-impact-traced as actuals drift in. A "
1804
+ "qualitative no-grid file and macro assumptions may legitimately omit it.",
1805
+ )
1806
+ @click.option(
1807
+ "--confidence",
1808
+ default=None,
1809
+ type=float,
1810
+ help="How strongly you hold this assumption, 0-1.",
1811
+ )
1812
+ @click.option(
1813
+ "--status",
1814
+ default=None,
1815
+ type=click.Choice(_ASSUMPTION_STATUSES),
1816
+ help="Assumption lifecycle state: holding until something contradicts it, "
1817
+ "broken once actuals do, superseded when a newer assumption replaces it.",
1818
+ )
1819
+ @click.option("--verified-at", "verified_at", default=None, help=_VERIFIED_AT_HELP)
1820
+ @click.option("--broken-at", "broken_at", default=None, help=_BROKEN_AT_HELP)
1821
+ @click.option(
1822
+ "--parent-claim-ref", default=None,
1823
+ help="Claim id this assumption hangs under (hierarchy, not an Argument edge).",
1824
+ )
1825
+ @_coverage_anchor_options
1826
+ @pass_ctx
1827
+ def reasoning_add_assumption_cmd(
1828
+ ctx: Ctx,
1829
+ filename: str,
1830
+ assumption_id: str,
1831
+ label: str | None,
1832
+ label_file: str | None,
1833
+ body: str | None,
1834
+ body_file: str | None,
1835
+ body_notation: str | None,
1836
+ status_ref: str | None,
1837
+ calc_ref: str | None,
1838
+ item_refs: str | None,
1839
+ context_refs: str | None,
1840
+ scenario_ref: str | None,
1841
+ custom_dimensions: str | None,
1842
+ confidence: float | None,
1843
+ status: str | None,
1844
+ verified_at: str | None,
1845
+ broken_at: str | None,
1846
+ parent_claim_ref: str | None,
1847
+ ) -> None:
1848
+ """Add a new <Assumption> to FILENAME's <Reasoning> section."""
1849
+ _check_single_stdin(label_file, body_file)
1850
+ label = _resolve_text_option(label, label_file, "label", required=True)
1851
+ body = _resolve_text_option(body, body_file, "body")
1852
+ xml, revision = _get_xml_with_revision(ctx, filename)
1853
+ payload = {
1854
+ k: v for k, v in {
1855
+ "id": assumption_id,
1856
+ "label_text": label,
1857
+ "body_text": body,
1858
+ "body_notation": body_notation,
1859
+ "statusRef": status_ref,
1860
+ "calcRef": calc_ref,
1861
+ "itemRefs": item_refs,
1862
+ "contextRefs": context_refs,
1863
+ "scenarioRef": scenario_ref,
1864
+ "customDimensions": custom_dimensions,
1865
+ "confidence": confidence,
1866
+ "status": status,
1867
+ "verifiedAt": verified_at,
1868
+ "brokenAt": broken_at,
1869
+ "parentClaimRef": parent_claim_ref,
1870
+ }.items() if v is not None
1871
+ }
1872
+ data = ctx.client.post(
1873
+ "/reasoning/edit",
1874
+ json={"xml": xml, "op": "add_assumption", "payload": payload},
1875
+ )
1876
+ _persist_edit_result(ctx, filename, data, revision)
1877
+
1878
+
1879
+ def _update_reasoning_node(
1880
+ ctx: Ctx, filename: str, op: str, target_id: str | None, payload: dict,
1881
+ address: dict | None = None,
1882
+ ) -> None:
1883
+ """Shared body for update-claim / update-assumption: require at least
1884
+ one attribute, post the partial payload through /reasoning/edit, and
1885
+ persist. The backend merges the payload into the existing node.
1886
+
1887
+ `address` is the edge triple `update-argument` may give instead of an id;
1888
+ it rides on the request envelope beside `target_id`, never inside the
1889
+ payload, because the payload is the patch and the triple is the address.
1890
+ """
1891
+ if not payload:
1892
+ raise click.ClickException(
1893
+ "Nothing to update — pass at least one attribute option "
1894
+ "(e.g. --item-refs, --calc-ref, --label)."
1895
+ )
1896
+ xml, revision = _get_xml_with_revision(ctx, filename)
1897
+ data = ctx.client.post(
1898
+ "/reasoning/edit",
1899
+ json={
1900
+ "xml": xml, "op": op, "target_id": target_id, "payload": payload,
1901
+ **(address or {}),
1902
+ },
1903
+ )
1904
+ _persist_edit_result(ctx, filename, data, revision)
1905
+
1906
+
1907
+ @reasoning.command("update-claim", cls=FileFirstWriteCommand)
1908
+ @click.argument("filename")
1909
+ @click.argument("claim_id")
1910
+ @click.option(
1911
+ "--kind",
1912
+ default=None,
1913
+ type=click.Choice(_CLAIM_KINDS),
1914
+ help="Reclassify the claim — see `deepcell reasoning add-claim --help` "
1915
+ "for what each kind means.",
1916
+ )
1917
+ @click.option(
1918
+ "--label", default=None,
1919
+ help=f"Replacement <Label> text. {_LABEL_HELP}",
1920
+ )
1921
+ @click.option(
1922
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
1923
+ help=_text_file_help("replacement <Label>", "label"),
1924
+ )
1925
+ @click.option("--body", default=None, help="Replacement <Body> text.")
1926
+ @click.option(
1927
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
1928
+ help=_text_file_help("replacement <Body>", "body"),
1929
+ )
1930
+ @click.option(
1931
+ "--body-notation", "body_notation", default=None,
1932
+ type=click.Choice(_BODY_NOTATIONS),
1933
+ help=_BODY_NOTATION_HELP,
1934
+ )
1935
+ @click.option(
1936
+ "--status-ref", default=None,
1937
+ help="Model Status dimension id (e.g. actual / projected) — NOT the "
1938
+ "claim lifecycle state (that is --status).",
1939
+ )
1940
+ @click.option(
1941
+ "--calc-ref", default=None,
1942
+ help="CalcDef id anchoring this claim to a formula in the model.",
1943
+ )
1944
+ @click.option(
1945
+ "--item-refs", default=None,
1946
+ help="Comma-separated item ids anchoring this claim to a Spreadsheet — "
1947
+ "the fix for unanchored_claim when the file has Items.",
1948
+ )
1949
+ @click.option(
1950
+ "--probability", default=None, type=float,
1951
+ help="Likelihood, 0-1 (required for kind=risk / catalyst).",
1952
+ )
1953
+ @click.option(
1954
+ "--severity", default=None, type=click.Choice(_SEVERITY_VALUES),
1955
+ help="Qualitative impact (required for kind=risk).",
1956
+ )
1957
+ @click.option(
1958
+ "--confidence", default=None, type=float,
1959
+ help="How strongly you hold this claim, 0-1.",
1960
+ )
1961
+ @click.option(
1962
+ "--strength", default=None, type=click.Choice(_CLAIM_STRENGTHS),
1963
+ help="How strongly the claim is held, qualitatively. Independent of "
1964
+ "--confidence (the numeric form); a claim may carry both.",
1965
+ )
1966
+ @click.option(
1967
+ "--parent-claim-ref", default=None,
1968
+ help="Claim id this one hangs under (hierarchy, not an Argument edge).",
1969
+ )
1970
+ @click.option(
1971
+ "--attribution", default=None,
1972
+ help="Who holds this view (required for kind=market_consensus).",
1973
+ )
1974
+ @click.option(
1975
+ "--status", default=None, type=click.Choice(_CLAIM_STATUSES),
1976
+ help="Claim lifecycle state — NOT the model's Status dimension "
1977
+ "(that is --status-ref).",
1978
+ )
1979
+ @_coverage_anchor_options
1980
+ @pass_ctx
1981
+ def reasoning_update_claim_cmd(
1982
+ ctx: Ctx,
1983
+ filename: str,
1984
+ claim_id: str,
1985
+ kind: str | None,
1986
+ label: str | None,
1987
+ label_file: str | None,
1988
+ body: str | None,
1989
+ body_file: str | None,
1990
+ body_notation: str | None,
1991
+ status_ref: str | None,
1992
+ calc_ref: str | None,
1993
+ item_refs: str | None,
1994
+ context_refs: str | None,
1995
+ scenario_ref: str | None,
1996
+ custom_dimensions: str | None,
1997
+ probability: float | None,
1998
+ severity: str | None,
1999
+ confidence: float | None,
2000
+ strength: str | None,
2001
+ parent_claim_ref: str | None,
2002
+ attribution: str | None,
2003
+ status: str | None,
2004
+ ) -> None:
2005
+ """Update attributes on an existing <Claim> CLAIM_ID in FILENAME.
2006
+
2007
+ Partial update: only the options you pass change; everything else on
2008
+ the node is preserved. Typical use: anchor an existing claim
2009
+ (`update-claim m.deepcell t1 --item-refs Revenue`).
2010
+ """
2011
+ _check_single_stdin(label_file, body_file)
2012
+ label = _resolve_text_option(label, label_file, "label")
2013
+ body = _resolve_text_option(body, body_file, "body")
2014
+ payload = {
2015
+ k: v for k, v in {
2016
+ "kind": kind,
2017
+ "label_text": label,
2018
+ "body_text": body,
2019
+ "body_notation": body_notation,
2020
+ "statusRef": status_ref,
2021
+ "calcRef": calc_ref,
2022
+ "itemRefs": item_refs,
2023
+ "contextRefs": context_refs,
2024
+ "scenarioRef": scenario_ref,
2025
+ "customDimensions": custom_dimensions,
2026
+ "probability": probability,
2027
+ "severity": severity,
2028
+ "confidence": confidence,
2029
+ "strength": strength,
2030
+ "parentClaimRef": parent_claim_ref,
2031
+ "attribution": attribution,
2032
+ "status": status,
2033
+ }.items() if v is not None
2034
+ }
2035
+ _update_reasoning_node(ctx, filename, "update_claim", claim_id, payload)
2036
+
2037
+
2038
+ @reasoning.command("update-assumption", cls=FileFirstWriteCommand)
2039
+ @click.argument("filename")
2040
+ @click.argument("assumption_id")
2041
+ @click.option(
2042
+ "--label", default=None,
2043
+ help=f"Replacement <Label> text. {_LABEL_HELP}",
2044
+ )
2045
+ @click.option(
2046
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
2047
+ help=_text_file_help("replacement <Label>", "label"),
2048
+ )
2049
+ @click.option("--body", default=None, help="Replacement <Body> text.")
2050
+ @click.option(
2051
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
2052
+ help=_text_file_help("replacement <Body>", "body"),
2053
+ )
2054
+ @click.option(
2055
+ "--body-notation", "body_notation", default=None,
2056
+ type=click.Choice(_BODY_NOTATIONS),
2057
+ help=_BODY_NOTATION_HELP,
2058
+ )
2059
+ @click.option(
2060
+ "--status-ref",
2061
+ default=None,
2062
+ help="Model Status dimension id (e.g. actual / projected) — NOT the "
2063
+ "assumption's lifecycle state (that is --status).",
2064
+ )
2065
+ @click.option(
2066
+ "--calc-ref",
2067
+ default=None,
2068
+ help="CalcDef id anchoring this assumption to a formula in the model.",
2069
+ )
2070
+ @click.option(
2071
+ "--item-refs", default=None,
2072
+ help="Comma-separated item ids the assumption constrains — the fix for "
2073
+ "unanchored_assumption when the file has Items.",
2074
+ )
2075
+ @click.option(
2076
+ "--confidence",
2077
+ default=None,
2078
+ type=float,
2079
+ help="How strongly you hold this assumption, 0-1.",
2080
+ )
2081
+ @click.option(
2082
+ "--status",
2083
+ default=None,
2084
+ type=click.Choice(_ASSUMPTION_STATUSES),
2085
+ help="Assumption lifecycle state: holding until something contradicts it, "
2086
+ "broken once actuals do, superseded when a newer assumption replaces it.",
2087
+ )
2088
+ @click.option("--verified-at", "verified_at", default=None, help=_VERIFIED_AT_HELP)
2089
+ @click.option("--broken-at", "broken_at", default=None, help=_BROKEN_AT_HELP)
2090
+ @_coverage_anchor_options
2091
+ @pass_ctx
2092
+ def reasoning_update_assumption_cmd(
2093
+ ctx: Ctx,
2094
+ filename: str,
2095
+ assumption_id: str,
2096
+ label: str | None,
2097
+ label_file: str | None,
2098
+ body: str | None,
2099
+ body_file: str | None,
2100
+ body_notation: str | None,
2101
+ status_ref: str | None,
2102
+ calc_ref: str | None,
2103
+ item_refs: str | None,
2104
+ context_refs: str | None,
2105
+ scenario_ref: str | None,
2106
+ custom_dimensions: str | None,
2107
+ confidence: float | None,
2108
+ status: str | None,
2109
+ verified_at: str | None,
2110
+ broken_at: str | None,
2111
+ ) -> None:
2112
+ """Update attributes on an existing <Assumption> ASSUMPTION_ID.
2113
+
2114
+ Partial update: only the options you pass change; everything else on
2115
+ the node is preserved.
2116
+
2117
+ A lifecycle flip is a plain update, not a supersede: `--status broken`
2118
+ records *that* the assumption failed, `--broken-at` records *when*.
2119
+ Supersede only when replacing the assumption's content.
2120
+ """
2121
+ _check_single_stdin(label_file, body_file)
2122
+ label = _resolve_text_option(label, label_file, "label")
2123
+ body = _resolve_text_option(body, body_file, "body")
2124
+ payload = {
2125
+ k: v for k, v in {
2126
+ "label_text": label,
2127
+ "body_text": body,
2128
+ "body_notation": body_notation,
2129
+ "statusRef": status_ref,
2130
+ "calcRef": calc_ref,
2131
+ "itemRefs": item_refs,
2132
+ "contextRefs": context_refs,
2133
+ "scenarioRef": scenario_ref,
2134
+ "customDimensions": custom_dimensions,
2135
+ "confidence": confidence,
2136
+ "status": status,
2137
+ "verifiedAt": verified_at,
2138
+ "brokenAt": broken_at,
2139
+ }.items() if v is not None
2140
+ }
2141
+ _update_reasoning_node(
2142
+ ctx, filename, "update_assumption", assumption_id, payload,
2143
+ )
2144
+
2145
+
2146
+ @reasoning.command("add-evidence", cls=WriteCommand)
2147
+ @click.argument("filename")
2148
+ @click.option("--id", "evidence_id", required=True, help="New Evidence id.")
2149
+ @click.option("--source-ref", required=True, help="sourceId of a <Source> in <SourceDefinitions>.")
2150
+ @click.option("--excerpt", default=None, help="<Excerpt> text.")
2151
+ @click.option(
2152
+ "--excerpt-file", "excerpt_file", type=_TEXT_FILE, default=None,
2153
+ help=_text_file_help("<Excerpt>", "excerpt"),
2154
+ )
2155
+ @click.option("--at", "at", default=None, help="Position in the source (page:47, t:00:14:32, text:HEADING).")
2156
+ @click.option("--reliability", default=None, help="primary | secondary | tertiary.")
2157
+ @click.option("--effective-date", default=None, help="ISO8601 date.")
2158
+ @click.option("--retrieved-at", default=None, help="ISO8601 date.")
2159
+ @click.option(
2160
+ "--item-refs", "item_refs", default=None,
2161
+ help="Comma-separated item ids this evidence bears on. Anchoring it here "
2162
+ "is what lets `reasoning impact` reach the evidence when one of those "
2163
+ "items moves; `update-evidence` can retrofit it later.",
2164
+ )
2165
+ @click.option(
2166
+ "--status-ref", "status_ref", default=None,
2167
+ help="Model Status dimension id (e.g. actual / projected) the evidence "
2168
+ "speaks to.",
2169
+ )
2170
+ @_coverage_anchor_options
2171
+ @pass_ctx
2172
+ def reasoning_add_evidence_cmd(
2173
+ ctx: Ctx,
2174
+ filename: str,
2175
+ evidence_id: str,
2176
+ source_ref: str,
2177
+ excerpt: str | None,
2178
+ excerpt_file: str | None,
2179
+ at: str | None,
2180
+ reliability: str | None,
2181
+ effective_date: str | None,
2182
+ retrieved_at: str | None,
2183
+ item_refs: str | None,
2184
+ status_ref: str | None,
2185
+ context_refs: str | None,
2186
+ scenario_ref: str | None,
2187
+ custom_dimensions: str | None,
2188
+ ) -> None:
2189
+ """Add a new <Evidence> to FILENAME's <Reasoning> section."""
2190
+ excerpt = _resolve_text_option(excerpt, excerpt_file, "excerpt")
2191
+ xml, revision = _get_xml_with_revision(ctx, filename)
2192
+ payload = {
2193
+ k: v for k, v in {
2194
+ "id": evidence_id,
2195
+ "sourceRef": source_ref,
2196
+ "excerpt_text": excerpt,
2197
+ "at": at,
2198
+ "reliability": reliability,
2199
+ "effectiveDate": effective_date,
2200
+ "retrievedAt": retrieved_at,
2201
+ "itemRefs": item_refs,
2202
+ "statusRef": status_ref,
2203
+ "contextRefs": context_refs,
2204
+ "scenarioRef": scenario_ref,
2205
+ "customDimensions": custom_dimensions,
2206
+ }.items() if v is not None
2207
+ }
2208
+ data = ctx.client.post(
2209
+ "/reasoning/edit",
2210
+ json={"xml": xml, "op": "add_evidence", "payload": payload},
2211
+ )
2212
+ _persist_edit_result(ctx, filename, data, revision)
2213
+
2214
+
2215
+ @reasoning.command("set-conclusion", cls=FileFirstWriteCommand)
2216
+ @click.argument("filename")
2217
+ @click.argument("claim_id", required=False)
2218
+ @click.option(
2219
+ "--clear",
2220
+ is_flag=True,
2221
+ default=False,
2222
+ help="Remove the declaration and fall back to deriving the apex from the "
2223
+ "argument topology.",
2224
+ )
2225
+ @pass_ctx
2226
+ def reasoning_set_conclusion_cmd(
2227
+ ctx: Ctx,
2228
+ filename: str,
2229
+ claim_id: str | None,
2230
+ clear: bool,
2231
+ ) -> None:
2232
+ """Declare which Claim is FILENAME's main conclusion.
2233
+
2234
+ Writes `<Reasoning conclusionRef="CLAIM_ID">`. Readers open the document on
2235
+ that Claim instead of guessing at the apex from the argument graph — which
2236
+ is wrong whenever a document chains its claims chronologically, because a
2237
+ `depends_on` edge to last week's claim is structurally identical to
2238
+ subordination.
2239
+
2240
+ This does not create a Claim. Author the apex `thesis` with `add-claim`
2241
+ first, then point the section at it.
2242
+
2243
+ Pass `--clear` to remove the declaration.
2244
+ """
2245
+ if clear and claim_id:
2246
+ raise click.UsageError("pass either CLAIM_ID or --clear, not both")
2247
+ if not clear and not claim_id:
2248
+ raise click.UsageError("CLAIM_ID is required (or pass --clear)")
2249
+
2250
+ xml, revision = _get_xml_with_revision(ctx, filename)
2251
+ data = ctx.client.post(
2252
+ "/reasoning/edit",
2253
+ json={
2254
+ "xml": xml,
2255
+ "op": "set_conclusion",
2256
+ "target_id": claim_id,
2257
+ "clear": clear,
2258
+ },
2259
+ )
2260
+ _persist_edit_result(ctx, filename, data, revision)
2261
+
2262
+
2263
+ @reasoning.command("set-key-question", cls=FileFirstWriteCommand)
2264
+ @click.argument("filename")
2265
+ @click.argument("claim_id", required=False)
2266
+ @click.option(
2267
+ "--clear",
2268
+ is_flag=True,
2269
+ default=False,
2270
+ help="Remove the declaration. The header then falls back to the `answers` "
2271
+ "edge off the conclusion, then to the document's only question, and "
2272
+ "otherwise shows nothing.",
2273
+ )
2274
+ @click.option(
2275
+ "--needs-review",
2276
+ "needs_review",
2277
+ is_flag=True,
2278
+ default=False,
2279
+ help="Mark the question as inferred rather than stated, so it renders as "
2280
+ "provisional until someone confirms it. Declaring without this flag "
2281
+ "clears it.",
2282
+ )
2283
+ @pass_ctx
2284
+ def reasoning_set_key_question_cmd(
2285
+ ctx: Ctx,
2286
+ filename: str,
2287
+ claim_id: str | None,
2288
+ clear: bool,
2289
+ needs_review: bool,
2290
+ ) -> None:
2291
+ """Declare which question FILENAME exists to answer.
2292
+
2293
+ Writes `<Reasoning keyQuestionRef="CLAIM_ID">`. Readers show that question
2294
+ in the Reasoning header instead of taking whichever `kind="question"` Claim
2295
+ happens to be authored first — which meant reordering, importing or
2296
+ deleting a question silently changed what the document appeared to be
2297
+ asking, in 15 of this repo's 34 reasoning-bearing documents.
2298
+
2299
+ This does not create a Claim. Author it with `add-claim --kind question`
2300
+ first, then point the section at it, and record the pairing with
2301
+ `add-argument --from-id <conclusion> --rel answers --to-id CLAIM_ID`.
2302
+
2303
+ Pass `--clear` to remove the declaration.
2304
+ """
2305
+ if clear and claim_id:
2306
+ raise click.UsageError("pass either CLAIM_ID or --clear, not both")
2307
+ if not clear and not claim_id:
2308
+ raise click.UsageError("CLAIM_ID is required (or pass --clear)")
2309
+ if clear and needs_review:
2310
+ raise click.UsageError("--needs-review makes no sense with --clear")
2311
+
2312
+ xml, revision = _get_xml_with_revision(ctx, filename)
2313
+ data = ctx.client.post(
2314
+ "/reasoning/edit",
2315
+ json={
2316
+ "xml": xml,
2317
+ "op": "set_key_question",
2318
+ "target_id": claim_id,
2319
+ "clear": clear,
2320
+ "needs_review": needs_review,
2321
+ },
2322
+ )
2323
+ _persist_edit_result(ctx, filename, data, revision)
2324
+
2325
+
2326
+ @reasoning.command("set-exhibit", cls=FileFirstWriteCommand)
2327
+ @click.argument("filename")
2328
+ @click.argument("exhibit_ref", required=False)
2329
+ @click.option(
2330
+ "--clear",
2331
+ is_flag=True,
2332
+ default=False,
2333
+ help="Remove the declaration. The conclusion then renders full width.",
2334
+ )
2335
+ @pass_ctx
2336
+ def reasoning_set_exhibit_cmd(
2337
+ ctx: Ctx,
2338
+ filename: str,
2339
+ exhibit_ref: str | None,
2340
+ clear: bool,
2341
+ ) -> None:
2342
+ """Declare which deck or slide to show beside FILENAME's conclusion.
2343
+
2344
+ Writes `<Reasoning exhibitRef="EXHIBIT_REF">`, where EXHIBIT_REF is a
2345
+ `deepcell:` URI — `deepcell:deck/executive` or
2346
+ `deepcell:slide/executive.overview`.
2347
+
2348
+ A URI rather than a bare id, unlike its `set-conclusion` and
2349
+ `set-key-question` neighbours: those name Claims inside `<Reasoning>`, and
2350
+ this one points at another surface.
2351
+
2352
+ Optional, and it degrades. No deck in the file means no slot; a reference
2353
+ that does not resolve renders the conclusion full width and lints, because
2354
+ a broken link never breaks a render.
2355
+
2356
+ Pass `--clear` to remove the declaration.
2357
+ """
2358
+ if clear and exhibit_ref:
2359
+ raise click.UsageError("pass either EXHIBIT_REF or --clear, not both")
2360
+ if not clear and not exhibit_ref:
2361
+ raise click.UsageError("EXHIBIT_REF is required (or pass --clear)")
2362
+
2363
+ xml, revision = _get_xml_with_revision(ctx, filename)
2364
+ data = ctx.client.post(
2365
+ "/reasoning/edit",
2366
+ json={
2367
+ "xml": xml,
2368
+ "op": "set_exhibit",
2369
+ "exhibit_ref": exhibit_ref,
2370
+ "clear": clear,
2371
+ },
2372
+ )
2373
+ _persist_edit_result(ctx, filename, data, revision)
2374
+
2375
+
2376
+ #: Built from the Choice so the sentence can never list fewer rels than the
2377
+ #: flag accepts — it did, and `answers` was the one it left out.
2378
+ _ADD_ARGUMENT_HELP = (
2379
+ "Add a new <Argument> edge to FILENAME's <Reasoning> section.\n\n"
2380
+ "`--from-id` and `--to-id` are required; `--rel` picks the edge type ("
2381
+ + ", ".join(_ARGUMENT_RELS)
2382
+ + ")."
2383
+ )
2384
+
2385
+
2386
+ @reasoning.command("add-argument", cls=WriteCommand, help=_ADD_ARGUMENT_HELP)
2387
+ @click.argument("filename")
2388
+ @click.option("--from-id", "from_id", required=True, help="Source node id.")
2389
+ @click.option("--to-id", "to_id", required=True, help="Target node id.")
2390
+ @click.option(
2391
+ "--rel",
2392
+ required=True,
2393
+ type=click.Choice(_ARGUMENT_RELS),
2394
+ help="How --from-id relates to --to-id: supports / refutes (evidential), "
2395
+ "depends_on (falsifying the target falsifies the source), derives_from "
2396
+ "(computed or inferred from it), answers (this conclusion settles that "
2397
+ "question), variant_of (your view vs a consensus claim), supersedes "
2398
+ "(replaces an older node), contradicts, references. "
2399
+ "See `deepcell ref rel`.",
2400
+ )
2401
+ @click.option("--id", "arg_id", default=None, help="Argument id (auto-generated if omitted).")
2402
+ @click.option(
2403
+ "--weight",
2404
+ default=None,
2405
+ type=float,
2406
+ help="How much this edge carries, 0-1 (optional).",
2407
+ )
2408
+ @click.option("--body", default=None, help="<Body> text.")
2409
+ @click.option(
2410
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
2411
+ help=_text_file_help("<Body>", "body"),
2412
+ )
2413
+ @click.option(
2414
+ "--body-notation", "body_notation", default=None,
2415
+ type=click.Choice(_BODY_NOTATIONS),
2416
+ help=_BODY_NOTATION_HELP,
2417
+ )
2418
+ @click.option("--status", default=None, help="Argument lifecycle status (active/superseded/revoked).")
2419
+ @pass_ctx
2420
+ def reasoning_add_argument_cmd(
2421
+ ctx: Ctx,
2422
+ filename: str,
2423
+ from_id: str,
2424
+ to_id: str,
2425
+ rel: str,
2426
+ arg_id: str | None,
2427
+ weight: float | None,
2428
+ body: str | None,
2429
+ body_file: str | None,
2430
+ body_notation: str | None,
2431
+ status: str | None,
2432
+ ) -> None:
2433
+ # Help text is `_ADD_ARGUMENT_HELP`, generated from `_ARGUMENT_RELS`.
2434
+ body = _resolve_text_option(body, body_file, "body")
2435
+ xml, revision = _get_xml_with_revision(ctx, filename)
2436
+ payload = {
2437
+ k: v for k, v in {
2438
+ "from": from_id,
2439
+ "to": to_id,
2440
+ "rel": rel,
2441
+ "id": arg_id,
2442
+ "weight": weight,
2443
+ "body_text": body,
2444
+ "body_notation": body_notation,
2445
+ "status": status,
2446
+ }.items() if v is not None
2447
+ }
2448
+ data = ctx.client.post(
2449
+ "/reasoning/edit",
2450
+ json={"xml": xml, "op": "add_argument", "payload": payload},
2451
+ )
2452
+ _persist_edit_result(ctx, filename, data, revision)
2453
+
2454
+
2455
+ # ---------------------------------------------------------------------------
2456
+ # Supersede + delete + the remaining updates.
2457
+ #
2458
+ # `deepcell guide revise/reasoning` teaches "never delete, always supersede" and says
2459
+ # the supersede op does it mechanically — but no CLI command emitted it, so the
2460
+ # workflow the guide prescribes was unreachable from the CLI and from MCP.
2461
+ # ---------------------------------------------------------------------------
2462
+
2463
+
2464
+ def _supersede(
2465
+ ctx: Ctx, filename: str, op: str, target_id: str, new_node_key: str,
2466
+ new_node: dict[str, Any],
2467
+ ) -> None:
2468
+ """POST a supersede op: flip the old node to superseded, add the new one,
2469
+ and emit the <Argument rel="supersedes"> edge between them."""
2470
+ xml, revision = _get_xml_with_revision(ctx, filename)
2471
+ data = ctx.client.post(
2472
+ "/reasoning/edit",
2473
+ json={"xml": xml, "op": op, "target_id": target_id, new_node_key: new_node},
2474
+ )
2475
+ _persist_edit_result(ctx, filename, data, revision)
2476
+
2477
+
2478
+ @reasoning.command("supersede-claim", cls=FileFirstWriteCommand)
2479
+ @click.argument("filename")
2480
+ @click.argument("claim_id")
2481
+ @click.option("--id", "new_id", required=True, help="Id of the replacement Claim.")
2482
+ @click.option(
2483
+ "--label", default=None,
2484
+ help=f"<Label> text of the replacement. {_LABEL_HELP}",
2485
+ )
2486
+ @click.option(
2487
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
2488
+ help=_text_file_help("replacement <Label>", "label"),
2489
+ )
2490
+ @click.option("--body", default=None, help="<Body> text of the replacement.")
2491
+ @click.option(
2492
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
2493
+ help=_text_file_help("replacement <Body>", "body"),
2494
+ )
2495
+ @click.option(
2496
+ "--body-notation", "body_notation", default=None,
2497
+ type=click.Choice(_BODY_NOTATIONS),
2498
+ help=_BODY_NOTATION_HELP,
2499
+ )
2500
+ @click.option(
2501
+ "--kind",
2502
+ default=None,
2503
+ type=click.Choice(_CLAIM_KINDS),
2504
+ help="Kind of the replacement (defaults to the superseded claim's kind).",
2505
+ )
2506
+ @click.option("--item-refs", "item_refs", default=None, help="Comma-separated item ids.")
2507
+ @click.option("--context-refs", "context_refs", default=None, help="Comma-separated context ids.")
2508
+ @click.option("--status-ref", "status_ref", default=None, help="Model Status dimension id.")
2509
+ @click.option("--calc-ref", "calc_ref", default=None, help="CalcDef id this claim is anchored to.")
2510
+ @click.option(
2511
+ "--confidence", default=None, type=float, help="Confidence in the replacement, 0-1."
2512
+ )
2513
+ @click.option(
2514
+ "--strength", default=None, type=click.Choice(_CLAIM_STRENGTHS),
2515
+ help="How strongly the replacement is held, qualitatively. Not inherited "
2516
+ "from the superseded claim: a reassessment that reused the old strength "
2517
+ "would publish a judgement nobody made.",
2518
+ )
2519
+ @_narrow_anchor_options
2520
+ @pass_ctx
2521
+ def reasoning_supersede_claim_cmd(
2522
+ ctx: Ctx,
2523
+ filename: str,
2524
+ claim_id: str,
2525
+ new_id: str,
2526
+ label: str | None,
2527
+ label_file: str | None,
2528
+ body: str | None,
2529
+ body_file: str | None,
2530
+ body_notation: str | None,
2531
+ kind: str | None,
2532
+ item_refs: str | None,
2533
+ context_refs: str | None,
2534
+ scenario_ref: str | None,
2535
+ custom_dimensions: str | None,
2536
+ status_ref: str | None,
2537
+ calc_ref: str | None,
2538
+ confidence: float | None,
2539
+ strength: str | None,
2540
+ ) -> None:
2541
+ """Replace CLAIM_ID with a new Claim, keeping the old one as history.
2542
+
2543
+ This is the canonical way to change what a Claim asserts — a Claim is a
2544
+ dated position, and rewriting one in place destroys the record of what
2545
+ was believed when. In one atomic edit it flips CLAIM_ID's status to
2546
+ `superseded`, adds the new Claim, and links them with
2547
+ `<Argument rel="supersedes">`, so `deepcell claim history` can walk back.
2548
+
2549
+ The replacement INHERITS the old claim's anchors (--item-refs,
2550
+ --context-refs, --status-ref, --calc-ref, and scenario / custom
2551
+ dimensions) for any you do not pass; passing one overrides it.
2552
+
2553
+ Supersession does not move Arguments that touch the old Claim or the
2554
+ document's conclusionRef. After reassessment, add current edges to the
2555
+ replacement and run `reasoning set-conclusion` if the old Claim was the
2556
+ declared conclusion. See `deepcell guide revise/premise-change`.
2557
+
2558
+ \b
2559
+ deepcell reasoning supersede-claim model.deepcell t_margin_v1 \\
2560
+ --id t_margin_v2 --label "Gross margin reaches 72% by 2027"
2561
+ """
2562
+ _check_single_stdin(label_file, body_file)
2563
+ label = _resolve_text_option(label, label_file, "label", required=True)
2564
+ body = _resolve_text_option(body, body_file, "body")
2565
+ new_claim = {
2566
+ k: v for k, v in {
2567
+ "id": new_id,
2568
+ "kind": kind,
2569
+ "label_text": label,
2570
+ "body_text": body,
2571
+ "body_notation": body_notation,
2572
+ "itemRefs": item_refs,
2573
+ "contextRefs": context_refs,
2574
+ "scenarioRef": scenario_ref,
2575
+ "customDimensions": custom_dimensions,
2576
+ "statusRef": status_ref,
2577
+ "calcRef": calc_ref,
2578
+ "confidence": confidence,
2579
+ "strength": strength,
2580
+ }.items() if v is not None
2581
+ }
2582
+ _supersede(ctx, filename, "supersede_claim", claim_id, "new_claim", new_claim)
2583
+
2584
+
2585
+ @reasoning.command("supersede-assumption", cls=FileFirstWriteCommand)
2586
+ @click.argument("filename")
2587
+ @click.argument("assumption_id")
2588
+ @click.option("--id", "new_id", required=True, help="Id of the replacement Assumption.")
2589
+ @click.option(
2590
+ "--label", default=None,
2591
+ help=f"<Label> text of the replacement. {_LABEL_HELP}",
2592
+ )
2593
+ @click.option(
2594
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
2595
+ help=_text_file_help("replacement <Label>", "label"),
2596
+ )
2597
+ @click.option("--body", default=None, help="<Body> text of the replacement.")
2598
+ @click.option(
2599
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
2600
+ help=_text_file_help("replacement <Body>", "body"),
2601
+ )
2602
+ @click.option(
2603
+ "--body-notation", "body_notation", default=None,
2604
+ type=click.Choice(_BODY_NOTATIONS),
2605
+ help=_BODY_NOTATION_HELP,
2606
+ )
2607
+ @click.option("--item-refs", "item_refs", default=None, help="Comma-separated item ids.")
2608
+ @click.option("--status-ref", "status_ref", default=None, help="Model Status dimension id.")
2609
+ @click.option("--calc-ref", "calc_ref", default=None, help="CalcDef id this assumption drives.")
2610
+ @click.option(
2611
+ "--confidence", default=None, type=float, help="Confidence in the replacement, 0-1."
2612
+ )
2613
+ @click.option(
2614
+ "--status",
2615
+ default=None,
2616
+ type=click.Choice(_ASSUMPTION_STATUSES),
2617
+ help="Lifecycle status of the replacement.",
2618
+ )
2619
+ @click.option("--verified-at", "verified_at", default=None, help=_VERIFIED_AT_HELP)
2620
+ @click.option("--broken-at", "broken_at", default=None, help=_BROKEN_AT_HELP)
2621
+ @_coverage_anchor_options
2622
+ @pass_ctx
2623
+ def reasoning_supersede_assumption_cmd(
2624
+ ctx: Ctx,
2625
+ filename: str,
2626
+ assumption_id: str,
2627
+ new_id: str,
2628
+ label: str | None,
2629
+ label_file: str | None,
2630
+ body: str | None,
2631
+ body_file: str | None,
2632
+ body_notation: str | None,
2633
+ item_refs: str | None,
2634
+ context_refs: str | None,
2635
+ scenario_ref: str | None,
2636
+ custom_dimensions: str | None,
2637
+ status_ref: str | None,
2638
+ calc_ref: str | None,
2639
+ confidence: float | None,
2640
+ status: str | None,
2641
+ verified_at: str | None,
2642
+ broken_at: str | None,
2643
+ ) -> None:
2644
+ """Replace ASSUMPTION_ID with a new Assumption, keeping the old as history.
2645
+
2646
+ Same mechanics as `supersede-claim`: the old node's status becomes
2647
+ `superseded`, the new node is added, and a `supersedes` Argument links
2648
+ them. Every Claim that depended on the old assumption keeps its edge, so
2649
+ `deepcell assumption impact` still answers for the superseded node.
2650
+
2651
+ Supersession does not attach those Claims to the replacement. After
2652
+ reassessing each surviving Claim, add a new `depends_on` Argument to the
2653
+ replacement and keep the old edge as history. See
2654
+ `deepcell guide revise/premise-change`.
2655
+
2656
+ `--verified-at` / `--broken-at` land on the replacement in the same
2657
+ commit, so recording that the new premise was checked does not take a
2658
+ second `update-assumption`.
2659
+ """
2660
+ _check_single_stdin(label_file, body_file)
2661
+ label = _resolve_text_option(label, label_file, "label", required=True)
2662
+ body = _resolve_text_option(body, body_file, "body")
2663
+ new_assumption = {
2664
+ k: v for k, v in {
2665
+ "id": new_id,
2666
+ "label_text": label,
2667
+ "body_text": body,
2668
+ "body_notation": body_notation,
2669
+ "itemRefs": item_refs,
2670
+ "contextRefs": context_refs,
2671
+ "scenarioRef": scenario_ref,
2672
+ "customDimensions": custom_dimensions,
2673
+ "statusRef": status_ref,
2674
+ "calcRef": calc_ref,
2675
+ "confidence": confidence,
2676
+ "status": status,
2677
+ "verifiedAt": verified_at,
2678
+ "brokenAt": broken_at,
2679
+ }.items() if v is not None
2680
+ }
2681
+ _supersede(
2682
+ ctx, filename, "supersede_assumption", assumption_id,
2683
+ "new_assumption", new_assumption,
2684
+ )
2685
+
2686
+
2687
+ _CASCADE_HELP = (
2688
+ "Also remove every Argument touching the node (default). --no-cascade "
2689
+ "keeps them, and is refused with code=would_dangle unless you also pass "
2690
+ "--allow-dangling — a dangling edge lints, and is still addressable by "
2691
+ "its (from, rel, to) or its id, so it can be cleaned up later."
2692
+ )
2693
+
2694
+
2695
+ def _delete_reasoning_node(
2696
+ ctx: Ctx, filename: str, op: str, target_id: str | None,
2697
+ cascade: bool, allow_dangling: bool, address: dict | None = None,
2698
+ ) -> None:
2699
+ xml, revision = _get_xml_with_revision(ctx, filename)
2700
+ data = ctx.client.post(
2701
+ "/reasoning/edit",
2702
+ json={
2703
+ "xml": xml, "op": op, "target_id": target_id,
2704
+ "cascade": cascade, "allow_dangling": allow_dangling,
2705
+ **(address or {}),
2706
+ },
2707
+ )
2708
+ _persist_edit_result(ctx, filename, data, revision)
2709
+
2710
+
2711
+ def _edge_address(
2712
+ argument_id: str | None, from_id: str | None, edge_rel: str | None,
2713
+ to_id: str | None,
2714
+ ) -> dict | None:
2715
+ """The triple to send beside `target_id`, or a usage error.
2716
+
2717
+ Both addresses at once is not refused: they may disagree, and the backend
2718
+ resolves `target_id` first, so the triple would be ignored silently. Say
2719
+ so instead.
2720
+ """
2721
+ given = [v for v in (from_id, edge_rel, to_id) if v is not None]
2722
+ if argument_id and given:
2723
+ raise click.UsageError(
2724
+ "Give ARGUMENT_ID or the --from-id/--edge-rel/--to-id triple, not "
2725
+ "both — the id wins and the triple would be ignored."
2726
+ )
2727
+ if not argument_id and len(given) != 3:
2728
+ raise click.UsageError(
2729
+ "Name the edge: either ARGUMENT_ID, or all three of --from-id, "
2730
+ "--edge-rel and --to-id."
2731
+ )
2732
+ if not given:
2733
+ return None
2734
+ return {"from_id": from_id, "edge_rel": edge_rel, "to_id": to_id}
2735
+
2736
+
2737
+ def _delete_command(name: str, op: str, noun: str, guidance: str):
2738
+ """Build one `reasoning delete-<noun>` command — the four differ only in
2739
+ the op name and the wording, so the wiring is shared.
2740
+
2741
+ `help=` is passed to the decorator rather than set as `__doc__`
2742
+ afterwards: Click reads the docstring when the command is constructed, so
2743
+ a later assignment leaves the command with no help at all.
2744
+ """
2745
+ @reasoning.command(
2746
+ name,
2747
+ cls=FileFirstWriteCommand,
2748
+ help=(
2749
+ f"Delete the {noun} NODE_ID from FILENAME's <Reasoning> section."
2750
+ f"\n\n{guidance}"
2751
+ ),
2752
+ )
2753
+ @click.argument("filename")
2754
+ @click.argument("node_id")
2755
+ @click.option("--cascade/--no-cascade", default=True, help=_CASCADE_HELP)
2756
+ @click.option(
2757
+ "--allow-dangling",
2758
+ "allow_dangling",
2759
+ is_flag=True,
2760
+ default=False,
2761
+ help="With --no-cascade, permit the delete even though it orphans Arguments.",
2762
+ )
2763
+ @pass_ctx
2764
+ def _cmd(
2765
+ ctx: Ctx, filename: str, node_id: str, cascade: bool, allow_dangling: bool
2766
+ ) -> None:
2767
+ _delete_reasoning_node(ctx, filename, op, node_id, cascade, allow_dangling)
2768
+
2769
+ return _cmd
2770
+
2771
+
2772
+ reasoning_delete_claim_cmd = _delete_command(
2773
+ "delete-claim", "delete_claim", "<Claim>",
2774
+ "Prefer `deepcell reasoning supersede-claim`: a Claim is a dated "
2775
+ "position, and deleting one erases the record that it was ever held. "
2776
+ "Delete is for claims authored in error, not for claims that turned "
2777
+ "out wrong — mark those falsified with `update-claim --status "
2778
+ "falsified`.",
2779
+ )
2780
+ reasoning_delete_assumption_cmd = _delete_command(
2781
+ "delete-assumption", "delete_assumption", "<Assumption>",
2782
+ "Prefer `deepcell reasoning supersede-assumption`. Check what depends "
2783
+ "on it first with `deepcell assumption impact`.",
2784
+ )
2785
+ reasoning_delete_evidence_cmd = _delete_command(
2786
+ "delete-evidence", "delete_evidence", "<Evidence>",
2787
+ "Removes the cited support itself, so any Claim that rested on it is "
2788
+ "left asserting the same thing with less behind it.",
2789
+ )
2790
+
2791
+
2792
+ @reasoning.command("delete-argument", cls=FileFirstWriteCommand)
2793
+ @click.argument("filename")
2794
+ @click.argument("argument_id", required=False)
2795
+ @click.option("--from-id", "from_id", default=None, help="Address: the edge's @from.")
2796
+ @click.option(
2797
+ "--edge-rel", "edge_rel", default=None, type=click.Choice(_ARGUMENT_RELS),
2798
+ help="Address: the edge's @rel.",
2799
+ )
2800
+ @click.option("--to-id", "to_id", default=None, help="Address: the edge's @to.")
2801
+ @click.option("--cascade/--no-cascade", default=True, help=_CASCADE_HELP)
2802
+ @click.option(
2803
+ "--allow-dangling",
2804
+ "allow_dangling",
2805
+ is_flag=True,
2806
+ default=False,
2807
+ help="With --no-cascade, permit the delete even though it orphans Arguments.",
2808
+ )
2809
+ @pass_ctx
2810
+ def reasoning_delete_argument_cmd(
2811
+ ctx: Ctx,
2812
+ filename: str,
2813
+ argument_id: str | None,
2814
+ from_id: str | None,
2815
+ edge_rel: str | None,
2816
+ to_id: str | None,
2817
+ cascade: bool,
2818
+ allow_dangling: bool,
2819
+ ) -> None:
2820
+ """Delete one <Argument> edge from FILENAME's <Reasoning> section.
2821
+
2822
+ Removes one edge; both nodes it connected stay. --cascade has no effect
2823
+ here, since an Argument has no inbound edges of its own.
2824
+
2825
+ An Argument has two permanent addresses: its id, which is derived from
2826
+ the edge as arg_{from}__{rel}__{to} and shown by `describe` and the
2827
+ Reasoning panel, and the (from, rel, to) triple it was added with. Give
2828
+ ARGUMENT_ID, or all three of --from-id, --edge-rel and --to-id. Edges
2829
+ written before ids were required have no @id on disk and resolve by
2830
+ either address without the file being rewritten.
2831
+ """
2832
+ address = _edge_address(argument_id, from_id, edge_rel, to_id)
2833
+ _delete_reasoning_node(
2834
+ ctx, filename, "delete_argument", argument_id, cascade, allow_dangling,
2835
+ address=address,
2836
+ )
2837
+
2838
+
2839
+ @reasoning.command("update-evidence", cls=FileFirstWriteCommand)
2840
+ @click.argument("filename")
2841
+ @click.argument("evidence_id")
2842
+ @click.option(
2843
+ "--label", default=None,
2844
+ help=f"Replacement <Label> text. {_LABEL_HELP}",
2845
+ )
2846
+ @click.option(
2847
+ "--label-file", "label_file", type=_TEXT_FILE, default=None,
2848
+ help=_text_file_help("replacement <Label>", "label"),
2849
+ )
2850
+ @click.option("--body", default=None, help="Replacement <Body> text.")
2851
+ @click.option(
2852
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
2853
+ help=_text_file_help("replacement <Body>", "body"),
2854
+ )
2855
+ @click.option("--source", default=None, help="Replacement @source (where the evidence came from).")
2856
+ @click.option("--url", default=None, help="Replacement @url.")
2857
+ @click.option("--as-of", "as_of", default=None, help="Replacement @asOf date (ISO).")
2858
+ @click.option("--item-refs", "item_refs", default=None, help="Comma-separated item ids.")
2859
+ @click.option("--context-refs", "context_refs", default=None, help="Comma-separated context ids.")
2860
+ @click.option(
2861
+ "--status-ref", "status_ref", default=None,
2862
+ help="Model Status dimension id (e.g. actual / projected) the evidence "
2863
+ "speaks to.",
2864
+ )
2865
+ @_narrow_anchor_options
2866
+ @pass_ctx
2867
+ def reasoning_update_evidence_cmd(
2868
+ ctx: Ctx,
2869
+ filename: str,
2870
+ evidence_id: str,
2871
+ label: str | None,
2872
+ label_file: str | None,
2873
+ body: str | None,
2874
+ body_file: str | None,
2875
+ source: str | None,
2876
+ url: str | None,
2877
+ as_of: str | None,
2878
+ item_refs: str | None,
2879
+ context_refs: str | None,
2880
+ status_ref: str | None,
2881
+ scenario_ref: str | None,
2882
+ custom_dimensions: str | None,
2883
+ ) -> None:
2884
+ """Patch an <Evidence> node; only the attributes you pass are changed.
2885
+
2886
+ Use it to retrofit anchors onto evidence added before the items existed,
2887
+ or to correct a source URL without losing the Arguments that cite it.
2888
+ """
2889
+ _check_single_stdin(label_file, body_file)
2890
+ label = _resolve_text_option(label, label_file, "label")
2891
+ body = _resolve_text_option(body, body_file, "body")
2892
+ payload = {
2893
+ k: v for k, v in {
2894
+ "label_text": label,
2895
+ "body_text": body,
2896
+ "source": source,
2897
+ "url": url,
2898
+ "asOf": as_of,
2899
+ "itemRefs": item_refs,
2900
+ "contextRefs": context_refs,
2901
+ "statusRef": status_ref,
2902
+ "scenarioRef": scenario_ref,
2903
+ "customDimensions": custom_dimensions,
2904
+ }.items() if v is not None
2905
+ }
2906
+ _update_reasoning_node(ctx, filename, "update_evidence", evidence_id, payload)
2907
+
2908
+
2909
+ @reasoning.command("update-argument", cls=FileFirstWriteCommand)
2910
+ @click.argument("filename")
2911
+ @click.argument("argument_id", required=False)
2912
+ @click.option("--from-id", "from_id", default=None, help="Address: the edge's @from.")
2913
+ @click.option(
2914
+ "--edge-rel", "edge_rel", default=None, type=click.Choice(_ARGUMENT_RELS),
2915
+ help="Address: the @rel the edge has now. --rel is what to change it to.",
2916
+ )
2917
+ @click.option("--to-id", "to_id", default=None, help="Address: the edge's @to.")
2918
+ @click.option(
2919
+ "--rel",
2920
+ default=None,
2921
+ type=click.Choice(_ARGUMENT_RELS),
2922
+ help="Reclassify the edge — see `deepcell reasoning add-argument --help`.",
2923
+ )
2924
+ @click.option("--weight", default=None, type=float, help="How much this edge carries, 0-1.")
2925
+ @click.option("--body", default=None, help="Replacement <Body> text.")
2926
+ @click.option(
2927
+ "--body-file", "body_file", type=_TEXT_FILE, default=None,
2928
+ help=_text_file_help("replacement <Body>", "body"),
2929
+ )
2930
+ @click.option(
2931
+ "--body-notation", "body_notation", default=None,
2932
+ type=click.Choice(_BODY_NOTATIONS),
2933
+ help=_BODY_NOTATION_HELP,
2934
+ )
2935
+ @click.option(
2936
+ "--status",
2937
+ default=None,
2938
+ help="Argument lifecycle status (active / superseded / revoked).",
2939
+ )
2940
+ @pass_ctx
2941
+ def reasoning_update_argument_cmd(
2942
+ ctx: Ctx,
2943
+ filename: str,
2944
+ argument_id: str | None,
2945
+ from_id: str | None,
2946
+ edge_rel: str | None,
2947
+ to_id: str | None,
2948
+ rel: str | None,
2949
+ weight: float | None,
2950
+ body: str | None,
2951
+ body_file: str | None,
2952
+ body_notation: str | None,
2953
+ status: str | None,
2954
+ ) -> None:
2955
+ """Patch an <Argument> edge; only the attributes you pass are changed.
2956
+
2957
+ An Argument has two permanent addresses: its id, which is derived from
2958
+ the edge as arg_{from}__{rel}__{to} and shown by `describe` and the
2959
+ Reasoning panel, and the (from, rel, to) triple it was added with. Give
2960
+ ARGUMENT_ID, or all three of --from-id, --edge-rel and --to-id. Edges
2961
+ written before ids were required have no @id on disk and resolve by
2962
+ either address without the file being rewritten.
2963
+
2964
+ Reclassifying is --rel; the address is --edge-rel. Recasting a reason
2965
+ as a condition is therefore one command:
2966
+ `--from-id r --edge-rel supports --to-id t --rel depends_on`.
2967
+ """
2968
+ address = _edge_address(argument_id, from_id, edge_rel, to_id)
2969
+ body = _resolve_text_option(body, body_file, "body")
2970
+ payload = {
2971
+ k: v for k, v in {
2972
+ "rel": rel,
2973
+ "weight": weight,
2974
+ "body_text": body,
2975
+ "body_notation": body_notation,
2976
+ "status": status,
2977
+ }.items() if v is not None
2978
+ }
2979
+ _update_reasoning_node(
2980
+ ctx, filename, "update_argument", argument_id, payload, address=address,
2981
+ )