arkaos 4.49.0 → 5.1.0

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 (43) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/core/governance/harness_scanner.py +108 -30
  4. package/core/governance/harness_scanner_cli.py +220 -8
  5. package/core/harness/remediation.py +644 -0
  6. package/harness/codex/AGENTS.md +1 -1
  7. package/harness/copilot/copilot-instructions.md +1 -1
  8. package/harness/cursor/rules/arkaos.mdc +2 -2
  9. package/harness/gemini/GEMINI.md +1 -1
  10. package/harness/opencode/AGENTS.md +1 -1
  11. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  12. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  13. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  14. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  15. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  16. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  17. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  18. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  19. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  20. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  21. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  22. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  23. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  24. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  25. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  26. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  27. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  28. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  29. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  30. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  31. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  32. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  33. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  34. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  35. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  36. package/harness/zed/.rules +1 -1
  37. package/installer/claude-md.js +242 -0
  38. package/installer/cli.js +9 -3
  39. package/installer/index.js +10 -8
  40. package/installer/update.js +12 -7
  41. package/knowledge/skills-manifest.json +1 -1
  42. package/package.json +1 -1
  43. package/pyproject.toml +1 -1
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.49.0 — 89 agents, 17 departments, 333 skills, 298 commands, 19 ADRs.
3
+ > v5.1.0 — 89 agents, 17 departments, 333 skills, 298 commands, 19 ADRs.
4
4
  > One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
5
5
 
6
6
  ## What it is
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.49.0
1
+ 5.1.0
@@ -19,10 +19,12 @@ with a letter grade; it prints nothing, exits nothing, and touches
19
19
  nothing. The ``--fix`` path is a later slice and must be able to trust
20
20
  that a scan never mutated what it measured.
21
21
 
22
- Never raises on hostile input. A settings file that is truncated,
22
+ Never raises on hostile CONTENT. A settings file that is truncated,
23
23
  binary, or full of nulls is a FINDING, not a traceback — a scanner that
24
24
  dies on the config it was pointed at reports nothing at all, which is
25
- the worst possible outcome for a security tool.
25
+ the worst possible outcome for a security tool. A config it could not
26
+ READ is the opposite case and raises deliberately, so the caller can
27
+ refuse it by name instead of grading what it never opened; see `scan`.
26
28
 
27
29
  Engine behind ``npx arkaos shield`` and the ``doctor`` advisory section.
28
30
  """
@@ -39,6 +41,34 @@ from pathlib import Path
39
41
  _MAX_FILE_BYTES = 2 * 1024 * 1024
40
42
 
41
43
 
44
+ def is_readable_file(path: Path) -> bool:
45
+ """Is `path` a regular file — and RAISE when we could not find out.
46
+
47
+ `Path.is_file()` cannot carry this contract across interpreters.
48
+ Python 3.14 reimplemented it to delegate to `os.path.isfile()`,
49
+ which swallows every OSError and answers False, so an unreadable
50
+ config read as "not there": the scanner counted zero files and
51
+ printed "Grade A (100/100) — nothing to report." for a directory it
52
+ never opened, on exactly the fresh machines and CI containers where
53
+ no venv exists and an ambient python3 is selected. The root and the
54
+ config file reach this call as `root / name`, and their two refusals
55
+ are built on this raise. `_check_hook_script` repeats the same
56
+ raising stat but renders EACCES as a HIGH `hook-script-unreadable`
57
+ finding, which grades B and exits 0 — the same discipline, and
58
+ deliberately NOT a refusal, since a refusal here means exit 2 (QG C3
59
+ r9 and r10, Francisca, reproduced side by side on 3.13 and 3.14;
60
+ vocabulary corrected at r15 by Marta, who executed the exit code).
61
+
62
+ ENOENT and ENOTDIR mean the file is absent, which is a real answer.
63
+ Every other errno — EACCES, EPERM, ELOOP — means we could not look,
64
+ which is not an answer and must never be graded.
65
+ """
66
+ try:
67
+ return stat.S_ISREG(path.stat().st_mode)
68
+ except (FileNotFoundError, NotADirectoryError):
69
+ return False
70
+
71
+
42
72
  class Severity(StrEnum):
43
73
  """How much a finding costs. Declaration order is report order."""
44
74
 
@@ -421,33 +451,69 @@ def _check_hook_command(command: str, where: str) -> list[Finding]:
421
451
 
422
452
 
423
453
  def _check_hook_script(command: str, where: str) -> list[Finding]:
424
- """The file the hook executes — does it exist, can anyone rewrite it?"""
454
+ """The file the hook executes — does it exist, can anyone rewrite it?
455
+
456
+ ONE stat, three named outcomes. `path.exists()` could not carry this:
457
+ it raises on 3.13 and answers False on 3.14, and the raise reached
458
+ the per-file backstop, which throws away every finding already found
459
+ for that settings file and leaves one LOW `scanner-error`. Measured
460
+ on one config holding a CRITICAL dangerous-allow plus a hook whose
461
+ target sat in a mode-000 directory: 3.14 graded F (51/100, 3
462
+ findings, exit 2), 3.13 graded **A (98/100), exit 0**, rules
463
+ `['scanner-error']` — and that is the JSON `installer/doctor.js`
464
+ reads. One unstat-able hook target laundered every CRITICAL in the
465
+ file (QG C3 r10, Francisca B1, reproduced side by side).
466
+ """
425
467
  path = _script_path(command)
426
468
  if path is None:
427
469
  return []
428
- if not path.exists():
429
- return [Finding(
430
- rule="hook-script-missing",
431
- severity=Severity.HIGH,
432
- where=where,
433
- detail=f"the hook points at {path}, which does not exist — the "
434
- f"hook silently does nothing.",
435
- fix="Restore the script, or remove the hook. If it is an "
436
- "ArkaOS hook, `npx arkaos install --force` reinstalls it.",
437
- )]
438
- if _is_group_or_world_writable(path):
439
- return [Finding(
440
- rule="hook-world-writable",
441
- severity=Severity.HIGH,
442
- where=where,
443
- detail=f"{path} is writable by group or others — any local "
444
- f"process can rewrite what the agent executes on every "
445
- f"tool call.",
446
- fix=f"chmod go-w {path}",
447
- )]
470
+ try:
471
+ mode = path.stat().st_mode
472
+ except (FileNotFoundError, NotADirectoryError):
473
+ return [_hook_script_missing(path, where)]
474
+ except OSError as exc:
475
+ return [_hook_script_unreadable(path, where, exc)]
476
+ if _is_group_or_world_writable(mode):
477
+ return [_hook_world_writable(path, where)]
448
478
  return []
449
479
 
450
480
 
481
+ def _hook_script_missing(path: Path, where: str) -> Finding:
482
+ return Finding(
483
+ rule="hook-script-missing",
484
+ severity=Severity.HIGH,
485
+ where=where,
486
+ detail=f"the hook points at {path}, which does not exist — the "
487
+ f"hook silently does nothing.",
488
+ fix="Restore the script, or remove the hook. If it is an "
489
+ "ArkaOS hook, `npx arkaos install --force` reinstalls it.",
490
+ )
491
+
492
+
493
+ def _hook_script_unreadable(path: Path, where: str, exc: OSError) -> Finding:
494
+ return Finding(
495
+ rule="hook-script-unreadable",
496
+ severity=Severity.HIGH,
497
+ where=where,
498
+ detail=f"the hook points at {path}, which could not be read "
499
+ f"({exc.strerror}) — its permissions and its contents are "
500
+ f"UNAUDITED. Treat as unaudited, not as clean.",
501
+ fix=f"Make {path} readable by the account that runs the agent, or "
502
+ f"point the hook somewhere auditable.",
503
+ )
504
+
505
+
506
+ def _hook_world_writable(path: Path, where: str) -> Finding:
507
+ return Finding(
508
+ rule="hook-world-writable",
509
+ severity=Severity.HIGH,
510
+ where=where,
511
+ detail=f"{path} is writable by group or others — any local process "
512
+ f"can rewrite what the agent executes on every tool call.",
513
+ fix=f"chmod go-w {path}",
514
+ )
515
+
516
+
451
517
  def _script_path(command: str) -> Path | None:
452
518
  """The leading absolute path of a hook command, when it has one.
