@event4u/agent-config 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -114,6 +114,11 @@ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
114
114
  except json.JSONDecodeError:
115
115
  return 0 # malformed stdin → silent no-op, never block
116
116
 
117
+ # Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md).
118
+ if all(k in payload for k in ("schema_version", "platform", "event", "payload")):
119
+ inner = payload.get("payload")
120
+ payload = inner if isinstance(inner, dict) else {}
121
+
117
122
  tool = payload.get("tool_name") or payload.get("toolName") or payload.get("tool")
118
123
  if not isinstance(tool, str) or tool not in WRITE_TOOLS:
119
124
  return 0
@@ -37,6 +37,11 @@
37
37
  "pattern": "^[a-z][a-z0-9-]*$"
38
38
  }
39
39
  },
40
+ "tier": {
41
+ "type": "string",
42
+ "enum": ["senior"],
43
+ "description": "Optional tier marker. `senior` opts the skill into the Senior-Tier Required Structure check (Context-First lead, Related Skills, Proactive Triggers, Output Artifacts) per .agent-src.uncompressed/rules/skill-quality.md."
44
+ },
40
45
  "execution": {
41
46
  "type": "object",
42
47
  "additionalProperties": false,
@@ -99,6 +99,17 @@ TYPE_PATTERN = re.compile(r'^type:\s*"?(always|auto)"?\s*$', re.MULTILINE)
99
99
  SOURCE_PATTERN = re.compile(r'^source:\s*"?(package|project)"?\s*$', re.MULTILINE)
100
100
  STATUS_PATTERN = re.compile(r'^status:\s*"?(active|deprecated|superseded)"?\s*$', re.MULTILINE)
101
101
  REPLACED_BY_PATTERN = re.compile(r'^replaced_by:\s*"?([\w-]+)"?\s*$', re.MULTILINE)
102
+ TIER_PATTERN = re.compile(r'^tier:\s*"?([\w-]+)"?\s*$', re.MULTILINE)
103
+
104
+ # --- Senior-tier required-block patterns (skill-quality.md § Senior-Tier Required Structure) ---
105
+ # Heading-only checks; detail-shape lives in skill-quality-mechanics.md.
106
+ SENIOR_RELATED_SKILLS_PATTERN = re.compile(r"^##\s+Related Skills\s*$", re.MULTILINE)
107
+ SENIOR_RELATED_WHEN_PATTERN = re.compile(r"\*\*WHEN to use this\*\*", re.IGNORECASE)
108
+ SENIOR_RELATED_WHEN_NOT_PATTERN = re.compile(r"\*\*WHEN NOT to use this\*\*", re.IGNORECASE)
109
+ SENIOR_PROACTIVE_PATTERN = re.compile(
110
+ r"^##\s+When the agent should load this\s*$", re.MULTILINE
111
+ )
112
+ SENIOR_OUTPUT_PATTERN = re.compile(r"^##\s+Output\s*$", re.MULTILINE)
102
113
  H1_PATTERN = re.compile(r"^# .+", re.MULTILINE)
103
114
  DOUBLE_BLANK_PATTERN = re.compile(r"\n{3,}")
104
115
 
@@ -415,6 +426,11 @@ def lint_skill(path: Path, text: str) -> LintResult:
415
426
  if execution is not None:
416
427
  issues.extend(lint_execution_metadata(execution))
417
428
 
429
+ # --- Senior-tier required-block check (skill-quality.md § Senior-Tier Required Structure) ---
430
+ tier_match = TIER_PATTERN.search(frontmatter)
431
+ if tier_match and tier_match.group(1) == "senior":
432
+ issues.extend(lint_senior_tier_blocks(text))
433
+
418
434
  procedure_block = find_procedure_block(text)
419
435
  if procedure_block is not None:
420
436
  if not procedure_block:
@@ -603,6 +619,57 @@ def parse_execution_block(frontmatter: str) -> Optional[dict]:
603
619
  return result
604
620
 
605
621
 
622
+ def lint_senior_tier_blocks(text: str) -> List[Issue]:
623
+ """Validate the four required blocks for `tier: senior` skills.
624
+
625
+ Per .agent-src.uncompressed/rules/skill-quality.md § Senior-Tier
626
+ Required Structure: Context-First lead (description), Related Skills
627
+ (with WHEN / WHEN NOT lists), Proactive Triggers, Output Artifacts.
628
+
629
+ The Context-First lead is checked structurally via description length
630
+ + content; here we enforce the three section blocks and the WHEN /
631
+ WHEN NOT two-list pattern inside Related Skills.
632
+ """
633
+ issues: List[Issue] = []
634
+
635
+ if not SENIOR_RELATED_SKILLS_PATTERN.search(text):
636
+ issues.append(Issue(
637
+ "error",
638
+ "missing_senior_related_skills",
639
+ "Senior-tier skill missing `## Related Skills` block (skill-quality.md § Senior-Tier Required Structure)",
640
+ ))
641
+ else:
642
+ related_block = extract_section_block(text, "Related Skills") or ""
643
+ if not SENIOR_RELATED_WHEN_PATTERN.search(related_block):
644
+ issues.append(Issue(
645
+ "error",
646
+ "missing_senior_related_when",
647
+ "Senior-tier `## Related Skills` block missing `**WHEN to use this**` list",
648
+ ))
649
+ if not SENIOR_RELATED_WHEN_NOT_PATTERN.search(related_block):
650
+ issues.append(Issue(
651
+ "error",
652
+ "missing_senior_related_when_not",
653
+ "Senior-tier `## Related Skills` block missing `**WHEN NOT to use this**` list",
654
+ ))
655
+
656
+ if not SENIOR_PROACTIVE_PATTERN.search(text):
657
+ issues.append(Issue(
658
+ "error",
659
+ "missing_senior_proactive_triggers",
660
+ "Senior-tier skill missing `## When the agent should load this` block",
661
+ ))
662
+
663
+ if not SENIOR_OUTPUT_PATTERN.search(text):
664
+ issues.append(Issue(
665
+ "error",
666
+ "missing_senior_output_artifacts",
667
+ "Senior-tier skill missing `## Output` block declaring artifact name + shape",
668
+ ))
669
+
670
+ return issues
671
+
672
+
606
673
  def lint_execution_metadata(execution: dict) -> List[Issue]:
607
674
  """Validate the execution block of a skill."""
608
675
  issues: List[Issue] = []
@@ -1656,6 +1723,70 @@ def lint_governance(path: Path, text: str, artifact_type: str, repo_root: Path |
1656
1723
  return issues
1657
1724
 
1658
1725
 
1726
+ # --- Structural malice check (see road-to-suite-closure Phase 5) ---
1727
+ #
1728
+ # Five regex patterns scan skill / rule / command bodies for **structural**
1729
+ # (not semantic) malice. Findings surface as ``Issue("error",
1730
+ # "malice:<pattern>", "<line>:<matched>")`` so ``compute_exit_code`` can
1731
+ # emit exit code 3 (security-failure), distinct from 2 (build-failure).
1732
+ # Semantic checks (PII leakage, prompt injection) are deferred to v2.
1733
+
1734
+ # (a) credential exfil — curl|wget piping ${TOKEN}/${KEY}/${SECRET}/...
1735
+ # env vars or hitting ~/.aws/ ~/.ssh/ secrets.
1736
+ _MALICE_CRED_EXFIL = re.compile(
1737
+ r"\b(?:curl|wget)\b[^\n]*"
1738
+ r"(?:\$\{?[A-Z_]*(?:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)[A-Z_]*\}?"
1739
+ r"|~/\.(?:aws|ssh)/)"
1740
+ )
1741
+ # (b) arbitrary execution — eval/exec over a network-fetched payload, or
1742
+ # `bash <(curl ...)` / `sh <(wget ...)` style remote-execution.
1743
+ _MALICE_REMOTE_EXEC = re.compile(
1744
+ r"(?:\b(?:eval|exec)\s*\([^)]*(?:curl|wget|requests\.get|urllib)"
1745
+ r"|\b(?:bash|sh|zsh)\s*<\s*\(\s*(?:curl|wget))"
1746
+ )
1747
+ # (c) force-push to a protected ref.
1748
+ _MALICE_FORCE_PUSH = re.compile(
1749
+ r"\bgit\s+push\b[^\n]*--force(?:-with-lease)?\b[^\n]*"
1750
+ r"\b(?:main|master|prod|production|release)\b"
1751
+ )
1752
+ # (d) world-readable secrets — chmod 0?[4567]xx on .pem/.key/.env files.
1753
+ _MALICE_CHMOD_SECRETS = re.compile(
1754
+ r"\bchmod\s+0?[4567]\d{2}\s+[^\n]*\.(?:pem|key|env)\b"
1755
+ )
1756
+ # (e) unbounded subprocess shell injection — shell=True interpolating ${VAR}.
1757
+ _MALICE_SHELL_INJECT = re.compile(
1758
+ r"\bsubprocess\.[A-Za-z_]+\s*\([^)]*shell\s*=\s*True[^)]*\$\{"
1759
+ )
1760
+
1761
+ _MALICE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
1762
+ ("cred_exfil", _MALICE_CRED_EXFIL),
1763
+ ("remote_exec", _MALICE_REMOTE_EXEC),
1764
+ ("force_push_protected", _MALICE_FORCE_PUSH),
1765
+ ("chmod_secrets", _MALICE_CHMOD_SECRETS),
1766
+ ("shell_injection", _MALICE_SHELL_INJECT),
1767
+ ]
1768
+
1769
+
1770
+ def check_structural_malice(text: str) -> List[Issue]:
1771
+ """Return one Issue per malice match. Empty list when clean.
1772
+
1773
+ Issue shape: ``Issue("error", f"malice:{name}", f"{line}:{matched}")``.
1774
+ The ``format_text`` renderer special-cases the ``malice:`` code prefix
1775
+ to emit ``<path>:<line>:malice:<pattern>:<matched>`` per Phase 5.2.
1776
+ """
1777
+ issues: List[Issue] = []
1778
+ for lineno, raw in enumerate(text.splitlines(), start=1):
1779
+ for name, pattern in _MALICE_PATTERNS:
1780
+ match = pattern.search(raw)
1781
+ if match:
1782
+ issues.append(Issue(
1783
+ severity="error",
1784
+ code=f"malice:{name}",
1785
+ message=f"{lineno}:{match.group(0).strip()}",
1786
+ ))
1787
+ return issues
1788
+
1789
+
1659
1790
  # --- Output-schema check (see road-to-trigger-evals Phase 3.5) ---
1660
1791
  #
1661
1792
  # Skills that freeze an output shape (`refine-ticket`, `estimate-ticket`)
@@ -1865,11 +1996,36 @@ def lint_file(path: Path, repo_root: Path | None = None) -> LintResult:
1865
1996
  result.issues.extend(schema_issues)
1866
1997
  result.status = classify_status(result.issues)
1867
1998
 
1999
+ # Post-processing: structural malice scan (errors). Skills, rules,
2000
+ # and commands carry executable patterns; guidelines/personas are
2001
+ # prose-only and skipped to keep noise low.
2002
+ if artifact_type in ("skill", "rule", "command"):
2003
+ malice_issues = check_structural_malice(text)
2004
+ if malice_issues:
2005
+ result.issues.extend(malice_issues)
2006
+ result.status = classify_status(result.issues)
2007
+
1868
2008
  return result
1869
2009
 
1870
2010
 
1871
2011
  def format_text(results: list[LintResult]) -> str:
1872
2012
  lines: list[str] = []
2013
+ # Phase 5.2: malice findings render in the spec shape
2014
+ # ``<path>:<line>:malice:<pattern>:<matched>`` ahead of the badge
2015
+ # block so security-failures are grep-able from the top.
2016
+ malice_total = 0
2017
+ for result in results:
2018
+ for issue in result.issues:
2019
+ if issue.code.startswith("malice:"):
2020
+ pattern_name = issue.code.split(":", 1)[1]
2021
+ lineno, _, matched = issue.message.partition(":")
2022
+ lines.append(
2023
+ f"{result.file}:{lineno}:malice:{pattern_name}:{matched}"
2024
+ )
2025
+ malice_total += 1
2026
+ if malice_total:
2027
+ lines.append("")
2028
+
1873
2029
  for result in results:
1874
2030
  badge = {"pass": "[PASS]", "pass_with_warnings": "[WARN]", "fail": "[FAIL]"}[result.status]
1875
2031
  lines.append(f"{badge} {result.file} ({result.artifact_type})")
@@ -1888,7 +2044,8 @@ def format_text(results: list[LintResult]) -> str:
1888
2044
  fails = sum(1 for r in results if r.status == "fail")
1889
2045
  warns = sum(1 for r in results if r.status == "pass_with_warnings")
1890
2046
  passes = sum(1 for r in results if r.status == "pass")
1891
- lines.append(f"Summary: {passes} pass, {warns} warn, {fails} fail, {total} total")
2047
+ suffix = f", {malice_total} malice" if malice_total else ""
2048
+ lines.append(f"Summary: {passes} pass, {warns} warn, {fails} fail, {total} total{suffix}")
1892
2049
  return "\n".join(lines)
1893
2050
 
1894
2051
 
@@ -2087,6 +2244,11 @@ def check_duplication(root: Path) -> list[LintResult]:
2087
2244
 
2088
2245
 
2089
2246
  def compute_exit_code(results: list[LintResult], strict_warnings: bool) -> int:
2247
+ # Phase 5.2: structural-malice findings emit exit code 3 (security-
2248
+ # failure), distinct from 2 (build-failure) so CI surfaces can split.
2249
+ for r in results:
2250
+ if any(issue.code.startswith("malice:") for issue in r.issues):
2251
+ return 3
2090
2252
  if any(r.status == "fail" for r in results):
2091
2253
  return 2
2092
2254
  if any(r.status == "pass_with_warnings" for r in results) and strict_warnings:
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- """Sync `.agent-settings.yml` against the template + profile.
2
+ """Sync `.agent-settings.yml` against the template + profile (additive merge).
3
3
 
4
4
  Applies the section-aware merge rules documented in
5
5
  `docs/guidelines/agent-infra/layered-settings.md`:
6
6
 
7
- - Template section order always winsreorder keys to match.
8
- - Existing user scalar values are preserved verbatim (as parsed).
9
- - Missing keys land with their template / profile default.
10
- - Template comments replace user comments in the same position.
11
- - Unknown user keys (not in the template) are preserved in a trailing
12
- `_user:` block so custom additions never get silently dropped.
7
+ - **User lines are preserved verbatim**comments, quoting, and key order
8
+ survive every sync. Existing values, custom inline comments, and
9
+ user-chosen ordering are never modified.
10
+ - Missing template keys are inserted (leaf into existing parent section,
11
+ full subtree at EOF for entirely missing top-level sections).
12
+ - Top-level user-only sections (no home in the template) are moved to a
13
+ single-level `_user:` block at the end of the file.
14
+ - The `_user:` block is single-level only — legacy multi-prefix
15
+ corruption (`_user._user.foo`) heals to `foo` on the next sync.
16
+ - Template comment changes on already-existing user keys do **not**
17
+ propagate (existing line untouched is the deal).
13
18
 
14
19
  Idempotent — writing a file that is already in sync is a no-op.
15
20
 
@@ -33,7 +38,8 @@ import sys
33
38
  from pathlib import Path
34
39
 
35
40
  sys.path.insert(0, str(Path(__file__).resolve().parent))
36
- import install as _install # noqa: E402 — shares _yaml_scalar + _replace_template_value
41
+ import install as _install # noqa: E402 — profile parsing + template rendering
42
+ import sync_yaml_rt as _rt # noqa: E402 — additive round-trip merge
37
43
 
38
44
  try:
39
45
  import yaml # type: ignore
@@ -46,126 +52,6 @@ DEFAULT_TEMPLATE = Path(__file__).resolve().parent.parent / "config" / "agent-se
46
52
  DEFAULT_PROFILE_DIR = Path(__file__).resolve().parent.parent / "config" / "profiles"
47
53
 
48
54
 
49
- def _flatten(data: dict, prefix: str = "") -> dict[str, object]:
50
- """Flatten nested dicts to dotted keys — recurses to all leaves.
51
-
52
- Lists, scalars, and ``None`` are leaves. Dicts are walked and their
53
- keys folded into the dotted path.
54
- """
55
- out: dict[str, object] = {}
56
- for key, value in data.items():
57
- path = f"{prefix}{key}"
58
- if isinstance(value, dict):
59
- out.update(_flatten(value, prefix=f"{path}."))
60
- else:
61
- out[path] = value
62
- return out
63
-
64
-
65
- def _as_yaml_value(value: object) -> str | None:
66
- """Format *value* as an inline-YAML literal.
67
-
68
- Returns ``None`` when the value cannot be safely represented as a
69
- scalar / flow-style sequence (e.g. unsupported types). Callers
70
- must skip those keys so the template default sticks instead of
71
- producing malformed YAML.
72
- """
73
- if isinstance(value, bool):
74
- return "true" if value else "false"
75
- if isinstance(value, int):
76
- return str(value)
77
- if isinstance(value, float):
78
- return repr(value)
79
- if value is None:
80
- return "~"
81
- if isinstance(value, list):
82
- items: list[str] = []
83
- for item in value:
84
- rendered = _as_yaml_value(item)
85
- if rendered is None:
86
- return None
87
- items.append(rendered)
88
- return "[" + ", ".join(items) + "]"
89
- if isinstance(value, str):
90
- return _install._yaml_scalar(value)
91
- return None
92
-
93
-
94
- def _template_keys(template_body: str) -> set[str]:
95
- """Return the set of dotted keys declared by the rendered template."""
96
- data = yaml.safe_load(template_body) or {}
97
- if not isinstance(data, dict):
98
- return set()
99
- return set(_flatten(data).keys())
100
-
101
-
102
- def _apply_user_values(template_body: str, user_flat: dict[str, object]) -> str:
103
- """Overlay every known user value on the rendered template body.
104
-
105
- Keys whose value cannot be rendered inline (see :func:`_as_yaml_value`)
106
- are skipped so the template default survives instead of corrupting
107
- the file.
108
- """
109
- body = template_body
110
- for dotted, value in user_flat.items():
111
- rendered = _as_yaml_value(value)
112
- if rendered is None:
113
- continue
114
- body = _install._replace_template_value_raw(body, dotted, rendered)
115
- return body
116
-
117
-
118
- def _append_unknown(body: str, user_flat: dict[str, object], known: set[str]) -> str:
119
- """Emit user keys that have no home in the template under `_user:`."""
120
- unknown = sorted(k for k in user_flat if k not in known)
121
- if not unknown:
122
- return body
123
- lines = [
124
- "",
125
- "# Unknown keys preserved by sync_agent_settings.py — review and move",
126
- "# them into the template or drop them.",
127
- "_user:",
128
- ]
129
- for key in unknown:
130
- rendered = _as_yaml_value(user_flat[key])
131
- if rendered is None:
132
- continue
133
- lines.append(f" {key}: {rendered}")
134
- suffix = "\n".join(lines) + "\n"
135
- return body + (suffix if body.endswith("\n") else "\n" + suffix)
136
-
137
-
138
- def render_target(template_body: str, user_data: dict) -> str:
139
- """Return the desired `.agent-settings.yml` body for the given user data.
140
-
141
- The trailing ``_user:`` block (emitted by :func:`_append_unknown`) is
142
- already in dotted-key form on every read after the first sync. Re-
143
- flattening it would prepend another ``_user.`` segment on every run
144
- and accumulate forever, so we strip the wrapper and merge its
145
- contents straight into the flat dict.
146
- """
147
- if user_data:
148
- user_only = user_data.pop("_user", None) if isinstance(user_data, dict) else None
149
- user_flat = _flatten(user_data)
150
- if isinstance(user_only, dict):
151
- for key, value in user_only.items():
152
- # Dotted keys round-trip verbatim — never re-flatten them.
153
- if isinstance(key, str):
154
- # Heal legacy corruption: pre-fix syncs prepended a
155
- # `_user.` segment per run, so a key may carry an
156
- # arbitrary number of them. Strip them all back to
157
- # the original leaf path.
158
- healed = key
159
- while healed.startswith("_user."):
160
- healed = healed[len("_user."):]
161
- user_flat[healed] = value
162
- else:
163
- user_flat = {}
164
- known = _template_keys(template_body)
165
- body = _apply_user_values(template_body, user_flat)
166
- return _append_unknown(body, user_flat, known)
167
-
168
-
169
55
  def load_profile(profile_dir: Path, profile: str) -> dict[str, str]:
170
56
  profile_source = profile_dir / f"{profile}.ini"
171
57
  if not profile_source.is_file():
@@ -231,10 +117,27 @@ def main(argv: list[str] | None = None) -> int:
231
117
  except FileNotFoundError as exc:
232
118
  print(f"error: {exc}", file=sys.stderr)
233
119
  return 2
120
+ except yaml.YAMLError as exc:
121
+ print(f"error: cannot parse {target}: {exc}", file=sys.stderr)
122
+ return 2
234
123
 
235
- new_text = render_target(template_body, user_data)
236
124
  existing_text = target.read_text(encoding="utf-8") if target.is_file() else ""
237
125
 
126
+ if existing_text:
127
+ # Additive merge — preserves user lines verbatim, inserts only
128
+ # the template keys the user is missing.
129
+ try:
130
+ new_text = _rt.sync(existing_text, template_body)
131
+ except ValueError as exc:
132
+ print(
133
+ f"error: cannot parse {target}: {exc}",
134
+ file=sys.stderr,
135
+ )
136
+ return 2
137
+ else:
138
+ # First-run / file absent — write the rendered template as-is.
139
+ new_text = template_body
140
+
238
141
  if new_text == existing_text:
239
142
  if not args.quiet:
240
143
  print(f"✅ {target}: already in sync (profile={profile})")