dotagents-cli 0.7.3__tar.gz → 0.7.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/CHANGELOG.md +25 -1
  2. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/PKG-INFO +1 -1
  3. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/guide/commands.md +6 -0
  4. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/guide/private-sync.md +8 -0
  5. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/AGENTS.md +1 -1
  6. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/__init__.py +1 -1
  7. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/cmds/findings.py +104 -5
  8. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_findings_cmd.py +81 -0
  9. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/.gitignore +0 -0
  10. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/LICENSE +0 -0
  11. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/README.md +0 -0
  12. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/agents.md +0 -0
  13. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/cli.md +0 -0
  14. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/commands.md +0 -0
  15. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/context.md +0 -0
  16. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/env.md +0 -0
  17. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/fs.md +0 -0
  18. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/hooks.md +0 -0
  19. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/merge.md +0 -0
  20. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/overlays.md +0 -0
  21. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/reference.md +0 -0
  22. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/resources.md +0 -0
  23. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/scope.md +0 -0
  24. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/skills.md +0 -0
  25. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/sources.md +0 -0
  26. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/api/wrappers.md +0 -0
  27. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/changelog.md +0 -0
  28. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/guide/authoring.md +0 -0
  29. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/guide/install.md +0 -0
  30. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/guide/overlays.md +0 -0
  31. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/docs/index.md +0 -0
  32. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/install.py +0 -0
  33. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/mkdocs.yml +0 -0
  34. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/pyproject.toml +0 -0
  35. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/__main__.py +0 -0
  36. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_agents.py +0 -0
  37. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_context.py +0 -0
  38. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_env.py +0 -0
  39. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_fs.py +0 -0
  40. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_hooks.py +0 -0
  41. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_ignore.py +0 -0
  42. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_merge.py +0 -0
  43. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/cmds/launch.py +0 -0
  44. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/hooks/preinvocation_antigravity_context.py +0 -0
  45. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/hooks/pretooluse_codex_env.py +0 -0
  46. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/hooks/sessionstart_codex_context.py +0 -0
  47. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/templates/AGENTS.md +0 -0
  48. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlay/dotagents/templates/PROJECT.md +0 -0
  49. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_overlays.py +0 -0
  50. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_resources.py +0 -0
  51. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_scope.py +0 -0
  52. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_skills.py +0 -0
  53. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_sources.py +0 -0
  54. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/_wrappers.py +0 -0
  55. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/__init__.py +0 -0
  56. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/_common.py +0 -0
  57. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/about.py +0 -0
  58. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/build_pyz.py +0 -0
  59. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/context.py +0 -0
  60. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/env.py +0 -0
  61. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/init.py +0 -0
  62. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/overlays.py +0 -0
  63. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/cli/path.py +0 -0
  64. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/src/dotagents/py.typed +0 -0
  65. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/_helpers.py +0 -0
  66. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/_shell.py +0 -0
  67. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/conftest.py +0 -0
  68. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_about.py +0 -0
  69. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_agents.py +0 -0
  70. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_api_header.py +0 -0
  71. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_audit_leak.py +0 -0
  72. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_build_pyz.py +0 -0
  73. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_cli_main.py +0 -0
  74. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_cloud_setup.py +0 -0
  75. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_cmds_discovery.py +0 -0
  76. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_context.py +0 -0
  77. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_env.py +0 -0
  78. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_env_cli_scope.py +0 -0
  79. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_env_format.py +0 -0
  80. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_env_into.py +0 -0
  81. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_env_regressions.py +0 -0
  82. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_env_safety.py +0 -0
  83. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_adapters.py +0 -0
  84. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_cli.py +0 -0
  85. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_env.py +0 -0
  86. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_hooks.py +0 -0
  87. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_init.py +0 -0
  88. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_overlays.py +0 -0
  89. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_packaging.py +0 -0
  90. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_scope_context.py +0 -0
  91. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_fix_skills_adapters.py +0 -0
  92. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_git_ssl_revoke.py +0 -0
  93. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_hooks.py +0 -0
  94. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_ignore.py +0 -0
  95. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_init_base.py +0 -0
  96. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_last_mile.py +0 -0
  97. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_launch_cmd.py +0 -0
  98. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_local_additions.py +0 -0
  99. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_mcp.py +0 -0
  100. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_no_internal_labels.py +0 -0
  101. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_overlay_libs.py +0 -0
  102. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_overlays_cli.py +0 -0
  103. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_overlays_command.py +0 -0
  104. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_path_timeout_frontmatter.py +0 -0
  105. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_powershell_env_hook.py +0 -0
  106. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_quoting_injection_safety.py +0 -0
  107. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_scope.py +0 -0
  108. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_sources.py +0 -0
  109. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_sources_uri.py +0 -0
  110. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_wire_hooks.py +0 -0
  111. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tests/test_wrappers.py +0 -0
  112. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tools/audit.py +0 -0
  113. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tools/cloud-setup.sh +0 -0
  114. {dotagents_cli-0.7.3 → dotagents_cli-0.7.4}/tools/pyz_smoke.sh +0 -0
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.4] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - `findings index --requote` quotes, in the findings already on disk, the frontmatter
15
+ values YAML cannot read as they stand. Only those lines change: bodies, the other
16
+ lines and line endings are kept.
17
+
18
+ ### Changed
19
+
20
+ - The private-sync guide describes the default ignore rules the overlay's
21
+ `sync-project` now writes, once, into the `.gitignore` of a store kept in git:
22
+ `scratch/`, `tmp/`, `__pycache__/`, hidden files and the machine-local overrides
23
+ stay out of the private repository.
24
+
25
+ ### Fixed
26
+
27
+ - `findings` writes frontmatter any YAML parser reads. A description that opens with
28
+ `[` or a backtick, or holds `: `, was written bare, and a markdown viewer or any
29
+ other tool reading the frontmatter rejected the file. Such a value is now a
30
+ double-quoted string; a plain one is still written bare, and a finding written
31
+ before reads as it did.
32
+
10
33
  ## [0.7.3] - 2026-10-04
11
34
 
12
35
  ### Changed
@@ -1394,7 +1417,8 @@ Patch release: the PATH/POSIX-conversion fix above (the only change since 0.3.0)
1394
1417
  affected module sources (`dotagents.cli`, `duho.presets`) to extracted temp files
1395
1418
  before dispatch; a no-op for a plain install.
1396
1419
 
1397
- [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.7.3...HEAD
1420
+ [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.7.4...HEAD
1421
+ [0.7.4]: https://github.com/jose-pr/dotagents/compare/v0.7.3...v0.7.4
1398
1422
  [0.7.3]: https://github.com/jose-pr/dotagents/compare/v0.7.2...v0.7.3
1399
1423
  [0.7.2]: https://github.com/jose-pr/dotagents/compare/v0.7.1...v0.7.2
1400
1424
  [0.7.1]: https://github.com/jose-pr/dotagents/compare/v0.7.0...v0.7.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dotagents-cli
3
- Version: 0.7.3
3
+ Version: 0.7.4
4
4
  Summary: The dotagents CLI: install and manage a portable ~/.agents config for AI coding agents
5
5
  Project-URL: Homepage, https://github.com/jose-pr/dotagents/
6
6
  Project-URL: Repository, https://github.com/jose-pr/dotagents
@@ -442,6 +442,7 @@ dotagents findings done <name> -r "fixed in _context; test added"
442
442
  dotagents findings reopen <name>
443
443
  dotagents findings remove <name> # an active finding recorded by mistake
444
444
  dotagents findings index # regenerate INDEX.md after hand edits
445
+ dotagents findings index --requote # ...and quote frontmatter values YAML cannot read
445
446
  dotagents findings path # where this scope's queue lives
446
447
  ```
447
448
 
@@ -463,6 +464,11 @@ dotagents findings path # where this scope's queue lives
463
464
  without a frontmatter is still listed (name = file stem, description = its
464
465
  first heading or line) and gains one the first time the command rewrites it;
465
466
  a note that is not UTF-8 is skipped with a warning and never rewritten.
467
+ - The frontmatter is YAML a viewer or another tool can parse: a value is written
468
+ bare when YAML reads it back as the same text, and double-quoted otherwise (a
469
+ description that opens with `[` or a backtick, or holds `: `). `index --requote`
470
+ does the same for findings an earlier version wrote, and for hand-written ones,
471
+ changing only the lines that need it.
466
472
  - `add` refuses the names `index` and `readme` (on a case-insensitive
467
473
  filesystem they are `INDEX.md` / `README.md`). `done` and `reopen` refuse to
468
474
  move onto an existing file, and `remove` deletes active findings only: reopen
@@ -49,6 +49,14 @@ leaves the machine is a perfectly valid setup.
49
49
  links the store instead.
50
50
  - **Copy mode** (`--copy`) makes `.agents` a real directory rather than a symlink;
51
51
  `sync-project` copies edits back into the store.
52
+ - **Default ignore rules.** A project's `.agents/scratch/` lives inside the store, so
53
+ the built-in git path would commit it. The first `sync-project` on that path
54
+ appends a block to the store's `.gitignore` that leaves out `scratch/`, `tmp/`,
55
+ `__pycache__/`, hidden files (`.*`, keeping `.gitignore`, `.gitattributes`,
56
+ `.gitkeep`, `.ignore` and `.dotagents-install.json`) and the machine-local
57
+ overrides (`*.local.*`, `local.env`). It is written once, under a marker line:
58
+ edit the block freely and keep that line. The overlay's `kb/PRIVATE_SYNC.md` has
59
+ the block in full.
52
60
 
53
61
  ## Cloud sessions
54
62
 
@@ -40,7 +40,7 @@ and an error saying a command is required, and exits 2.
40
40
  | `path [-g] [--lib] [--format list\|native\|posix\|json]` | Print the bin dirs `env` puts on `PATH` (`--lib`: the existing lib dirs it puts on `PYTHONPATH`), highest precedence first; runs no env file. `posix` is `:`-joined, MSYS2 form on Windows. |
41
41
  | `env [-g] [--format F] [--diff] [--cache] [--into FILE]` | Print the assembled environment (`--diff`: only what differs from the caller's; `--cache`: replay the previous output while `env_cache_key` holds, up to `ENV_CACHE_TTL`; `--into FILE`: write it into FILE as the one block this command owns there) in format `F`: `auto` (default: the calling shell), `export`/`posix`/`sh`/`bash`, `dotenv`/`env`, `powershell`/`pwsh`/`ps`, `cmd`/`bat`/`batch`, `fish`, `json`, `ini`, `yaml`. |
42
42
  | `launch [AGENT] [-g] [--command PROG] [--no-context] [--inline] [--write-agent] [--dry-run] [-- ARGS...]` | Run the agent's CLI with the env applied and the context handed over; the exit code is the harness's (`128+N` on signal N). |
43
- | `findings add\|list\|show\|done\|reopen\|remove\|index\|path [-g] [--dir D]` | The findings queue at `<store>/findings/` (`--dir` overrides): one markdown file per finding, `done` moves it to `processed/` with a required resolution, `INDEX.md` regenerated on every change. |
43
+ | `findings add\|list\|show\|done\|reopen\|remove\|index\|path [-g] [--dir D]` | The findings queue at `<store>/findings/` (`--dir` overrides): one markdown file per finding, `done` moves it to `processed/` with a required resolution, `INDEX.md` regenerated on every change. The frontmatter is valid YAML (a value is double-quoted when it must be); `index --requote` fixes values written bare in existing files, touching nothing else. |
44
44
  | `about [--json]` | `dotagents-cli <version>`, then `<distribution> <version>` per bundled (`.pyz`) or installed package. |
45
45
  | `build-pyz [--out P] [--python SHEBANG] [--duho-version V] [--pathlib-next-version V] [--extras a,b]` | Build a zipapp from a source checkout; errors elsewhere. |
46
46
 
@@ -1,3 +1,3 @@
1
1
  """dotagents: installable CLI for the dotagents agent-config payload."""
2
2
 
3
- __version__ = "0.7.3"
3
+ __version__ = "0.7.4"
@@ -37,6 +37,10 @@ line, the details in the body::
37
37
 
38
38
  <the decision, evidence, or why no action was needed>
39
39
 
40
+ The frontmatter is YAML a viewer or another tool can parse: a value is written
41
+ bare when YAML reads it back as the same text, and as a double-quoted string
42
+ otherwise (a description that opens with ``[`` or a backtick, or holds ``: ``).
43
+
40
44
  ``INDEX.md`` is one line per finding, active first, then processed -- the cheap
41
45
  thing to read at session start. Hand-written notes without frontmatter are
42
46
  still listed (name = file stem, description = first heading or line), and gain
@@ -90,6 +94,69 @@ def slugify(text: str) -> str:
90
94
  return slug
91
95
 
92
96
 
97
+ #: A value cannot be written bare when it opens with one of these: YAML reads
98
+ #: ``[`` as a list, a quote as a quoted string, a backtick as reserved.
99
+ _YAML_INDICATORS = frozenset("-?:,[]{}#&*!|>'\"%@`")
100
+ #: Bare words YAML reads as a boolean or as nothing rather than as text.
101
+ _YAML_WORDS = frozenset(("true", "false", "yes", "no", "on", "off", "null", "~"))
102
+ #: Line breaks to YAML that JSON leaves as they are, escaped inside a quoted value.
103
+ _YAML_BREAKS = {"\x85": "\\u0085", "
": "\\u2028", "
": "\\u2029"}
104
+
105
+
106
+ def _yaml_scalar(value: str) -> str:
107
+ """``value`` as a one-line YAML scalar: bare when YAML reads it back as the
108
+ same text, double-quoted when it would read something else (a list, a
109
+ mapping, a comment) or nothing at all."""
110
+ plain = (
111
+ value == value.strip()
112
+ and value[:1] not in _YAML_INDICATORS
113
+ and ": " not in value
114
+ and " #" not in value
115
+ and not value.endswith(":")
116
+ and value.lower() not in _YAML_WORDS
117
+ and not any(ord(c) < 32 or c == "\x7f" or c in _YAML_BREAKS for c in value)
118
+ )
119
+ if plain and value:
120
+ return value
121
+ quoted = json.dumps(value, ensure_ascii=False) # a JSON string is a YAML one
122
+ for char, escape in _YAML_BREAKS.items():
123
+ quoted = quoted.replace(char, escape)
124
+ return quoted
125
+
126
+
127
+ def _yaml_text(scalar: str) -> str:
128
+ """The text of a one-line scalar as :func:`_yaml_scalar` writes it. Anything
129
+ that is not one whole quoted string is its own text, which is how a value
130
+ this command wrote bare, before it quoted, still reads as it did."""
131
+ if len(scalar) >= 2 and scalar[0] == scalar[-1] == '"':
132
+ try:
133
+ value = json.loads(scalar)
134
+ except ValueError:
135
+ return scalar
136
+ return value if isinstance(value, str) else scalar
137
+ if len(scalar) >= 2 and scalar[0] == scalar[-1] == "'":
138
+ inner = scalar[1:-1]
139
+ if "'" not in inner.replace("''", ""):
140
+ return inner.replace("''", "'")
141
+ return scalar
142
+
143
+
144
+ def _requoted(block: str) -> str:
145
+ """A frontmatter block with every bare value YAML would misread quoted.
146
+ A value that is already one quoted string, or reads back as itself, keeps
147
+ its line as it is; so do keys, order, line endings and any line that is
148
+ not ``key: value``."""
149
+ out = []
150
+ for line in block.splitlines(True):
151
+ text = line.rstrip("\r\n")
152
+ key, sep, value = text.partition(":")
153
+ bare = value.strip()
154
+ if sep and key.strip() and bare and _yaml_text(bare) == bare and _yaml_scalar(bare) != bare:
155
+ text = "%s: %s" % (key, _yaml_scalar(bare))
156
+ out.append(text + line[len(line.rstrip("\r\n")):])
157
+ return "".join(out)
158
+
159
+
93
160
  def _today() -> str:
94
161
  return datetime.date.today().isoformat()
95
162
 
@@ -153,7 +220,7 @@ class Finding:
153
220
  for line in m.group(1).splitlines():
154
221
  key, sep, value = line.partition(":")
155
222
  if sep and key.strip():
156
- meta[key.strip()] = value.strip()
223
+ meta[key.strip()] = _yaml_text(value.strip())
157
224
  body = text[m.end():]
158
225
  else:
159
226
  body = text
@@ -178,10 +245,10 @@ class Finding:
178
245
  lines = ["---"]
179
246
  for key in _META_KEYS:
180
247
  if self.meta.get(key):
181
- lines.append("%s: %s" % (key, self.meta[key]))
248
+ lines.append("%s: %s" % (key, _yaml_scalar(self.meta[key])))
182
249
  for key, value in self.meta.items():
183
250
  if key not in _META_KEYS and value:
184
- lines.append("%s: %s" % (key, value))
251
+ lines.append("%s: %s" % (key, _yaml_scalar(value)))
185
252
  lines.append("---")
186
253
  body = self.body.rstrip("\r\n")
187
254
  return "\n".join(lines) + "\n\n" + (body + "\n" if body else "")
@@ -372,6 +439,26 @@ class FindingsStore:
372
439
  self.write_index()
373
440
  return path
374
441
 
442
+ def requote(self) -> "list[Path]":
443
+ """Write again every frontmatter whose values are not in the form this
444
+ command writes now: a description an earlier version left bare where
445
+ YAML needs it quoted. Only those lines change; the body, the other
446
+ lines and the line endings stay byte for byte. Returns the files
447
+ changed."""
448
+ changed = []
449
+ for finding in self.all():
450
+ text = finding.path.read_bytes().decode("utf-8")
451
+ m = _FRONTMATTER_RE.match(text[1:] if text.startswith("") else text)
452
+ if not m:
453
+ continue
454
+ shift = 1 if text.startswith("") else 0
455
+ start, end = m.start(1) + shift, m.end(1) + shift
456
+ block = _requoted(text[start:end])
457
+ if block != text[start:end]:
458
+ write_text_lf(finding.path, text[:start] + block + text[end:])
459
+ changed.append(finding.path)
460
+ return changed
461
+
375
462
  # -- index ---------------------------------------------------------------
376
463
 
377
464
  def render_index(self) -> str:
@@ -605,12 +692,24 @@ class Findings(LoggingArgs, Cli):
605
692
  return 0
606
693
 
607
694
  class Index(_Base):
608
- """Regenerate INDEX.md from the files (after hand edits)."""
695
+ """Regenerate INDEX.md from the files (after hand edits).
696
+
697
+ `--requote` first quotes any frontmatter value YAML cannot read as it
698
+ stands: what an earlier version wrote for a description opening with
699
+ `[` or a backtick, or holding `: `."""
609
700
 
610
701
  _parsername_ = "index"
611
702
 
703
+ requote: bool = False
704
+ "Also quote frontmatter values YAML cannot read bare (bodies are not touched)."
705
+ ("--requote",)
706
+
612
707
  def __call__(self) -> int:
613
- _write_stdout("%s\n" % (self.store().write_index(),))
708
+ store = self.store()
709
+ if self.requote:
710
+ changed = store.requote()
711
+ self._logger_.info("requoted the frontmatter of %d finding(s)", len(changed))
712
+ _write_stdout("%s\n" % (store.write_index(),))
614
713
  return 0
615
714
 
616
715
  class PathCmd(_Base):
@@ -336,3 +336,84 @@ def test_a_finding_named_like_the_index_is_refused(findings_mod, tmp_path):
336
336
  with pytest.raises(SystemExit, match="reserved"):
337
337
  store.add(title, body="important details")
338
338
  assert not root.exists() or not [p for p in root.iterdir() if p.suffix == ".md"]
339
+
340
+
341
+ # --------------------------------------------------------------------------- #
342
+ # The frontmatter is YAML another tool can read
343
+ # --------------------------------------------------------------------------- #
344
+
345
+ AWKWARD = [
346
+ "[low][correctness] off Linux, sniff with an interface name raises",
347
+ "`dotagents pyvenv` broken on the new machine",
348
+ "perf rule: two hosted-runner runs are not a before/after",
349
+ '"It compiles" is not evidence',
350
+ "'first', 'second' and a third",
351
+ '"quoted"',
352
+ "ends with a colon:",
353
+ "a marker # that starts a comment",
354
+ "*starred* and &anchored",
355
+ 'a back\\slash and a "quote": both',
356
+ "- a leading dash",
357
+ "{braces} and a percent %",
358
+ "em dash — and an accent: é",
359
+ "true",
360
+ ]
361
+
362
+
363
+ @pytest.mark.parametrize("text", AWKWARD)
364
+ def test_any_description_is_frontmatter_yaml_can_read(findings_mod, tmp_path, text):
365
+ yaml = pytest.importorskip("yaml")
366
+ store = findings_mod.FindingsStore(tmp_path / "findings")
367
+ finding = store.add(text, name="x")
368
+ front = yaml.safe_load(finding.path.read_text(encoding="utf-8").split("---\n")[1])
369
+ assert front["name"] == "x" and str(front["description"]) == text
370
+ assert findings_mod.Finding.load(finding.path).description == text
371
+ assert "— %s\n" % text in store.index_path.read_text(encoding="utf-8")
372
+
373
+
374
+ def test_a_plain_description_is_written_bare(findings_mod, tmp_path):
375
+ store = findings_mod.FindingsStore(tmp_path / "findings")
376
+ text = "bare kb/X.md refs were never inlined (3 files, 2026-09-09)"
377
+ raw = store.add(text).path.read_text(encoding="utf-8")
378
+ assert "\ndescription: %s\n" % text in raw
379
+
380
+
381
+ @pytest.mark.parametrize("text", [
382
+ "[low][api] a consumer: rewrites `x`",
383
+ '"It compiles" is not evidence',
384
+ "'one', 'two'",
385
+ '"a" and "b"',
386
+ ])
387
+ def test_a_finding_written_bare_still_reads_as_it_did(findings_mod, tmp_path, text):
388
+ """What an earlier version wrote: the description as typed, whatever it held."""
389
+ path = tmp_path / "findings" / "old.md"
390
+ path.parent.mkdir(parents=True)
391
+ path.write_bytes(("---\nname: old\ndescription: %s\nstatus: active\n---\n\nbody\n" % text).encode("utf-8"))
392
+ assert findings_mod.Finding.load(path).description == text
393
+
394
+
395
+ def test_index_requote_changes_only_the_values_yaml_cannot_read(findings_mod, tmp_path, capsys):
396
+ yaml = pytest.importorskip("yaml")
397
+ root = tmp_path / "findings"
398
+ (root / "processed").mkdir(parents=True)
399
+ bad = root / "processed" / "bad.md" # CRLF, odd spacing, blank lines: all kept
400
+ bad.write_bytes(b"---\r\nname: bad\r\ndescription: [low][api] a: b\r\nstatus: processed\r\n---\r\n\r\n\r\nbody kept \r\n\r\n\r\n")
401
+ kept = {
402
+ root / "quoted.md": b'---\nname: quoted\ndescription: "already: quoted"\n---\nbody\n',
403
+ root / "plain.md": b"---\nname: plain\ndescription: nothing to do\nextra: 1\n---\n\nbody\n",
404
+ root / "note.md": b"# a hand note\n\n---\nnot: [frontmatter\n---\n",
405
+ }
406
+ for path, data in kept.items():
407
+ path.write_bytes(data)
408
+
409
+ assert _run(findings_mod.Findings.Index, dir=root, requote=True) == 0
410
+ assert capsys.readouterr().out.strip() == str(root / "INDEX.md")
411
+
412
+ assert bad.read_bytes() == (
413
+ b'---\r\nname: bad\r\ndescription: "[low][api] a: b"\r\nstatus: processed\r\n---\r\n\r\n\r\nbody kept \r\n\r\n\r\n')
414
+ assert yaml.safe_load(bad.read_bytes().decode().split("---\r\n")[1])["description"] == "[low][api] a: b"
415
+ for path, data in kept.items():
416
+ assert path.read_bytes() == data, path.name
417
+ assert "— [low][api] a: b" in (root / "INDEX.md").read_text(encoding="utf-8")
418
+ # A second pass has nothing left to do.
419
+ assert findings_mod.FindingsStore(root).requote() == []
File without changes
File without changes
File without changes
File without changes
File without changes