453
519
 
@@ -465,11 +531,13 @@ def _script_path(command: str) -> Path | None:
465
531
  return Path(first)
466
532
 
467
533
 
468
- def _is_group_or_world_writable(path: Path) -> bool:
469
- try:
470
- mode = path.stat().st_mode
471
- except OSError:
472
- return False
534
+ def _is_group_or_world_writable(mode: int) -> bool:
535
+ """Mode bits only. The stat that produced them owns the error path.
536
+
537
+ This used to stat the file itself and swallow OSError into False —
538
+ "we could not tell" answering "safe", the exact direction a security
539
+ check must never take (QG C3 r10, Francisca B1).
540
+ """
473
541
  return bool(mode & (stat.S_IWGRP | stat.S_IWOTH))
474
542
 
475
543
 
@@ -755,7 +823,17 @@ def _safe_scan_file(
755
823
 
756
824
 
757
825
  def scan(root: Path) -> ScanReport:
758
- """Scan a harness config tree. Read-only. Never raises."""
826
+ """Scan a harness config tree. Read-only.
827
+
828
+ RAISES, deliberately, when a config cannot be READ: an unreadable
829
+ root or candidate propagates its OSError so the caller can refuse it
830
+ by name. Callers must use `harness_scanner_cli._safe_scan`, which is
831
+ built to catch exactly that. Per-FILE failures do not raise — they
832
+ are absorbed by `_safe_scan_file` into a `scanner-error` finding, so
833
+ one malformed config cannot end the scan (QG C3 r10, Francisca M2:
834
+ "Never raises" became false when the refusal family was built on the
835
+ raise, and the two docstrings said opposite things).
836
+ """
759
837
  try:
760
838
  root = Path(root).expanduser()
761
839
  except (RuntimeError, ValueError):
@@ -769,7 +847,7 @@ def scan(root: Path) -> ScanReport:
769
847
  for names, scanner in groups:
770
848
  for name in names:
771
849
  path = root / name
772
- if path.is_file():
850
+ if is_readable_file(path):
773
851
  report.files_scanned += 1
774
852
  report.findings.extend(_safe_scan_file(scanner, path, name))
775
853
  report.findings.sort(key=lambda f: (-PENALTY[f.severity], f.rule))
@@ -8,14 +8,24 @@ permissions themselves live in the user settings. A tool that reports a
8
8
  clean bill of health because it only looked at one of them is worse than
9
9
  no tool.
10
10
 
11
- Exit codes: 0 = grade A/B, 1 = grade C/D, 2 = grade F or a CRITICAL
12
- finding. CI can gate on it.
11
+ Exit codes: 0 = grade A/B, 1 = grade C/D, 2 = grade F, a CRITICAL
12
+ finding, or a REFUSAL. A refusal is a root we could not read: any root
13
+ the operator NAMED, every root when none was readable, or ANY root —
14
+ the defaults included — that raised while we scanned it (mode 000, a
15
+ root-owned config, a container uid mismatch). Only a MISSING default
16
+ root is exempt: a fresh machine has no ``~/.claude``, so it is noted on
17
+ stderr and the readable roots set the code; failing there would break
18
+ every CI container. CI can gate on the exit code. A refusal is never a
19
+ pass: a config we never opened cannot be graded, and saying "nothing to
20
+ report" about it would be the clean-looking run this tool exists to
21
+ prevent.
13
22
  """
14
23
 
15
24
  from __future__ import annotations
16
25
 
17
26
  import argparse
18
27
  import json
28
+ import stat
19
29
  import sys
20
30
  from pathlib import Path
21
31
 
@@ -36,8 +46,17 @@ _ICON = {
36
46
 
37
47
 
38
48
  def merge(reports: list[ScanReport]) -> ScanReport:
39
- """One grade for the operator, not one per file they must add up."""
40
- merged = ScanReport(root=reports[0].root if reports else Path("."))
49
+ """One grade for the operator, not one per file they must add up.
50
+
51
+ Merging nothing raises. An empty list produced a grade-A 100/100
52
+ report, so every caller that forgot to check got a clean bill of
53
+ health for a scan that never happened — twice in this module, and
54
+ the next caller would inherit it (QG C3 r7, Francisca M1). Grading
55
+ nothing is a programming error, not a passing grade.
56
+ """
57
+ if not reports:
58
+ raise ValueError("merge() of no reports: nothing was scanned")
59
+ merged = ScanReport(root=reports[0].root)
41
60
  for report in reports:
42
61
  merged.files_scanned += report.files_scanned
43
62
  for finding in report.findings:
@@ -98,7 +117,69 @@ def _roots(args: argparse.Namespace) -> list[Path]:
98
117
  return [Path.home() / ".claude", Path.cwd()]
99
118
 
100
119
 
101
- def main(argv: list[str] | None = None) -> int:
120
+ def _partition_roots(
121
+ args: argparse.Namespace,
122
+ ) -> tuple[list[Path], list[Path], bool]:
123
+ """Split the requested roots, and say whether the OPERATOR named them.
124
+
125
+ Provenance travels with the partition rather than being re-derived
126
+ from ``args.path`` at each use: the wording and the exit code both
127
+ depend on it, and two derivations five lines apart is the structural
128
+ precondition of every defect this module produced across six rounds
129
+ (QG C3 r7, Francisca M3).
130
+ """
131
+ requested = _roots(args)
132
+ readable = [root for root in requested if _looks_like_dir(root)]
133
+ unreadable = [root for root in requested if root not in readable]
134
+ return readable, unreadable, bool(args.path)
135
+
136
+
137
+ def _looks_like_dir(root: Path) -> bool:
138
+ """Is `root` a directory we should try to scan?
139
+
140
+ "We could not tell" must answer YES, so the root reaches `_safe_scan`
141
+ and comes back as a REFUSAL. Answering NO would file it under
142
+ "skipped", which for a DEFAULT root exits 0 — a clean run over a
143
+ config nobody read. `Path.is_dir()` cannot express that: it raises on
144
+ 3.13 (an unhandled traceback out of `main`) and swallows on 3.14
145
+ (silently skipped), so neither interpreter produced the refusal
146
+ (QG C3 r9, Francisca B1, sibling of the scan() probe).
147
+ """
148
+ try:
149
+ return stat.S_ISDIR(root.stat().st_mode)
150
+ except (FileNotFoundError, NotADirectoryError):
151
+ return False # genuinely absent — the exemption case
152
+ except OSError:
153
+ return True # cannot tell: let _safe_scan refuse it by name
154
+
155
+
156
+ def _skipped_notice(readable: list[Path], skipped: list[Path],
157
+ named: bool) -> str:
158
+ """Name every requested root that was never opened.
159
+
160
+ A root the operator NAMED and we silently dropped is a clean bill of
161
+ health for something we never read: `shield ~/.claude/settings.json`
162
+ — the file rather than its directory, a normal invocation — was
163
+ answered with "Grade A (100/100) — nothing to report." on the read-only
164
+ path and with a bare exit 2 on --fix (QG C3 r5, Francisca B2).
165
+
166
+ REFUSED is used exactly when the run will exit 2 for this reason, so
167
+ the word and the exit code never disagree (QG C3 r6, Francisca B1).
168
+ A missing DEFAULT root is a note instead: a fresh machine has no
169
+ ~/.claude and must not fail CI over it. Written to stderr so
170
+ `--json` stdout stays parseable.
171
+ """
172
+ paths = ", ".join(str(root) for root in skipped)
173
+ subject = ("a readable directory" if len(skipped) == 1
174
+ else "readable directories")
175
+ if not readable:
176
+ return f"REFUSED: no readable config root — {paths}"
177
+ if named:
178
+ return f"REFUSED: not {subject} — {paths}"
179
+ return f"note: skipped, not {subject} — {paths}"
180
+
181
+
182
+ def _build_parser() -> argparse.ArgumentParser:
102
183
  parser = argparse.ArgumentParser(
103
184
  prog="arkaos shield",
104
185
  description=(
@@ -107,7 +188,13 @@ def main(argv: list[str] | None = None) -> int:
107
188
  "injection, unpinned MCP servers, prompt injection in "
108
189
  "instruction files."
109
190
  ),
110
- epilog="Exit: 0 = A/B, 1 = C/D, 2 = F or any CRITICAL finding.",
191
+ epilog=(
192
+ "Exit: 0 = A/B, 1 = C/D, 2 = F, any CRITICAL finding, or a "
193
+ "root we could not read — any root YOU named, every root "
194
+ "when none was readable, or any root that raised while we "
195
+ "scanned it. Only a MISSING default root (~/.claude on a "
196
+ "fresh machine) is noted on stderr instead of failing the run."
197
+ ),
111
198
  )
112
199
  parser.add_argument(
113
200
  "path", nargs="*",
@@ -117,14 +204,139 @@ def main(argv: list[str] | None = None) -> int:
117
204
  "--json", action="store_true", dest="as_json",
118
205
  help="Machine-readable output.",
119
206
  )
207
+ _add_fix_arguments(parser)
208
+ return parser
209
+
210
+
211
+ def _add_fix_arguments(parser: argparse.ArgumentParser) -> None:
212
+ parser.add_argument(
213
+ "--fix", action="store_true",
214
+ help=(
215
+ "Plan the mechanical fixes (dangerous-command allow rules, "
216
+ "missing deny list). Prints every finding it cannot fix and "
217
+ "why — unscoped rules need your pattern, so they are named, "
218
+ "never dropped. Writes nothing without --apply."
219
+ ),
220
+ )
221
+ parser.add_argument(
222
+ "--apply", action="store_true",
223
+ help="With --fix: write the changes, after a timestamped backup.",
224
+ )
225
+
226
+
227
+ def main(argv: list[str] | None = None) -> int:
228
+ parser = _build_parser()
120
229
  args = parser.parse_args(argv)
230
+ if args.apply and not args.fix:
231
+ # Silently degrading a destructive flag into a read-only scan is
232
+ # a trap: the operator believes they wrote (QG C3 r1, Francisca M1).
233
+ parser.error("--apply requires --fix")
234
+
235
+ roots, skipped, named = _partition_roots(args)
236
+ if skipped:
237
+ print(_skipped_notice(roots, skipped, named), file=sys.stderr)
238
+ if not roots:
239
+ return 2 # nothing was read, so nothing can be graded
240
+ code = _fix(roots, args) if args.fix else _grade(roots, args)
241
+ if skipped and named:
242
+ # A root the OPERATOR NAMED and we never opened is a refusal
243
+ # even when the others reported: the run cannot claim to have
244
+ # graded what it was asked for (QG C3 r6, Francisca B1). Only a
245
+ # MISSING default root is exempt — a fresh machine has no
246
+ # ~/.claude. A default root that EXISTS and cannot be read is
247
+ # refused by _grade/_fix, not here (QG C3 r7, Eduardo B1).
248
+ return 2
249
+ return code
250
+
121
251
 
122
- report = merge([scan(root) for root in _roots(args) if root.is_dir()])
252
+ def _grade(roots: list[Path], args: argparse.Namespace) -> int:
253
+ """Read-only path: grade what we could read, refuse the rest.
254
+
255
+ Guarded for the same reason the fix path is: `scan` raises on a root
256
+ it cannot list, and leaving one call site bare only moves the
257
+ traceback (QG C3 r4, Francisca B2).
258
+ """
259
+ reports, refusals = [], []
260
+ for root in roots:
261
+ result = _safe_scan(root)
262
+ if isinstance(result, str):
263
+ refusals.append(result)
264
+ else:
265
+ reports.append(result)
266
+ for refusal in refusals:
267
+ print(refusal, file=sys.stderr)
268
+ if not reports:
269
+ return 2
270
+ report = merge(reports)
123
271
  if args.as_json:
124
272
  print(json.dumps(report.to_dict(), indent=2))
125
273
  else:
126
274
  print(render(report))
127
- return exit_code(report)
275
+ return 2 if refusals else exit_code(report)
276
+
277
+
278
+ def _safe_scan(root: Path) -> ScanReport | str:
279
+ """Scan a root, or say why it could not be scanned.
280
+
281
+ ``scan`` is not never-raises: an unreadable ROOT (mode 000, a
282
+ root-owned config, a container uid mismatch) passes the is_dir()
283
+ filter and then raises PermissionError from the first is_file()
284
+ (QG C3 r4, Francisca B2, reproduced end to end). A security tool
285
+ that answers a locked directory with a traceback has told the
286
+ operator nothing.
287
+ """
288
+ try:
289
+ return scan(root)
290
+ except OSError as exc:
291
+ return f"REFUSED: cannot scan {root} — {exc}"
292
+
293
+
294
+ def _fix(roots: list[Path], args: argparse.Namespace) -> int:
295
+ """Remediate each root, then grade what is left.
296
+
297
+ The exit code stays the scanner's contract and is computed from the
298
+ RE-SCAN: a fix run that leaves criticals still exits 2.
299
+ """
300
+ payloads, rendered, graded = [], [], []
301
+ for root in roots:
302
+ payload, text, after = _fix_one(root, args)
303
+ payloads.append(payload)
304
+ rendered.append(text)
305
+ if after is not None:
306
+ graded.append(after)
307
+ if args.as_json:
308
+ print(json.dumps(payloads, indent=2))
309
+ else:
310
+ print("\n\n".join(rendered))
311
+ if not graded or len(graded) < len(roots):
312
+ # `not graded` is not redundant with the caller's guard: without
313
+ # it, exit_code(merge([])) grades an empty read as A and returns
314
+ # 0 — the r5 B2 defect surviving one level down, where the two
315
+ # guards above only masked it (QG C3 r6).
316
+ return 2
317
+ return exit_code(merge(graded))
318
+
319
+
320
+ def _fix_one(root: Path,
321
+ args: argparse.Namespace) -> tuple[dict, str, ScanReport | None]:
322
+ """Remediate one root and re-grade it; None means the re-scan refused.
323
+
324
+ The re-scan happens ONCE and its report is what both the printed
325
+ grade and the exit code are computed from. Scanning a second time to
326
+ derive the exit code graded a different read than the one on screen
327
+ (QG C3 r5, Francisca M1), and dropped what `fix` itself planned when
328
+ the re-scan refused (her M3).
329
+ """
330
+ from core.harness import remediation
331
+
332
+ fix_report = remediation.fix(root, apply=args.apply)
333
+ after = _safe_scan(root)
334
+ if isinstance(after, str):
335
+ return ({"root": str(root), **fix_report.to_dict(), "refused": after},
336
+ f"{root}\n{remediation.render(fix_report)}\n{after}", None)
337
+ return ({"root": str(root), **fix_report.to_dict(),
338
+ "grade_after": after.grade},
339
+ f"{root}\n{remediation.render(fix_report, after)}", after)
128
340
 
129
341
 
130
342
  if __name__ == "__main__":