@ictechgy/context-guard 0.4.14 → 0.4.16

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 (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +72 -1
  3. package/README.md +85 -2
  4. package/docs/benchmark-fixtures/image-context-pack-full-evidence.prompt.example.md +28 -0
  5. package/docs/benchmark-fixtures/image-context-pack-packed-evidence.prompt.example.md +31 -0
  6. package/docs/benchmark-fixtures/image-context-pack.evidence.example.jsonl +2 -0
  7. package/docs/benchmark-fixtures/image-context-pack.tasks.example.json +18 -0
  8. package/docs/benchmark-fixtures/image-context-pack.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +16 -0
  10. package/docs/experimental-benchmark-fixtures.md +52 -1
  11. package/package.json +2 -1
  12. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  13. package/plugins/context-guard/README.ko.md +43 -0
  14. package/plugins/context-guard/README.md +43 -0
  15. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  16. package/plugins/context-guard/bin/context-guard-audit +169 -66
  17. package/plugins/context-guard/bin/context-guard-bench +7038 -307
  18. package/plugins/context-guard/bin/context-guard-compress +90 -8
  19. package/plugins/context-guard/bin/context-guard-diet +1 -7
  20. package/plugins/context-guard/bin/context-guard-experiments +3085 -134
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  22. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  23. package/plugins/context-guard/bin/context-guard-mcp +999 -0
  24. package/plugins/context-guard/bin/context-guard-pack +744 -20
  25. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  26. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  27. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  28. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  29. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  30. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  31. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  32. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  33. package/plugins/context-guard/brief/README.md +19 -0
  34. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  35. package/plugins/context-guard/lib/context_guard_commands.py +14 -2
  36. package/plugins/context-guard/lib/credential_policy.py +177 -0
  37. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import argparse
11
11
  import copy
12
12
  import datetime as _dt
13
+ import hashlib
13
14
  import json
14
15
  import os
15
16
  import re
@@ -46,8 +47,6 @@ RECOMMENDED_DENIES = [
46
47
  "Read(./vendor/**)",
47
48
  "Read(./.context-guard/**)",
48
49
  "Read(./.claude-token-optimizer/**)",
49
- "Read(./.env)",
50
- "Read(./.env.*)",
51
50
  "Read(./.npmrc)",
52
51
  "Read(./.pypirc)",
53
52
  "Read(./.netrc)",
@@ -57,6 +56,10 @@ RECOMMENDED_DENIES = [
57
56
  "Read(~/.kube/**)",
58
57
  "Read(~/.docker/**)",
59
58
  ]
59
+ PRODUCT_OWNED_ENV_READ_DENIES = frozenset({
60
+ "Read(./.env)",
61
+ "Read(./.env.*)",
62
+ })
60
63
  HELPER_STATUSLINE = "context-guard-statusline-merged"
61
64
  HELPER_REWRITE_BASH = "context-guard-rewrite-bash"
62
65
  HELPER_GUARD_READ = "context-guard-guard-read"
@@ -165,14 +168,19 @@ class SetupResult:
165
168
  # - native-skill / report-only agents are never written to; they are reported.
166
169
  # It never sends work to external providers and never promises token/cost savings.
167
170
 
168
- ADAPTER_RULE_BLOCK_BEGIN = "<!-- contextguard:begin -->"
169
- ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
171
+ LEGACY_ADAPTER_RULE_BLOCK_BEGIN = "<!-- contextguard:begin -->"
172
+ LEGACY_ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
173
+ ADAPTER_RULE_BLOCK_BEGIN = "<!-- BEGIN context-guard:repo-rules version=1 -->"
174
+ ADAPTER_RULE_BLOCK_END = "<!-- END context-guard:repo-rules -->"
170
175
  CODEX_SKILL_REL = ".agents/skills/context-guard/SKILL.md"
171
- CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
172
- CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
176
+ LEGACY_CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
177
+ LEGACY_CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
178
+ CODEX_SKILL_MARKER_BEGIN = "<!-- BEGIN context-guard:codex-skill version=1 -->"
179
+ CODEX_SKILL_MARKER_END = "<!-- END context-guard:codex-skill -->"
173
180
  BRIEF_MODE_LEVELS = ("lite", "standard", "ultra")
174
181
  BRIEF_MODE_OFF = "off"
175
182
  BRIEF_MODE_CHOICES = (*BRIEF_MODE_LEVELS, BRIEF_MODE_OFF)
183
+ NARRATION_MODE_CHOICES = ("quiet", "default")
176
184
  BRIEF_MODE_BLOCK_END = "<!-- END context-guard:brief-mode -->"
177
185
  BRIEF_MODE_BEGIN_RE = re.compile(
178
186
  r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
@@ -186,6 +194,214 @@ BRIEF_MODE_BLOCK_RE = re.compile(
186
194
  re.DOTALL,
187
195
  )
188
196
 
197
+ LEGACY_REPO_RULE_MARKER_BEGIN = LEGACY_ADAPTER_RULE_BLOCK_BEGIN.encode("ascii")
198
+ LEGACY_REPO_RULE_MARKER_END = LEGACY_ADAPTER_RULE_BLOCK_END.encode("ascii")
199
+ REPO_RULE_MARKER_V1_BEGIN = ADAPTER_RULE_BLOCK_BEGIN.encode("ascii")
200
+ REPO_RULE_MARKER_V1_END = ADAPTER_RULE_BLOCK_END.encode("ascii")
201
+ LEGACY_CODEX_SKILL_MARKER_V0_BEGIN = LEGACY_CODEX_SKILL_MARKER_BEGIN.encode("ascii")
202
+ LEGACY_CODEX_SKILL_MARKER_V0_END = LEGACY_CODEX_SKILL_MARKER_END.encode("ascii")
203
+ CODEX_SKILL_MARKER_V1_BEGIN = CODEX_SKILL_MARKER_BEGIN.encode("ascii")
204
+ CODEX_SKILL_MARKER_V1_END = CODEX_SKILL_MARKER_END.encode("ascii")
205
+ BRIEF_MODE_MARKER_END = BRIEF_MODE_BLOCK_END.encode("ascii")
206
+ NARRATION_MODE_MARKER_BEGIN = b"<!-- BEGIN context-guard:narration-mode mode=quiet version=1 -->"
207
+ NARRATION_MODE_MARKER_END = b"<!-- END context-guard:narration-mode -->"
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class ManagedMarker:
212
+ kind: str
213
+ version: int
214
+ begin: bytes
215
+ end: bytes
216
+ variant: str | None = None
217
+
218
+
219
+ MANAGED_MARKERS = (
220
+ ManagedMarker(
221
+ "repo-rules",
222
+ 0,
223
+ LEGACY_REPO_RULE_MARKER_BEGIN,
224
+ LEGACY_REPO_RULE_MARKER_END,
225
+ "legacy",
226
+ ),
227
+ ManagedMarker(
228
+ "repo-rules",
229
+ 1,
230
+ REPO_RULE_MARKER_V1_BEGIN,
231
+ REPO_RULE_MARKER_V1_END,
232
+ ),
233
+ ManagedMarker(
234
+ "codex-skill",
235
+ 0,
236
+ LEGACY_CODEX_SKILL_MARKER_V0_BEGIN,
237
+ LEGACY_CODEX_SKILL_MARKER_V0_END,
238
+ "legacy",
239
+ ),
240
+ ManagedMarker(
241
+ "codex-skill",
242
+ 1,
243
+ CODEX_SKILL_MARKER_V1_BEGIN,
244
+ CODEX_SKILL_MARKER_V1_END,
245
+ ),
246
+ *(
247
+ ManagedMarker(
248
+ "brief-mode",
249
+ 1,
250
+ f"<!-- BEGIN context-guard:brief-mode level={level} version=1 -->".encode("ascii"),
251
+ BRIEF_MODE_MARKER_END,
252
+ level,
253
+ )
254
+ for level in BRIEF_MODE_LEVELS
255
+ ),
256
+ ManagedMarker(
257
+ "narration-mode",
258
+ 1,
259
+ NARRATION_MODE_MARKER_BEGIN,
260
+ NARRATION_MODE_MARKER_END,
261
+ "quiet",
262
+ ),
263
+ )
264
+ _MANAGED_BEGIN_MARKERS = {marker.begin: marker for marker in MANAGED_MARKERS}
265
+ _MANAGED_END_MARKERS: dict[bytes, tuple[ManagedMarker, ...]] = {}
266
+ for _marker in MANAGED_MARKERS:
267
+ _MANAGED_END_MARKERS[_marker.end] = (*_MANAGED_END_MARKERS.get(_marker.end, ()), _marker)
268
+
269
+
270
+ @dataclass(frozen=True)
271
+ class ManagedSpan:
272
+ kind: str
273
+ version: int
274
+ variant: str | None
275
+ start: int
276
+ end: int
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class ManagedParseResult:
281
+ status: str
282
+ spans: tuple[ManagedSpan, ...] = ()
283
+ reason: str | None = None
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class ManagedFileSnapshot:
288
+ data: bytes | None
289
+ metadata: tuple[int, int, int, int, int] | None
290
+
291
+
292
+ class ManagedFileConflictError(OSError):
293
+ """Raised when a managed target no longer matches its planned snapshot."""
294
+
295
+
296
+ def _iter_binary_lines(data: bytes):
297
+ offset = 0
298
+ while offset < len(data):
299
+ newline = data.find(b"\n", offset)
300
+ if newline < 0:
301
+ yield offset, len(data), data[offset:], b""
302
+ return
303
+ end = newline + 1
304
+ if newline > offset and data[newline - 1 : newline] == b"\r":
305
+ content, ending = data[offset : newline - 1], b"\r\n"
306
+ else:
307
+ content, ending = data[offset:newline], b"\n"
308
+ yield offset, end, content, ending
309
+ offset = end
310
+
311
+
312
+ def _fence_open(content: bytes) -> tuple[int, int] | None:
313
+ match = re.match(rb"^ {0,3}(`{3,}|~{3,}).*$", content)
314
+ if not match:
315
+ return None
316
+ run = match.group(1)
317
+ return run[0], len(run)
318
+
319
+
320
+ def _fence_close(content: bytes, fence: tuple[int, int]) -> bool:
321
+ char, minimum = fence
322
+ match = re.match(rb"^ {0,3}([`~]+) *$", content)
323
+ if not match:
324
+ return False
325
+ run = match.group(1)
326
+ return bool(run and run[0] == char and len(run) >= minimum and all(byte == char for byte in run))
327
+
328
+
329
+ def _looks_like_contextguard_marker(content: bytes) -> bool:
330
+ lowered = content.lstrip(b" \t").lower()
331
+ return (
332
+ lowered.startswith(b"<!--")
333
+ and b"-->" in lowered
334
+ and (b"contextguard:" in lowered or b"context-guard:" in lowered)
335
+ )
336
+
337
+
338
+ def _scan_managed_spans(data: bytes) -> ManagedParseResult:
339
+ spans: list[ManagedSpan] = []
340
+ open_marker: tuple[ManagedMarker, int] | None = None
341
+ fence: tuple[int, int] | None = None
342
+ unsupported = False
343
+ malformed = False
344
+ for start, end, content, ending in _iter_binary_lines(data):
345
+ if fence is not None:
346
+ if _fence_close(content, fence):
347
+ fence = None
348
+ continue
349
+ opener = _fence_open(content)
350
+ if opener is not None:
351
+ fence = opener
352
+ continue
353
+ marker = _MANAGED_BEGIN_MARKERS.get(content) if ending else None
354
+ end_markers = _MANAGED_END_MARKERS.get(content, ()) if ending else ()
355
+ if marker is not None:
356
+ if open_marker is not None:
357
+ malformed = True
358
+ else:
359
+ open_marker = (marker, start)
360
+ continue
361
+ if end_markers:
362
+ if open_marker is None:
363
+ malformed = True
364
+ continue
365
+ active, span_start = open_marker
366
+ if active.end != content:
367
+ malformed = True
368
+ open_marker = None
369
+ continue
370
+ spans.append(
371
+ ManagedSpan(
372
+ kind=active.kind,
373
+ version=active.version,
374
+ variant=active.variant,
375
+ start=span_start,
376
+ end=end,
377
+ )
378
+ )
379
+ open_marker = None
380
+ continue
381
+ if _looks_like_contextguard_marker(content):
382
+ unsupported = True
383
+ if open_marker is not None:
384
+ malformed = True
385
+ if malformed:
386
+ return ManagedParseResult("malformed", tuple(spans), "malformed managed marker structure")
387
+ if unsupported:
388
+ return ManagedParseResult("unsupported", tuple(spans), "unsupported managed marker literal")
389
+ return ManagedParseResult("valid" if spans else "absent", tuple(spans))
390
+
391
+
392
+ def parse_managed_bytes(data: bytes, *, kind: str | None = None) -> ManagedParseResult:
393
+ """Classify exact ContextGuard-managed raw-byte spans without decoding user bytes."""
394
+ scanned = _scan_managed_spans(data)
395
+ if scanned.status in {"malformed", "unsupported"}:
396
+ return scanned
397
+ spans = tuple(span for span in scanned.spans if kind is None or span.kind == kind)
398
+ by_kind: dict[str, int] = {}
399
+ for span in spans:
400
+ by_kind[span.kind] = by_kind.get(span.kind, 0) + 1
401
+ if any(count > 1 for count in by_kind.values()) or (kind is None and len(spans) > 1):
402
+ return ManagedParseResult("ambiguous", spans, "multiple managed spans")
403
+ return ManagedParseResult("valid" if spans else "absent", spans)
404
+
189
405
 
190
406
  class CapabilityClass:
191
407
  """How ContextGuard can integrate with a given agent."""
@@ -375,15 +591,14 @@ def render_repo_rule_block() -> str:
375
591
  ])
376
592
 
377
593
 
378
- def render_codex_skill() -> str:
379
- """Render the optional project-local Codex skill for ContextGuard."""
594
+ def _render_codex_skill_with_markers(begin: str, end: str) -> str:
380
595
  return "\n".join([
381
596
  "---",
382
597
  "name: context-guard",
383
598
  "description: Use ContextGuard helpers to keep Codex context focused with local-first setup, audit, trimming, and artifact commands.",
384
599
  "---",
385
600
  "",
386
- CODEX_SKILL_MARKER_BEGIN,
601
+ begin,
387
602
  "# ContextGuard for Codex",
388
603
  "",
389
604
  "Use this skill when a task would otherwise paste large files, long logs, or repeated setup context into Codex.",
@@ -400,11 +615,38 @@ def render_codex_skill() -> str:
400
615
  "- If `context-guard` is not on PATH, install it explicitly or run via `npx @ictechgy/context-guard`.",
401
616
  "",
402
617
  "Do not claim fixed token or cost savings from these helpers; treat byte reductions as local proxy evidence only.",
403
- CODEX_SKILL_MARKER_END,
618
+ end,
404
619
  "",
405
620
  ])
406
621
 
407
622
 
623
+ def render_codex_skill() -> str:
624
+ """Render the v1 project-local Codex skill."""
625
+ return _render_codex_skill_with_markers(CODEX_SKILL_MARKER_BEGIN, CODEX_SKILL_MARKER_END)
626
+
627
+
628
+ def render_legacy_codex_skill_v0() -> str:
629
+ """Render the exact legacy whole-file image released before managed-span v1."""
630
+ return _render_codex_skill_with_markers(
631
+ LEGACY_CODEX_SKILL_MARKER_BEGIN,
632
+ LEGACY_CODEX_SKILL_MARKER_END,
633
+ )
634
+
635
+
636
+ LEGACY_CODEX_SKILL_SHA256_ALLOWLIST = {
637
+ hashlib.sha256(render_legacy_codex_skill_v0().encode("utf-8")).hexdigest(): "legacy-v0-current",
638
+ }
639
+
640
+
641
+ def render_codex_skill_block_bytes() -> bytes:
642
+ rendered = render_codex_skill().encode("utf-8")
643
+ start = rendered.index(CODEX_SKILL_MARKER_V1_BEGIN)
644
+ end = rendered.index(CODEX_SKILL_MARKER_V1_END, start) + len(CODEX_SKILL_MARKER_V1_END)
645
+ if rendered[end : end + 1] == b"\n":
646
+ end += 1
647
+ return rendered[start:end]
648
+
649
+
408
650
  def _brief_mode_source_candidates(level: str) -> list[Path]:
409
651
  """Return deterministic source candidates for packaged/repo brief snippets."""
410
652
  filename = f"brief-mode.{level}.md"
@@ -476,6 +718,39 @@ def render_brief_mode_block(level: str) -> str:
476
718
  return render_fallback_brief_mode_block(level)
477
719
 
478
720
 
721
+ def render_quiet_narration_block() -> str:
722
+ """Render embedded canonical bytes without opening any non-target file."""
723
+ return "\n".join([
724
+ NARRATION_MODE_MARKER_BEGIN.decode("ascii"),
725
+ "## ContextGuard quiet narration (advisory)",
726
+ "",
727
+ "Best effort: reduce only discretionary intermediate narration. Skip routine preambles,",
728
+ "per-tool narration, filler, and repeated interim summaries when they add no useful",
729
+ "information.",
730
+ "",
731
+ "Always preserve required user-facing communication:",
732
+ "",
733
+ "- user approvals and decisions;",
734
+ "- blockers and failures;",
735
+ "- destructive-risk and security warnings;",
736
+ "- progress required by higher-priority instructions;",
737
+ "- the final result;",
738
+ "- changed files; and",
739
+ "- verification evidence.",
740
+ "",
741
+ "This mode does not require a shorter final answer and does not change reasoning effort.",
742
+ "It asks Claude to reduce discretionary narration; it does not guarantee token or cost savings,",
743
+ "and no numeric savings should be claimed without matched provider evidence.",
744
+ NARRATION_MODE_MARKER_END.decode("ascii"),
745
+ ])
746
+
747
+
748
+ def _append_narration_block_bytes(existing: bytes, block: bytes) -> bytes:
749
+ """Append one deterministic separator that default-mode removes with the span."""
750
+ block = block.rstrip(b"\r\n") + b"\n"
751
+ return block if not existing else existing + b"\n" + block
752
+
753
+
479
754
  def _brief_mode_levels_in_text(text: str) -> list[str]:
480
755
  return [match.group("level") for match in BRIEF_MODE_BLOCK_RE.finditer(text)]
481
756
 
@@ -494,37 +769,107 @@ def _append_managed_block(existing: str, block: str) -> str:
494
769
  return block + "\n"
495
770
 
496
771
 
497
- def compose_rule_file_text(
498
- existing: str | None,
772
+ def _managed_block_bytes(block: str) -> bytes:
773
+ return block.encode("utf-8").rstrip(b"\r\n") + b"\n"
774
+
775
+
776
+ def _append_managed_block_bytes(existing: bytes, block: bytes) -> bytes:
777
+ block = block.rstrip(b"\r\n") + b"\n"
778
+ if not existing:
779
+ return block
780
+ if existing.endswith(b"\n\n"):
781
+ separator = b""
782
+ elif existing.endswith(b"\n"):
783
+ separator = b"\n"
784
+ else:
785
+ separator = b"\n\n"
786
+ return existing + separator + block
787
+
788
+
789
+ def _managed_span_for_kind(data: bytes, kind: str) -> ManagedSpan | None:
790
+ parsed = parse_managed_bytes(data, kind=kind)
791
+ if parsed.status == "absent":
792
+ return None
793
+ if parsed.status != "valid":
794
+ raise ValueError(parsed.reason or f"{parsed.status} managed {kind} markers")
795
+ return parsed.spans[0]
796
+
797
+
798
+ def _replace_managed_span(data: bytes, span: ManagedSpan, block: bytes) -> bytes:
799
+ return data[: span.start] + block.rstrip(b"\r\n") + b"\n" + data[span.end :]
800
+
801
+
802
+ def _brief_mode_levels_in_bytes(data: bytes) -> list[str]:
803
+ parsed = _scan_managed_spans(data)
804
+ return [
805
+ str(span.variant)
806
+ for span in parsed.spans
807
+ if span.kind == "brief-mode" and span.variant in BRIEF_MODE_LEVELS
808
+ ]
809
+
810
+
811
+ def compose_rule_file_bytes(
812
+ existing: bytes | None,
499
813
  *,
500
814
  with_init: bool,
501
815
  brief_mode: str | None,
502
- ) -> tuple[str, dict[str, Any]]:
503
- """Compose final repo rule text for combined init and brief-mode mutations."""
504
- text = existing or ""
505
- original_text = text
506
- existing_brief_levels = _brief_mode_levels_in_text(text)
816
+ ) -> tuple[bytes, dict[str, Any]]:
817
+ """Compose rule-file mutations from exact owned spans, preserving all other bytes."""
818
+ data = existing or b""
819
+ original = data
820
+ before_brief = _brief_mode_levels_in_bytes(data)
507
821
  meta: dict[str, Any] = {
508
822
  "init_changed": False,
509
- "init_present_before": ADAPTER_RULE_BLOCK_BEGIN in text,
510
- "brief_levels_before": existing_brief_levels,
823
+ "init_present_before": False,
824
+ "init_migrated_legacy": False,
825
+ "brief_levels_before": before_brief,
511
826
  "brief_changed": False,
512
827
  }
513
- if with_init and ADAPTER_RULE_BLOCK_BEGIN not in text:
514
- text = _append_managed_block(text, render_repo_rule_block())
515
- meta["init_changed"] = True
828
+ repo_span = _managed_span_for_kind(data, "repo-rules")
829
+ meta["init_present_before"] = repo_span is not None
830
+ if with_init:
831
+ block = _managed_block_bytes(render_repo_rule_block())
832
+ if repo_span is None:
833
+ data = _append_managed_block_bytes(data, block)
834
+ meta["init_changed"] = True
835
+ elif data[repo_span.start : repo_span.end] != block:
836
+ data = _replace_managed_span(data, repo_span, block)
837
+ meta["init_changed"] = True
838
+ meta["init_migrated_legacy"] = repo_span.version == 0
839
+
516
840
  if brief_mode:
517
- stripped, removed_levels = _remove_brief_mode_blocks(text)
841
+ span = _managed_span_for_kind(data, "brief-mode")
842
+ removed = [str(span.variant)] if span is not None and span.variant else []
843
+ meta["brief_levels_removed"] = removed
518
844
  if brief_mode == BRIEF_MODE_OFF:
519
- text = stripped
520
- meta["brief_changed"] = bool(removed_levels)
845
+ if span is not None:
846
+ data = data[: span.start] + data[span.end :]
847
+ meta["brief_changed"] = True
521
848
  else:
522
- block = render_brief_mode_block(brief_mode)
523
- text = _append_managed_block(stripped, block)
524
- meta["brief_changed"] = removed_levels != [brief_mode] or text != original_text
525
- meta["brief_levels_removed"] = removed_levels
526
- meta["changed"] = text != original_text
527
- return text, meta
849
+ block = _managed_block_bytes(render_brief_mode_block(brief_mode))
850
+ if span is None:
851
+ data = _append_managed_block_bytes(data, block)
852
+ meta["brief_changed"] = True
853
+ elif data[span.start : span.end] != block:
854
+ data = _replace_managed_span(data, span, block)
855
+ meta["brief_changed"] = True
856
+ meta["changed"] = data != original
857
+ return data, meta
858
+
859
+
860
+ def compose_rule_file_text(
861
+ existing: str | None,
862
+ *,
863
+ with_init: bool,
864
+ brief_mode: str | None,
865
+ ) -> tuple[str, dict[str, Any]]:
866
+ """Compatibility text wrapper around the byte-exact managed composer."""
867
+ rendered, meta = compose_rule_file_bytes(
868
+ existing.encode("utf-8") if existing is not None else None,
869
+ with_init=with_init,
870
+ brief_mode=brief_mode,
871
+ )
872
+ return rendered.decode("utf-8"), meta
528
873
 
529
874
 
530
875
  def plan_or_write_rule_file_blocks(
@@ -534,7 +879,7 @@ def plan_or_write_rule_file_blocks(
534
879
  brief_mode: str | None,
535
880
  applied: bool,
536
881
  ) -> dict[str, Any]:
537
- """Plan or apply managed rule-file blocks with one original backup per changed existing write."""
882
+ """Plan/apply exact managed spans through the shared cooperative writer."""
538
883
  result: dict[str, Any] = {
539
884
  "status": None,
540
885
  "planned_actions": [],
@@ -551,27 +896,38 @@ def plan_or_write_rule_file_blocks(
551
896
  result["planned_actions"].append(reason)
552
897
  return result
553
898
 
554
- existing = state.get("text")
555
- existing_text = str(existing or "")
556
- result["brief_mode_existing_levels"] = _brief_mode_levels_in_text(existing_text)
557
- rule_present = existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing_text
558
- planned_meta: dict[str, Any] | None = None
559
- if brief_mode:
560
- _, planned_meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
899
+ existing = state.get("bytes")
900
+ snapshot = state["snapshot"]
901
+ existing_bytes = bytes(existing or b"")
902
+ result["brief_mode_existing_levels"] = _brief_mode_levels_in_bytes(existing_bytes)
903
+ repo_state = parse_managed_bytes(existing_bytes, kind="repo-rules")
904
+ rule_present = repo_state.status == "valid"
905
+ try:
906
+ final_bytes, planned_meta = compose_rule_file_bytes(
907
+ existing,
908
+ with_init=with_init,
909
+ brief_mode=brief_mode,
910
+ )
911
+ except ValueError as exc:
912
+ reason = f"refused unsafe managed rule state in {path.name}: {exc}"
913
+ result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
914
+ result["planned_actions"].append(reason)
915
+ return result
561
916
 
562
917
  if with_init:
563
- if rule_present:
918
+ if rule_present and not planned_meta["init_changed"]:
564
919
  result["status"] = "exists"
565
920
  result["planned_actions"].append("advisory ContextGuard rules already present")
566
921
  elif not applied:
567
922
  result["status"] = "planned"
568
- result["planned_actions"].append("would add advisory ContextGuard rules")
923
+ verb = "migrate" if planned_meta.get("init_migrated_legacy") else "add"
924
+ result["planned_actions"].append(f"would {verb} advisory ContextGuard rules")
569
925
  elif not brief_mode:
570
926
  result["status"] = "planned"
571
927
  result["planned_actions"].append("run with --with-init to add advisory ContextGuard rules")
572
928
 
573
929
  if brief_mode:
574
- brief_changed = bool(planned_meta and planned_meta.get("brief_changed"))
930
+ brief_changed = bool(planned_meta.get("brief_changed"))
575
931
  if brief_mode == BRIEF_MODE_OFF:
576
932
  if brief_changed:
577
933
  result["brief_mode_status"] = "planned" if not applied else None
@@ -595,7 +951,7 @@ def plan_or_write_rule_file_blocks(
595
951
  result["status"] = "planned" if result["planned_actions"] else "unchanged"
596
952
  return result
597
953
 
598
- final_text, meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
954
+ meta = planned_meta
599
955
  if not meta["changed"]:
600
956
  if result["status"] is None:
601
957
  result["status"] = "exists" if rule_present else "unchanged"
@@ -603,33 +959,29 @@ def plan_or_write_rule_file_blocks(
603
959
  result["brief_mode_status"] = "absent" if brief_mode == BRIEF_MODE_OFF else "exists"
604
960
  return result
605
961
 
606
- backup_path = None
607
- if existing is not None:
608
- try:
609
- backup_path = backup_existing(path)
610
- except OSError as exc:
611
- reason = f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"
612
- result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
613
- result["planned_actions"] = [reason]
614
- return result
615
- durability_warning = None
616
- try:
617
- atomic_write(
618
- path,
619
- final_text,
620
- existing_mode_or_default(path, 0o644) if existing is not None else 0o644,
621
- dir_mode=0o755,
622
- )
623
- except AtomicWriteDurabilityError as exc:
624
- durability_warning = str(exc)
625
- except OSError as exc:
626
- reason = f"could not write repo rule file {path.name}: {exc.__class__.__name__}"
627
- result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
962
+ write_result = write_managed_file(
963
+ path,
964
+ expected=snapshot,
965
+ desired=final_bytes,
966
+ mode=0o644,
967
+ dir_mode=0o755,
968
+ )
969
+ if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
970
+ reason = write_result.get("reason") or f"could not write repo rule file {path.name}"
971
+ result.update({
972
+ "status": write_result["status"],
973
+ "brief_mode_status": write_result["status"],
974
+ "reason": reason,
975
+ })
628
976
  result["planned_actions"] = [reason]
629
977
  return result
630
-
631
- if backup_path:
632
- result["brief_mode_backup_path"] = str(backup_path)
978
+ if write_result.get("backup_path"):
979
+ result["brief_mode_backup_path"] = write_result["backup_path"]
980
+ durability_warning = (
981
+ write_result.get("reason")
982
+ if write_result["status"] == "applied-durability-uncertain"
983
+ else None
984
+ )
633
985
  if durability_warning:
634
986
  result["status"] = "applied-durability-uncertain"
635
987
  result["reason"] = durability_warning
@@ -641,7 +993,7 @@ def plan_or_write_rule_file_blocks(
641
993
  else:
642
994
  result["planned_actions"].append("advisory ContextGuard rules already present")
643
995
  elif result["status"] is None:
644
- result["status"] = "unchanged"
996
+ result["status"] = "applied"
645
997
  if brief_mode:
646
998
  if brief_mode == BRIEF_MODE_OFF:
647
999
  result["brief_mode_status"] = "removed" if meta["brief_changed"] else "absent"
@@ -686,6 +1038,7 @@ def _existing_rule_parent_issue(path: Path) -> str | None:
686
1038
  because plan/apply must agree and must never follow an attacker-swapped rule
687
1039
  directory outside the project.
688
1040
  """
1041
+ path = _normalize_allowed_first_absolute_symlink(path)
689
1042
  parts = path.parts[1:-1] if path.is_absolute() else path.parts[:-1]
690
1043
  if not parts:
691
1044
  return None
@@ -706,14 +1059,20 @@ def _existing_rule_parent_issue(path: Path) -> str | None:
706
1059
 
707
1060
 
708
1061
  def _rule_file_state(path: Path) -> dict[str, Any]:
709
- """Return a non-throwing state for project rule/skill files."""
1062
+ """Return a non-throwing exact-byte snapshot for project rule/skill files."""
710
1063
  parent_issue = _existing_rule_parent_issue(path)
711
1064
  if parent_issue:
712
1065
  return {"status": "unsafe", "text": None, "reason": parent_issue}
713
1066
  try:
714
1067
  st = os.lstat(path)
715
1068
  except FileNotFoundError:
716
- return {"status": "missing", "text": None, "reason": None}
1069
+ return {
1070
+ "status": "missing",
1071
+ "text": None,
1072
+ "bytes": None,
1073
+ "snapshot": ManagedFileSnapshot(None, None),
1074
+ "reason": None,
1075
+ }
717
1076
  except OSError as exc:
718
1077
  return {"status": "unsafe", "text": None, "reason": f"could not inspect rule file: {exc.__class__.__name__}"}
719
1078
  if stat.S_ISLNK(st.st_mode):
@@ -721,20 +1080,35 @@ def _rule_file_state(path: Path) -> dict[str, Any]:
721
1080
  if stat.S_ISDIR(st.st_mode):
722
1081
  return {"status": "directory", "text": None, "reason": f"refused to replace directory rule target: {path.name}"}
723
1082
  try:
724
- text = _read_text_no_follow(path)
1083
+ snapshot = read_managed_file_snapshot(path)
725
1084
  except OSError as exc:
726
1085
  return {
727
1086
  "status": "unsafe",
728
1087
  "text": None,
1088
+ "bytes": None,
729
1089
  "reason": f"could not read rule file without following symlinks: {exc.__class__.__name__}",
730
1090
  }
731
- return {"status": "file", "text": text, "reason": None}
1091
+ data = snapshot.data or b""
1092
+ try:
1093
+ text = data.decode("utf-8")
1094
+ except UnicodeDecodeError:
1095
+ text = None
1096
+ return {
1097
+ "status": "file",
1098
+ "text": text,
1099
+ "bytes": data,
1100
+ "snapshot": snapshot,
1101
+ "reason": None,
1102
+ }
732
1103
 
733
1104
 
734
1105
  def repo_rule_block_present(path: Path) -> bool:
735
1106
  """True when the advisory ContextGuard block already exists in the rule file."""
736
1107
  state = _rule_file_state(path)
737
- return state["status"] == "file" and ADAPTER_RULE_BLOCK_BEGIN in str(state.get("text") or "")
1108
+ return (
1109
+ state["status"] == "file"
1110
+ and parse_managed_bytes(bytes(state.get("bytes") or b""), kind="repo-rules").status == "valid"
1111
+ )
738
1112
 
739
1113
 
740
1114
  def write_repo_rule_init(path: Path) -> dict[str, Any]:
@@ -747,36 +1121,24 @@ def write_repo_rule_init(path: Path) -> dict[str, Any]:
747
1121
  state = _rule_file_state(path)
748
1122
  if state["status"] not in {"missing", "file"}:
749
1123
  return {"status": "skipped", "reason": state.get("reason") or f"refused unsafe rule target: {path.name}"}
750
- existing = state.get("text")
751
- if existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing:
752
- return {"status": "exists"}
753
- block = render_repo_rule_block()
754
- if existing:
755
- new_text = existing.rstrip("\n") + "\n\n" + block + "\n"
756
- else:
757
- new_text = block + "\n"
758
- mode = existing_mode_or_default(path, 0o644) if existing is not None else 0o644
759
- backup_path = None
760
- if existing is not None:
761
- try:
762
- backup_path = backup_existing(path)
763
- except OSError as exc:
764
- return {"status": "skipped", "reason": f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"}
765
- durability_warning = None
766
1124
  try:
767
- atomic_write(path, new_text, mode, dir_mode=0o755)
768
- except AtomicWriteDurabilityError as exc:
769
- durability_warning = str(exc)
770
- except OSError as exc:
771
- result = {"status": "skipped", "reason": f"could not write repo rule file {path.name}: {exc.__class__.__name__}"}
772
- if backup_path:
773
- result["backup_path"] = str(backup_path)
774
- return result
775
- result = {"status": "applied", "backup_path": str(backup_path) if backup_path else None}
776
- if durability_warning:
777
- result["status"] = "applied-durability-uncertain"
778
- result["reason"] = durability_warning
779
- return result
1125
+ final, meta = compose_rule_file_bytes(
1126
+ state.get("bytes"),
1127
+ with_init=True,
1128
+ brief_mode=None,
1129
+ )
1130
+ except ValueError as exc:
1131
+ return {"status": "skipped", "reason": f"refused unsafe managed rule state: {exc}"}
1132
+ if not meta["changed"]:
1133
+ return {"status": "exists"}
1134
+ write_result = write_managed_file(
1135
+ path,
1136
+ expected=state["snapshot"],
1137
+ desired=final,
1138
+ mode=0o644,
1139
+ dir_mode=0o755,
1140
+ )
1141
+ return write_result
780
1142
 
781
1143
 
782
1144
  def codex_skill_status(path: Path) -> str:
@@ -785,10 +1147,17 @@ def codex_skill_status(path: Path) -> str:
785
1147
  return "missing"
786
1148
  if state["status"] != "file":
787
1149
  return "unsafe"
788
- text = str(state.get("text") or "")
789
- if text == render_codex_skill():
1150
+ data = bytes(state.get("bytes") or b"")
1151
+ if data == render_codex_skill().encode("utf-8"):
790
1152
  return "exists"
791
- if CODEX_SKILL_MARKER_BEGIN in text and CODEX_SKILL_MARKER_END in text:
1153
+ parsed = parse_managed_bytes(data, kind="codex-skill")
1154
+ if parsed.status != "valid":
1155
+ return "foreign"
1156
+ span = parsed.spans[0]
1157
+ if span.version == 0:
1158
+ digest = hashlib.sha256(data).hexdigest()
1159
+ return "update-needed" if digest in LEGACY_CODEX_SKILL_SHA256_ALLOWLIST else "foreign"
1160
+ if span.version == 1:
792
1161
  return "update-needed"
793
1162
  return "foreign"
794
1163
 
@@ -806,11 +1175,29 @@ def write_codex_project_skill(path: Path) -> dict[str, Any]:
806
1175
  "status": "skipped",
807
1176
  "reason": f"refused to overwrite non-ContextGuard Codex skill file: {path}",
808
1177
  }
809
- try:
810
- atomic_write(path, render_codex_skill(), 0o644, dir_mode=0o755)
811
- except OSError as exc:
812
- return {"status": "skipped", "reason": f"could not write Codex skill file {path}: {exc.__class__.__name__}"}
813
- return {"status": "updated" if status == "update-needed" else "applied"}
1178
+ existing = state.get("bytes")
1179
+ if status == "missing":
1180
+ desired = render_codex_skill().encode("utf-8")
1181
+ else:
1182
+ data = bytes(existing or b"")
1183
+ parsed = parse_managed_bytes(data, kind="codex-skill")
1184
+ span = parsed.spans[0]
1185
+ if span.version == 0:
1186
+ desired = render_codex_skill().encode("utf-8")
1187
+ else:
1188
+ desired = _replace_managed_span(data, span, render_codex_skill_block_bytes())
1189
+ result = write_managed_file(
1190
+ path,
1191
+ expected=state["snapshot"],
1192
+ desired=desired,
1193
+ mode=0o644,
1194
+ dir_mode=0o755,
1195
+ )
1196
+ if result["status"] == "applied":
1197
+ result["status"] = "updated" if status == "update-needed" else "applied"
1198
+ elif result["status"] == "applied-durability-uncertain":
1199
+ result["change_kind"] = "updated" if status == "update-needed" else "applied"
1200
+ return result
814
1201
 
815
1202
 
816
1203
  def adapter_rule_path(root: Path, adapter: AgentAdapter) -> Path | None:
@@ -1005,14 +1392,23 @@ def build_adapter_plan(
1005
1392
  entry["planned_actions"].append(
1006
1393
  f"would generate project Codex skill at {adapter.project_skill_rel}"
1007
1394
  )
1395
+ elif entry["status"] == "applied-durability-uncertain":
1396
+ entry["project_skill_status"] = "blocked-durability-uncertain"
1397
+ entry["planned_actions"].append(
1398
+ "blocked project Codex skill write because the preceding rule-file "
1399
+ "commit has uncertain directory durability"
1400
+ )
1008
1401
  else:
1009
1402
  skill_result = write_codex_project_skill(skill_path)
1010
1403
  entry["project_skill_status"] = skill_result["status"]
1011
- if skill_result["status"] in {"applied", "updated"}:
1404
+ if skill_result["status"] in {"applied", "updated", "applied-durability-uncertain"}:
1012
1405
  action = f"wrote project Codex skill to {adapter.project_skill_rel}"
1013
1406
  entry["applied_actions"].append(action)
1014
1407
  entry["planned_actions"].append(action)
1015
- if entry["status"] in {"planned", "exists", "unchanged"}:
1408
+ if skill_result["status"] == "applied-durability-uncertain":
1409
+ entry["status"] = "applied-durability-uncertain"
1410
+ entry["reason"] = skill_result.get("reason")
1411
+ elif entry["status"] in {"planned", "exists", "unchanged"}:
1016
1412
  entry["status"] = "applied"
1017
1413
  elif skill_result["status"] == "exists":
1018
1414
  entry["planned_actions"].append(
@@ -1272,10 +1668,10 @@ def _ensure_directory_no_symlink(path: Path, mode: int | None = None, *, parents
1272
1668
  raise
1273
1669
 
1274
1670
 
1275
- def _read_text_no_follow(path: Path) -> str:
1671
+ def _read_bytes_no_follow(path: Path) -> bytes:
1276
1672
  fd = _open_regular_no_symlink(path)
1277
1673
  try:
1278
- with os.fdopen(fd, "r", encoding="utf-8") as handle:
1674
+ with os.fdopen(fd, "rb") as handle:
1279
1675
  fd = -1
1280
1676
  return handle.read()
1281
1677
  finally:
@@ -1283,6 +1679,47 @@ def _read_text_no_follow(path: Path) -> str:
1283
1679
  os.close(fd)
1284
1680
 
1285
1681
 
1682
+ def _read_text_no_follow(path: Path) -> str:
1683
+ return _read_bytes_no_follow(path).decode("utf-8")
1684
+
1685
+
1686
+ def _snapshot_metadata(st: os.stat_result) -> tuple[int, int, int, int, int]:
1687
+ return (
1688
+ int(st.st_dev),
1689
+ int(st.st_ino),
1690
+ int(st.st_mode),
1691
+ int(st.st_size),
1692
+ int(st.st_mtime_ns),
1693
+ )
1694
+
1695
+
1696
+ def read_managed_file_snapshot(path: Path) -> ManagedFileSnapshot:
1697
+ """Read an exact byte+metadata snapshot without following target/parent links."""
1698
+ try:
1699
+ fd = _open_regular_no_symlink(path)
1700
+ except FileNotFoundError:
1701
+ return ManagedFileSnapshot(None, None)
1702
+ try:
1703
+ before = os.fstat(fd)
1704
+ with os.fdopen(fd, "rb") as handle:
1705
+ fd = -1
1706
+ data = handle.read()
1707
+ after = os.fstat(handle.fileno())
1708
+ if _snapshot_metadata(before) != _snapshot_metadata(after) or len(data) != after.st_size:
1709
+ raise ManagedFileConflictError(f"managed target changed during read: {path}")
1710
+ return ManagedFileSnapshot(data, _snapshot_metadata(after))
1711
+ finally:
1712
+ if fd != -1:
1713
+ os.close(fd)
1714
+
1715
+
1716
+ def _verify_expected_snapshot(path: Path, expected: ManagedFileSnapshot) -> ManagedFileSnapshot:
1717
+ current = read_managed_file_snapshot(path)
1718
+ if current != expected:
1719
+ raise ManagedFileConflictError(f"managed target changed since planning: {path}")
1720
+ return current
1721
+
1722
+
1286
1723
  def _read_optional_text_no_follow(path: Path) -> str | None:
1287
1724
  try:
1288
1725
  return _read_text_no_follow(path)
@@ -1318,7 +1755,12 @@ def load_json_object(path: Path) -> dict[str, Any]:
1318
1755
  return _parse_json_object_text(_read_optional_text_no_follow(path), path)
1319
1756
 
1320
1757
 
1321
- def ensure_permissions(settings: dict[str, Any], actions: list[str]) -> None:
1758
+ def ensure_permissions(
1759
+ settings: dict[str, Any],
1760
+ actions: list[str],
1761
+ *,
1762
+ migrate_env_read_denies: bool = False,
1763
+ ) -> None:
1322
1764
  permissions = settings.get("permissions")
1323
1765
  if permissions is None:
1324
1766
  permissions = {}
@@ -1331,6 +1773,21 @@ def ensure_permissions(settings: dict[str, Any], actions: list[str]) -> None:
1331
1773
  permissions["deny"] = deny
1332
1774
  if not isinstance(deny, list):
1333
1775
  raise SystemExit("Refusing to replace non-list settings.permissions.deny; repair it manually first.")
1776
+ if migrate_env_read_denies:
1777
+ retained = [
1778
+ rule
1779
+ for rule in deny
1780
+ if not (
1781
+ isinstance(rule, str)
1782
+ and rule in PRODUCT_OWNED_ENV_READ_DENIES
1783
+ )
1784
+ ]
1785
+ removed = len(deny) - len(retained)
1786
+ if removed:
1787
+ deny[:] = retained
1788
+ actions.append(
1789
+ f"removed {removed} obsolete permissions.deny rules now enforced by the Claude Read hook"
1790
+ )
1334
1791
  added = 0
1335
1792
  for rule in RECOMMENDED_DENIES:
1336
1793
  if rule not in deny:
@@ -1649,6 +2106,16 @@ def ensure_post_tool_hook(settings: dict[str, Any], hook: dict[str, Any], comman
1649
2106
  _ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUse")
1650
2107
 
1651
2108
 
2109
+ def ensure_post_tool_failure_hook(
2110
+ settings: dict[str, Any],
2111
+ hook: dict[str, Any],
2112
+ command: str,
2113
+ label: str,
2114
+ actions: list[str],
2115
+ ) -> None:
2116
+ _ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUseFailure")
2117
+
2118
+
1652
2119
  def _ensure_tool_hook(
1653
2120
  settings: dict[str, Any],
1654
2121
  hook: dict[str, Any],
@@ -2136,7 +2603,11 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
2136
2603
  elif settings.get("statusLine") != statusline:
2137
2604
  actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
2138
2605
  if choices.denies:
2139
- ensure_permissions(settings, actions)
2606
+ ensure_permissions(
2607
+ settings,
2608
+ actions,
2609
+ migrate_env_read_denies=choices.read_guard,
2610
+ )
2140
2611
  if choices.bash_hook:
2141
2612
  bash_hook = bash_hook_setting(allow_path_fallback=allow_path_fallback)
2142
2613
  bash_command = bash_hook["hooks"][0]["command"]
@@ -2149,10 +2620,24 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
2149
2620
  nudge_hook = failed_nudge_setting(allow_path_fallback=allow_path_fallback)
2150
2621
  nudge_command = nudge_hook["hooks"][0]["command"]
2151
2622
  ensure_post_tool_hook(settings, nudge_hook, nudge_command, "failed-attempt /clear nudge", actions)
2623
+ ensure_post_tool_failure_hook(
2624
+ settings,
2625
+ nudge_hook,
2626
+ nudge_command,
2627
+ "failed-attempt /clear nudge",
2628
+ actions,
2629
+ )
2152
2630
  return actions
2153
2631
 
2154
2632
 
2155
- def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PRIVATE_DIR_MODE) -> None:
2633
+ def atomic_write_bytes(
2634
+ path: Path,
2635
+ data: bytes,
2636
+ mode: int = 0o600,
2637
+ *,
2638
+ dir_mode: int = PRIVATE_DIR_MODE,
2639
+ expected: ManagedFileSnapshot | None = None,
2640
+ ) -> None:
2156
2641
  if os.rename not in os.supports_dir_fd or os.unlink not in os.supports_dir_fd:
2157
2642
  raise OSError("platform does not support directory-relative atomic writes")
2158
2643
  parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
@@ -2162,12 +2647,14 @@ def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PR
2162
2647
  try:
2163
2648
  if hasattr(os, "fchmod"):
2164
2649
  os.fchmod(fd, mode)
2165
- with os.fdopen(fd, "w", encoding="utf-8") as f:
2650
+ with os.fdopen(fd, "wb") as f:
2166
2651
  fd = -1
2167
- f.write(text)
2652
+ f.write(data)
2168
2653
  f.flush()
2169
2654
  os.fsync(f.fileno())
2170
2655
  os.fsync(parent_fd)
2656
+ if expected is not None:
2657
+ _verify_expected_snapshot(path, expected)
2171
2658
  os.rename(tmp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
2172
2659
  try:
2173
2660
  os.fsync(parent_fd)
@@ -2185,6 +2672,37 @@ def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PR
2185
2672
  os.close(parent_fd)
2186
2673
 
2187
2674
 
2675
+ def atomic_write(
2676
+ path: Path,
2677
+ content: str | bytes,
2678
+ mode: int = 0o600,
2679
+ *,
2680
+ dir_mode: int = PRIVATE_DIR_MODE,
2681
+ ) -> None:
2682
+ data = content if isinstance(content, bytes) else content.encode("utf-8")
2683
+ atomic_write_bytes(path, data, mode, dir_mode=dir_mode)
2684
+
2685
+
2686
+ def _atomic_remove_expected(
2687
+ path: Path,
2688
+ expected: ManagedFileSnapshot,
2689
+ *,
2690
+ dir_mode: int,
2691
+ ) -> None:
2692
+ parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
2693
+ try:
2694
+ _verify_expected_snapshot(path, expected)
2695
+ os.unlink(path.name, dir_fd=parent_fd)
2696
+ try:
2697
+ os.fsync(parent_fd)
2698
+ except OSError as exc:
2699
+ raise AtomicWriteDurabilityError(
2700
+ f"remove committed but parent directory durability is uncertain: {path}"
2701
+ ) from exc
2702
+ finally:
2703
+ os.close(parent_fd)
2704
+
2705
+
2188
2706
  def existing_mode_or_default(path: Path, default: int = 0o600) -> int:
2189
2707
  try:
2190
2708
  fd = _open_regular_no_symlink(path)
@@ -2210,6 +2728,175 @@ def backup_existing(path: Path) -> Path | None:
2210
2728
  return backup
2211
2729
 
2212
2730
 
2731
+ def managed_lock_path(path: Path) -> Path:
2732
+ return path.with_name(f".{path.name}.lock")
2733
+
2734
+
2735
+ def acquire_managed_file_lock(path: Path, *, dir_mode: int = PRIVATE_DIR_MODE) -> int:
2736
+ """Acquire the shared sibling lock used by all managed forward/rollback writers."""
2737
+ if fcntl is None:
2738
+ raise OSError("platform does not support advisory file locks")
2739
+ parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
2740
+ lock_name = managed_lock_path(path).name
2741
+ flags = os.O_CREAT | os.O_RDWR | _no_follow_flag()
2742
+ if hasattr(os, "O_CLOEXEC"):
2743
+ flags |= os.O_CLOEXEC
2744
+ fd: int | None = None
2745
+ try:
2746
+ for attempt in range(3):
2747
+ try:
2748
+ fd = os.open(lock_name, flags, 0o600, dir_fd=parent_fd)
2749
+ break
2750
+ except FileNotFoundError:
2751
+ if attempt == 2:
2752
+ raise
2753
+ time.sleep(0.001)
2754
+ except OSError as exc:
2755
+ raise OSError(f"could not open cooperative lock {managed_lock_path(path)}: {exc}") from exc
2756
+ finally:
2757
+ os.close(parent_fd)
2758
+ if fd is None:
2759
+ raise OSError(f"could not open cooperative lock {managed_lock_path(path)}")
2760
+ try:
2761
+ st = os.fstat(fd)
2762
+ if not stat.S_ISREG(st.st_mode):
2763
+ raise OSError(f"cooperative lock is not a regular file: {managed_lock_path(path)}")
2764
+ if hasattr(os, "fchmod"):
2765
+ os.fchmod(fd, 0o600)
2766
+ fcntl.flock(fd, fcntl.LOCK_EX)
2767
+ return fd
2768
+ except Exception:
2769
+ os.close(fd)
2770
+ raise
2771
+
2772
+
2773
+ def release_managed_file_lock(fd: int) -> None:
2774
+ try:
2775
+ if fcntl is not None:
2776
+ fcntl.flock(fd, fcntl.LOCK_UN)
2777
+ finally:
2778
+ os.close(fd)
2779
+
2780
+
2781
+ def _managed_backup(path: Path, data: bytes, *, dir_mode: int) -> Path:
2782
+ stamp = _dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
2783
+ backup = path.with_name(f"{path.name}.bak-{stamp}-{uuid.uuid4().hex[:8]}")
2784
+ atomic_write_bytes(backup, data, 0o600, dir_mode=dir_mode)
2785
+ return backup
2786
+
2787
+
2788
+ def write_managed_file(
2789
+ path: Path,
2790
+ *,
2791
+ expected: ManagedFileSnapshot,
2792
+ desired: bytes | None,
2793
+ mode: int = 0o644,
2794
+ dir_mode: int = 0o755,
2795
+ create_backup: bool = True,
2796
+ prepare_commit: Any = None,
2797
+ ) -> dict[str, Any]:
2798
+ """Apply one cooperative byte-exact transaction or return a fail-closed status."""
2799
+ if desired == expected.data:
2800
+ return {"status": "unchanged", "backup_path": None}
2801
+ try:
2802
+ lock_fd = acquire_managed_file_lock(path, dir_mode=dir_mode)
2803
+ except OSError as exc:
2804
+ return {"status": "skipped", "reason": f"could not acquire cooperative lock: {exc}"}
2805
+ backup_path: Path | None = None
2806
+ try:
2807
+ try:
2808
+ current = _verify_expected_snapshot(path, expected)
2809
+ except ManagedFileConflictError as exc:
2810
+ return {"status": "conflict", "reason": str(exc), "backup_path": None}
2811
+
2812
+ target_mode = mode
2813
+ if current.metadata is not None:
2814
+ target_mode = stat.S_IMODE(current.metadata[2])
2815
+ if current.data is not None and create_backup:
2816
+ try:
2817
+ backup_path = _managed_backup(path, current.data, dir_mode=dir_mode)
2818
+ except OSError as exc:
2819
+ return {
2820
+ "status": "skipped",
2821
+ "reason": f"could not create private managed-file backup: {exc}",
2822
+ "backup_path": None,
2823
+ }
2824
+ try:
2825
+ if desired is None:
2826
+ if prepare_commit is not None:
2827
+ prepare_commit(backup_path)
2828
+ _atomic_remove_expected(path, current, dir_mode=dir_mode)
2829
+ else:
2830
+ _verify_expected_snapshot(path, current)
2831
+ if prepare_commit is not None:
2832
+ prepare_commit(backup_path)
2833
+ atomic_write(
2834
+ path,
2835
+ desired,
2836
+ target_mode,
2837
+ dir_mode=dir_mode,
2838
+ )
2839
+ except ManagedFileConflictError as exc:
2840
+ return {
2841
+ "status": "conflict",
2842
+ "reason": str(exc),
2843
+ "backup_path": str(backup_path) if backup_path else None,
2844
+ }
2845
+ except AtomicWriteDurabilityError as exc:
2846
+ return {
2847
+ "status": "applied-durability-uncertain",
2848
+ "reason": str(exc),
2849
+ "backup_path": str(backup_path) if backup_path else None,
2850
+ "residual_risk": (
2851
+ "A non-cooperating editor can still race after the final comparison; "
2852
+ "automatic follow-on mutation is blocked."
2853
+ ),
2854
+ }
2855
+ except OSError as exc:
2856
+ return {
2857
+ "status": "skipped",
2858
+ "reason": f"could not commit managed file: {exc}",
2859
+ "backup_path": str(backup_path) if backup_path else None,
2860
+ }
2861
+ return {
2862
+ "status": "applied",
2863
+ "backup_path": str(backup_path) if backup_path else None,
2864
+ "residual_risk": (
2865
+ "Cooperating ContextGuard writers serialize; a non-cooperating editor can still race "
2866
+ "after the final comparison and before atomic replace."
2867
+ ),
2868
+ }
2869
+ finally:
2870
+ release_managed_file_lock(lock_fd)
2871
+
2872
+
2873
+ def rollback_managed_file(
2874
+ path: Path,
2875
+ *,
2876
+ expected_post: ManagedFileSnapshot,
2877
+ restore: bytes | None,
2878
+ kind: str,
2879
+ mode: int = 0o644,
2880
+ dir_mode: int = 0o755,
2881
+ ) -> dict[str, Any]:
2882
+ """Rollback only a still-matching post-image with the same parser/lock authority."""
2883
+ if expected_post.data is None:
2884
+ return {"status": "skipped", "reason": "rollback post-image is missing"}
2885
+ ownership = parse_managed_bytes(expected_post.data, kind=kind)
2886
+ if ownership.status != "valid":
2887
+ return {
2888
+ "status": "skipped",
2889
+ "reason": f"rollback lacks valid {kind} ownership: {ownership.status}",
2890
+ }
2891
+ return write_managed_file(
2892
+ path,
2893
+ expected=expected_post,
2894
+ desired=restore,
2895
+ mode=mode,
2896
+ dir_mode=dir_mode,
2897
+ )
2898
+
2899
+
2213
2900
  def rollback_restore_guidance(settings_path: Path, backup_path: Path | None, original_existed: bool) -> str:
2214
2901
  if backup_path is not None:
2215
2902
  return (
@@ -2320,7 +3007,7 @@ def interactive_choices(defaults: Choices) -> Choices:
2320
3007
  read_guard=prompt_bool("Enable large Read guard?", defaults.read_guard),
2321
3008
  model_defaults=prompt_bool("Set missing defaults to model=sonnet and effortLevel=medium?", defaults.model_defaults),
2322
3009
  failed_attempt_nudge=prompt_bool(
2323
- "Enable failed-attempt /clear nudge? (PostToolUse hook on Bash; recommended default)",
3010
+ "Enable failed-attempt /clear nudge? (Bash terminal-event hooks; recommended default)",
2324
3011
  defaults.failed_attempt_nudge,
2325
3012
  ),
2326
3013
  )
@@ -2396,6 +3083,201 @@ def render_text(result: SetupResult) -> str:
2396
3083
  return "\n".join(lines) + "\n"
2397
3084
 
2398
3085
 
3086
+ def validate_rules_only_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> bool:
3087
+ """Validate and identify the isolated Claude quiet-narration CLI branch."""
3088
+ rules_only = bool(getattr(args, "rules_only", False))
3089
+ narration_mode = getattr(args, "narration_mode", None)
3090
+ if narration_mode and not rules_only:
3091
+ parser.error("--narration-mode requires --rules-only")
3092
+ if rules_only and not narration_mode:
3093
+ parser.error("--rules-only requires a rule operation such as --narration-mode")
3094
+ if not rules_only:
3095
+ return False
3096
+
3097
+ if getattr(args, "scope", "project") != "project":
3098
+ parser.error("quiet narration rules support only --scope project")
3099
+ selected = [item.lower() for item in (explicit_agent_selection(args) or [])]
3100
+ if selected != ["claude"] or not getattr(args, "agent", None) or getattr(args, "only", None):
3101
+ parser.error("quiet narration rules require exactly one explicit --agent claude")
3102
+ action_count = sum(
3103
+ bool(value)
3104
+ for value in (
3105
+ getattr(args, "yes", False),
3106
+ getattr(args, "plan", False),
3107
+ getattr(args, "dry_run", False),
3108
+ )
3109
+ )
3110
+ if action_count != 1:
3111
+ parser.error("quiet narration rules require exactly one of --plan, --dry-run, or --yes")
3112
+
3113
+ conflicting = [
3114
+ flag
3115
+ for attr, flag in (
3116
+ ("allow_home_settings", "--allow-home-settings"),
3117
+ ("verify", "--verify"),
3118
+ ("no_backup", "--no-backup"),
3119
+ ("no_denies", "--no-denies"),
3120
+ ("no_statusline", "--no-statusline"),
3121
+ ("no_bash_hook", "--no-bash-hook"),
3122
+ ("no_read_guard", "--no-read-guard"),
3123
+ ("no_model_defaults", "--no-model-defaults"),
3124
+ ("no_diet_scan", "--no-diet-scan"),
3125
+ ("allow_path_helper_fallback", "--allow-path-helper-fallback"),
3126
+ ("with_init", "--with-init"),
3127
+ ("with_skill", "--with-skill"),
3128
+ ("brief_mode", "--brief-mode"),
3129
+ ("list_adapters", "--list-adapters"),
3130
+ )
3131
+ if getattr(args, attr, False)
3132
+ ]
3133
+ if getattr(args, "failed_attempt_nudge", None) is not None:
3134
+ conflicting.append(
3135
+ "--failed-attempt-nudge"
3136
+ if args.failed_attempt_nudge
3137
+ else "--no-failed-attempt-nudge"
3138
+ )
3139
+ if conflicting:
3140
+ parser.error(
3141
+ "quiet narration rules cannot be combined with settings, hook, adapter, "
3142
+ f"or setup flags: {', '.join(conflicting)}"
3143
+ )
3144
+ return True
3145
+
3146
+
3147
+ def run_quiet_narration_rules(args: argparse.Namespace) -> dict[str, Any]:
3148
+ """Plan/apply the isolated Claude/project CLAUDE.md narration span."""
3149
+ require_no_follow_file_ops_supported()
3150
+ root = resolve_setup_root(args.root)
3151
+ rule_path = root / "CLAUDE.md"
3152
+ state = _rule_file_state(rule_path)
3153
+ if state["status"] not in {"missing", "file"}:
3154
+ raise SystemExit(
3155
+ state.get("reason")
3156
+ or f"refused unsafe quiet narration rule target: {rule_path}"
3157
+ )
3158
+ existing = bytes(state.get("bytes") or b"")
3159
+ parsed = parse_managed_bytes(existing, kind="narration-mode")
3160
+ if parsed.status not in {"absent", "valid"}:
3161
+ raise SystemExit(
3162
+ f"refused unsafe managed narration state in {rule_path.name}: "
3163
+ f"{parsed.reason or parsed.status}"
3164
+ )
3165
+
3166
+ mode = str(args.narration_mode)
3167
+ span = parsed.spans[0] if parsed.status == "valid" else None
3168
+ desired = existing
3169
+ if mode == "quiet":
3170
+ block = _managed_block_bytes(render_quiet_narration_block())
3171
+ if span is None:
3172
+ desired = _append_narration_block_bytes(existing, block)
3173
+ elif existing[span.start : span.end] != block:
3174
+ desired = _replace_managed_span(existing, span, block)
3175
+ elif span is not None:
3176
+ removal_start = span.start
3177
+ if removal_start > 0 and existing[removal_start - 1 : removal_start] == b"\n":
3178
+ removal_start -= 1
3179
+ desired = existing[:removal_start] + existing[span.end :]
3180
+
3181
+ changed = desired != existing
3182
+ apply_requested = bool(args.yes)
3183
+ if not changed:
3184
+ status = "exists" if mode == "quiet" else "absent"
3185
+ return {
3186
+ "schema_version": "contextguard.narration-rules.v1",
3187
+ "operation": "quiet-narration",
3188
+ "mode": mode,
3189
+ "root": str(root),
3190
+ "rule_file": str(rule_path),
3191
+ "marker_state_before": parsed.status,
3192
+ "status": status,
3193
+ "changed": False,
3194
+ "applied": False,
3195
+ "apply_requested": apply_requested,
3196
+ "backup_path": None,
3197
+ "actions": [
3198
+ "quiet narration rules already present"
3199
+ if mode == "quiet"
3200
+ else "quiet narration rules already absent"
3201
+ ],
3202
+ "claim_boundary": "static setup result only; no model-compliance or savings claim",
3203
+ }
3204
+
3205
+ planned_status = "planned"
3206
+ action = (
3207
+ ("add" if span is None else "refresh") + " quiet narration rules"
3208
+ if mode == "quiet"
3209
+ else "remove quiet narration rules"
3210
+ )
3211
+ if not apply_requested:
3212
+ return {
3213
+ "schema_version": "contextguard.narration-rules.v1",
3214
+ "operation": "quiet-narration",
3215
+ "mode": mode,
3216
+ "root": str(root),
3217
+ "rule_file": str(rule_path),
3218
+ "marker_state_before": parsed.status,
3219
+ "status": planned_status,
3220
+ "changed": True,
3221
+ "applied": False,
3222
+ "apply_requested": False,
3223
+ "backup_path": None,
3224
+ "actions": [f"would {action}"],
3225
+ "claim_boundary": "static setup result only; no model-compliance or savings claim",
3226
+ }
3227
+
3228
+ write_result = write_managed_file(
3229
+ rule_path,
3230
+ expected=state["snapshot"],
3231
+ desired=desired,
3232
+ mode=existing_mode_or_default(rule_path, 0o644),
3233
+ dir_mode=0o755,
3234
+ )
3235
+ if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
3236
+ raise SystemExit(
3237
+ write_result.get("reason")
3238
+ or f"could not safely update quiet narration rules in {rule_path}"
3239
+ )
3240
+ status = (
3241
+ write_result["status"]
3242
+ if write_result["status"] == "applied-durability-uncertain"
3243
+ else ("removed" if mode == "default" else ("applied" if span is None else "updated"))
3244
+ )
3245
+ payload = {
3246
+ "schema_version": "contextguard.narration-rules.v1",
3247
+ "operation": "quiet-narration",
3248
+ "mode": mode,
3249
+ "root": str(root),
3250
+ "rule_file": str(rule_path),
3251
+ "marker_state_before": parsed.status,
3252
+ "status": status,
3253
+ "changed": True,
3254
+ "applied": True,
3255
+ "apply_requested": True,
3256
+ "backup_path": write_result.get("backup_path"),
3257
+ "actions": [action],
3258
+ "claim_boundary": "static setup result only; no model-compliance or savings claim",
3259
+ }
3260
+ if write_result.get("reason"):
3261
+ payload["warning"] = write_result["reason"]
3262
+ if write_result.get("residual_risk"):
3263
+ payload["residual_risk"] = write_result["residual_risk"]
3264
+ return payload
3265
+
3266
+
3267
+ def render_quiet_narration_text(result: dict[str, Any]) -> str:
3268
+ lines = [
3269
+ f"ContextGuard quiet narration ({result['status']})",
3270
+ f"root={result['root']}",
3271
+ f"rule_file={result['rule_file']}",
3272
+ f"mode={result['mode']}",
3273
+ ]
3274
+ if result.get("backup_path"):
3275
+ lines.append(f"backup={result['backup_path']}")
3276
+ lines.extend(f"- {action}" for action in result.get("actions", []))
3277
+ lines.append(str(result["claim_boundary"]))
3278
+ return "\n".join(lines) + "\n"
3279
+
3280
+
2399
3281
  def run(args: argparse.Namespace) -> SetupResult:
2400
3282
  require_no_follow_file_ops_supported()
2401
3283
  scope = normalize_scope(getattr(args, "scope", "project"))
@@ -2416,10 +3298,21 @@ def run(args: argparse.Namespace) -> SetupResult:
2416
3298
  if claude_targeted:
2417
3299
  validate_settings_target(root, settings_path, allow_home_settings=(args.allow_home_settings or scope == "user"))
2418
3300
  original_text = _read_optional_text_no_follow(settings_path)
3301
+ settings_snapshot = read_managed_file_snapshot(settings_path)
3302
+ snapshot_text = (
3303
+ settings_snapshot.data.decode("utf-8")
3304
+ if settings_snapshot.data is not None
3305
+ else None
3306
+ )
3307
+ if snapshot_text != original_text:
3308
+ raise SystemExit(
3309
+ f"Settings changed while setup was preparing changes; re-run setup to merge latest file: {settings_path}"
3310
+ )
2419
3311
  original = _parse_json_object_text(original_text, settings_path)
2420
3312
  settings = json.loads(json.dumps(original))
2421
3313
  else:
2422
3314
  original_text = None
3315
+ settings_snapshot = ManagedFileSnapshot(None, None)
2423
3316
  original = {}
2424
3317
  settings = {}
2425
3318
 
@@ -2471,31 +3364,45 @@ def run(args: argparse.Namespace) -> SetupResult:
2471
3364
  if claude_targeted and apply_requested and changed:
2472
3365
  if scope == "user" and original_text is not None and args.no_backup:
2473
3366
  raise SystemExit("Refusing --no-backup for user-scope changes to existing Claude settings.")
2474
- lock_fd = acquire_settings_lock(settings_path)
2475
- try:
2476
- current_text = _read_optional_text_no_follow(settings_path)
2477
- if current_text != original_text:
2478
- raise SystemExit(
2479
- f"Settings changed while setup was preparing changes; re-run setup to merge latest file: {settings_path}"
2480
- )
2481
- if original_text is not None and not args.no_backup and settings != original:
2482
- backup_path = backup_existing(settings_path)
2483
- if settings != original:
2484
- rollback_id, rollback_path = write_rollback_record(
2485
- root=root,
2486
- scope=scope,
2487
- settings_path=settings_path,
2488
- backup_path=backup_path,
2489
- original_existed=(original_text is not None),
2490
- )
2491
- atomic_write(
2492
- settings_path,
2493
- json.dumps(settings, indent=2, sort_keys=True) + "\n",
2494
- existing_mode_or_default(settings_path, 0o600),
2495
- )
2496
- claude_settings_written = True
2497
- finally:
2498
- release_settings_lock(lock_fd)
3367
+ rollback_state: dict[str, Any] = {}
3368
+
3369
+ def prepare_settings_commit(managed_backup_path: Path | None) -> None:
3370
+ prepared_rollback_id, prepared_rollback_path = write_rollback_record(
3371
+ root=root,
3372
+ scope=scope,
3373
+ settings_path=settings_path,
3374
+ backup_path=managed_backup_path,
3375
+ original_existed=(original_text is not None),
3376
+ )
3377
+ rollback_state.update({
3378
+ "rollback_id": prepared_rollback_id,
3379
+ "rollback_path": prepared_rollback_path,
3380
+ })
3381
+
3382
+ desired_settings = (
3383
+ json.dumps(settings, indent=2, sort_keys=True) + "\n"
3384
+ ).encode("utf-8")
3385
+ write_result = write_managed_file(
3386
+ settings_path,
3387
+ expected=settings_snapshot,
3388
+ desired=desired_settings,
3389
+ mode=existing_mode_or_default(settings_path, 0o600),
3390
+ dir_mode=PRIVATE_DIR_MODE,
3391
+ create_backup=not args.no_backup,
3392
+ prepare_commit=prepare_settings_commit,
3393
+ )
3394
+ if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
3395
+ reason = write_result.get("reason") or "managed settings transaction was not applied"
3396
+ raise SystemExit(f"Could not safely update {settings_path}: {reason}")
3397
+ if write_result.get("backup_path"):
3398
+ backup_path = Path(write_result["backup_path"])
3399
+ rollback_id = rollback_state.get("rollback_id")
3400
+ rollback_path = rollback_state.get("rollback_path")
3401
+ if write_result["status"] == "applied-durability-uncertain" and write_result.get("reason"):
3402
+ warnings.append(str(write_result["reason"]))
3403
+ if write_result.get("residual_risk"):
3404
+ warnings.append(str(write_result["residual_risk"]))
3405
+ claude_settings_written = True
2499
3406
 
2500
3407
  # Build the per-adapter plan; repo-rule writes happen here when an applying
2501
3408
  # run (--yes) requested --with-init or project-scope --brief-mode.
@@ -2559,6 +3466,17 @@ def build_parser() -> argparse.ArgumentParser:
2559
3466
  parser.add_argument("--dry-run", action="store_true", help="alias for --plan")
2560
3467
  parser.add_argument("--verify", action="store_true", help="run a read-only setup health check; never writes or prompts")
2561
3468
  parser.add_argument("--json", action="store_true", help="print machine-readable result")
3469
+ parser.add_argument(
3470
+ "--rules-only",
3471
+ action="store_true",
3472
+ help="run an isolated rule-file operation without reading or changing settings/hooks",
3473
+ )
3474
+ parser.add_argument(
3475
+ "--narration-mode",
3476
+ choices=NARRATION_MODE_CHOICES,
3477
+ default=None,
3478
+ help="with --rules-only, add quiet Claude narration guidance or restore default behavior",
3479
+ )
2562
3480
  parser.add_argument("--no-backup", action="store_true", help="do not create .bak-* before modifying existing settings")
2563
3481
  parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
2564
3482
  parser.add_argument("--no-statusline", action="store_true", help="skip token statusline")
@@ -2613,7 +3531,7 @@ def build_parser() -> argparse.ArgumentParser:
2613
3531
  dest="failed_attempt_nudge",
2614
3532
  action="store_true",
2615
3533
  default=None,
2616
- help="enable PostToolUse Bash hook that suggests /clear when the same command fails twice in a row (recommended default)",
3534
+ help="enable Bash terminal-event hooks that suggest /clear when the same command fails twice in a row (recommended default)",
2617
3535
  )
2618
3536
  nudge_group.add_argument(
2619
3537
  "--no-failed-attempt-nudge",
@@ -2628,6 +3546,14 @@ def build_parser() -> argparse.ArgumentParser:
2628
3546
  def main() -> int:
2629
3547
  parser = build_parser()
2630
3548
  args = parser.parse_args()
3549
+ rules_only = validate_rules_only_args(parser, args)
3550
+ if rules_only:
3551
+ result = run_quiet_narration_rules(args)
3552
+ if args.json:
3553
+ print(json.dumps(result, indent=2, sort_keys=True))
3554
+ else:
3555
+ print(render_quiet_narration_text(result), end="")
3556
+ return 0
2631
3557
  if args.dry_run:
2632
3558
  args.plan = True
2633
3559
  if args.verify and args.yes: