@jaguilar87/gaia 5.0.2 → 5.0.5

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 (154) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/ARCHITECTURE.md +0 -1
  4. package/CHANGELOG.md +110 -0
  5. package/INSTALL.md +0 -2
  6. package/README.md +1 -6
  7. package/bin/README.md +0 -1
  8. package/bin/cli/_install_helpers.py +1 -1
  9. package/bin/cli/approvals.py +23 -21
  10. package/bin/cli/cleanup.py +0 -1
  11. package/bin/cli/doctor.py +1 -1
  12. package/bin/cli/memory.py +2 -0
  13. package/bin/cli/update.py +1 -1
  14. package/bin/pre-publish-validate.js +48 -5
  15. package/config/README.md +22 -44
  16. package/config/surface-routing.json +0 -2
  17. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  18. package/dist/gaia-ops/config/README.md +22 -44
  19. package/dist/gaia-ops/config/surface-routing.json +0 -2
  20. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +18 -0
  21. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +214 -2
  22. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +26 -0
  23. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +15 -0
  24. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -5
  25. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +124 -19
  26. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +99 -7
  27. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +127 -24
  28. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +90 -55
  29. package/dist/gaia-ops/skills/README.md +1 -1
  30. package/dist/gaia-ops/skills/agent-contract-handoff/SKILL.md +3 -0
  31. package/dist/gaia-ops/skills/agent-response/SKILL.md +4 -2
  32. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +1 -1
  33. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -3
  34. package/dist/gaia-ops/skills/gaia-release/SKILL.md +60 -24
  35. package/dist/gaia-ops/skills/gaia-release/reference.md +35 -11
  36. package/dist/gaia-ops/skills/git-conventions/SKILL.md +6 -2
  37. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +30 -7
  38. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +32 -15
  39. package/dist/gaia-ops/skills/readme-writing/SKILL.md +1 -1
  40. package/dist/gaia-ops/skills/readme-writing/reference.md +0 -1
  41. package/dist/gaia-ops/skills/security-tiers/SKILL.md +5 -1
  42. package/dist/gaia-ops/skills/security-tiers/reference.md +3 -1
  43. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +43 -6
  44. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +66 -16
  45. package/dist/gaia-ops/tools/context/README.md +1 -1
  46. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +0 -1
  47. package/dist/gaia-ops/tools/scan/ui.py +20 -4
  48. package/dist/gaia-ops/tools/scan/verify.py +3 -3
  49. package/dist/gaia-ops/tools/validation/README.md +15 -24
  50. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  51. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +18 -0
  52. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +214 -2
  53. package/dist/gaia-security/hooks/modules/agents/response_contract.py +26 -0
  54. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +15 -0
  55. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -5
  56. package/dist/gaia-security/hooks/modules/security/approval_grants.py +124 -19
  57. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +99 -7
  58. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +127 -24
  59. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +90 -55
  60. package/gaia/state/transitions.py +4 -4
  61. package/gaia/store/writer.py +56 -0
  62. package/hooks/modules/README.md +2 -4
  63. package/hooks/modules/agents/contract_validator.py +18 -0
  64. package/hooks/modules/agents/handoff_persister.py +214 -2
  65. package/hooks/modules/agents/response_contract.py +26 -0
  66. package/hooks/modules/agents/transcript_reader.py +15 -0
  67. package/hooks/modules/security/__init__.py +0 -5
  68. package/hooks/modules/security/approval_grants.py +124 -19
  69. package/hooks/modules/security/mutative_verbs.py +99 -7
  70. package/hooks/modules/tools/bash_validator.py +127 -24
  71. package/hooks/modules/validation/commit_validator.py +90 -55
  72. package/index.js +2 -12
  73. package/package.json +4 -6
  74. package/pyproject.toml +3 -3
  75. package/scripts/bootstrap_database.sh +88 -439
  76. package/scripts/check_schema_drift.py +208 -0
  77. package/scripts/migrations/README.md +78 -28
  78. package/scripts/migrations/schema.checksum +8 -0
  79. package/scripts/release-prepare.mjs +199 -0
  80. package/skills/README.md +1 -1
  81. package/skills/agent-contract-handoff/SKILL.md +3 -0
  82. package/skills/agent-response/SKILL.md +4 -2
  83. package/skills/gaia-patterns/SKILL.md +1 -1
  84. package/skills/gaia-patterns/reference.md +2 -3
  85. package/skills/gaia-release/SKILL.md +60 -24
  86. package/skills/gaia-release/reference.md +35 -11
  87. package/skills/git-conventions/SKILL.md +6 -2
  88. package/skills/orchestrator-present-approval/SKILL.md +30 -7
  89. package/skills/orchestrator-present-approval/reference.md +32 -15
  90. package/skills/readme-writing/SKILL.md +1 -1
  91. package/skills/readme-writing/reference.md +0 -1
  92. package/skills/security-tiers/SKILL.md +5 -1
  93. package/skills/security-tiers/reference.md +3 -1
  94. package/skills/subagent-request-approval/SKILL.md +43 -6
  95. package/skills/subagent-request-approval/reference.md +66 -16
  96. package/tools/context/README.md +1 -1
  97. package/tools/gaia_simulator/extractor.py +0 -1
  98. package/tools/scan/ui.py +20 -4
  99. package/tools/scan/verify.py +3 -3
  100. package/tools/validation/README.md +15 -24
  101. package/commands/README.md +0 -64
  102. package/commands/gaia.md +0 -37
  103. package/commands/scan-project.md +0 -74
  104. package/config/crons-schema.md +0 -81
  105. package/config/git_standards.json +0 -72
  106. package/dist/gaia-ops/commands/gaia.md +0 -37
  107. package/dist/gaia-ops/config/crons-schema.md +0 -81
  108. package/dist/gaia-ops/config/git_standards.json +0 -72
  109. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +0 -179
  110. package/dist/gaia-ops/tools/agentic-loop/decide-status.py +0 -210
  111. package/dist/gaia-ops/tools/agentic-loop/parse-metric.py +0 -106
  112. package/dist/gaia-ops/tools/agentic-loop/record-iteration.py +0 -223
  113. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +0 -179
  114. package/git-hooks/commit-msg +0 -41
  115. package/hooks/modules/security/gitops_validator.py +0 -179
  116. package/scripts/migrations/v10_to_v11.sql +0 -170
  117. package/scripts/migrations/v10_to_v11_fresh.sql +0 -18
  118. package/scripts/migrations/v11_to_v12.sql +0 -195
  119. package/scripts/migrations/v11_to_v12_fresh.sql +0 -19
  120. package/scripts/migrations/v12_to_v13.sql +0 -48
  121. package/scripts/migrations/v12_to_v13_fresh.sql +0 -17
  122. package/scripts/migrations/v13_to_v14.sql +0 -44
  123. package/scripts/migrations/v13_to_v14_fresh.sql +0 -17
  124. package/scripts/migrations/v14_to_v15.sql +0 -71
  125. package/scripts/migrations/v14_to_v15_fresh.sql +0 -19
  126. package/scripts/migrations/v15_to_v16.sql +0 -57
  127. package/scripts/migrations/v15_to_v16_fresh.sql +0 -18
  128. package/scripts/migrations/v16_to_v17.sql +0 -51
  129. package/scripts/migrations/v16_to_v17_fresh.sql +0 -18
  130. package/scripts/migrations/v17_to_v18.sql +0 -66
  131. package/scripts/migrations/v17_to_v18_fresh.sql +0 -24
  132. package/scripts/migrations/v1_to_v2.sql +0 -97
  133. package/scripts/migrations/v2_to_v3.sql +0 -68
  134. package/scripts/migrations/v2_to_v3_merge.sql +0 -69
  135. package/scripts/migrations/v3_to_v4.sql +0 -67
  136. package/scripts/migrations/v3_to_v4_fresh.sql +0 -20
  137. package/scripts/migrations/v4_to_v5.sql +0 -55
  138. package/scripts/migrations/v4_to_v5_fresh.sql +0 -20
  139. package/scripts/migrations/v5_to_v6.sql +0 -48
  140. package/scripts/migrations/v5_to_v6_fresh.sql +0 -17
  141. package/scripts/migrations/v6_to_v7.sql +0 -26
  142. package/scripts/migrations/v6_to_v7_fresh.sql +0 -13
  143. package/scripts/migrations/v7_to_v8.sql +0 -44
  144. package/scripts/migrations/v7_to_v8_fresh.sql +0 -14
  145. package/scripts/migrations/v8_to_v9.sql +0 -87
  146. package/scripts/migrations/v8_to_v9_fresh.sql +0 -15
  147. package/scripts/migrations/v9_to_v10.sql +0 -109
  148. package/scripts/migrations/v9_to_v10_episodes_workspace.sql +0 -109
  149. package/scripts/migrations/v9_to_v10_fresh.sql +0 -18
  150. package/templates/README.md +0 -70
  151. package/templates/managed-settings.template.json +0 -43
  152. package/tools/agentic-loop/decide-status.py +0 -210
  153. package/tools/agentic-loop/parse-metric.py +0 -106
  154. package/tools/agentic-loop/record-iteration.py +0 -223
@@ -151,10 +151,10 @@ MUTATIVE_VERBS: FrozenSet[str] = frozenset({
151
151
  "disconnect", "unbind", "force-delete", "force-remove", "erase",
152
152
  # Collaboration (GitHub/GitLab CLI)
153
153
  "comment", "label", "annotate", "approve", "close", "reopen", "tag",
154
- # Helm-specific
155
- "uninstall",
156
154
  # HTTP methods (e.g., glab api -X POST, gh api -X DELETE)
157
- "post", "put", "patch",
155
+ # NOTE: "put" and "patch" already appear under Modification above, and
156
+ # "uninstall" under Deletion/removal -- so only "post" is new here.
157
+ "post",
158
158
  })
159
159
 
160
160
  SIMULATION_VERBS: FrozenSet[str] = frozenset({
@@ -283,6 +283,12 @@ COMMAND_SUBCOMMAND_TIER_EXCEPTIONS: Dict[Tuple[str, str], str] = {
283
283
  # `gaia ac <verb>` (add/remove/edit/show/list/set-status): local acceptance-
284
284
  # criteria bookkeeping — reversible, no external effects.
285
285
  ("gaia", "ac"): CATEGORY_READ_ONLY,
286
+ # `gaia plan <verb>` (save/edit/show/list/set-status): local planning
287
+ # bookkeeping in the plan store — reversible, no external effects. Anchored
288
+ # here (not left to the SIMULATION_VERBS['plan'] lexical collision) so the
289
+ # exemption is explicit and carries the same DENY-verb guard as `gaia brief`:
290
+ # `gaia plan delete` (whole-record destruction) stays T3.
291
+ ("gaia", "plan"): CATEGORY_READ_ONLY,
286
292
  }
287
293
 
288
294
  # Verbs that stay gated even under an excepted group above. The exception
@@ -294,6 +300,37 @@ COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS: FrozenSet[str] = frozenset({
294
300
  })
295
301
 
296
302
 
303
+ # ============================================================================
304
+ # PRINCIPLE: consent-REDUCING operations are not T3.
305
+ # ----------------------------------------------------------------------------
306
+ # An operation requires T3 approval because it GRANTS capability or DESTROYS
307
+ # state — it moves the system toward *more* power or *less* recoverability, the
308
+ # directions that need informed consent. An operation that REVOKES, REJECTS,
309
+ # or CLEANS a consent grant Gaia itself issued moves in the opposite direction:
310
+ # it can only REDUCE the capability already granted. It never grants anything
311
+ # and never reaches outside the local approval store. Gating it creates an
312
+ # absurd loop — you would need an approval to clean up approvals.
313
+ #
314
+ # So: within Gaia's own consent layer (`gaia approvals ...`), verbs that REDUCE
315
+ # consent are exempted to read-only; the one verb that GRANTS capability
316
+ # (`approve`) is deliberately NOT in this set and stays T3. That asymmetry is
317
+ # the whole point: `approve` hands out capability without the AskUserQuestion
318
+ # flow, so it must remain gated; `revoke`/`reject`/`reject-all`/`clean` only
319
+ # take capability back, so they must not be.
320
+ #
321
+ # This is anchored to (base_cmd, group) so it applies ONLY to Gaia's own
322
+ # consent store, not to any other CLI's notion of "revoke"/"reject" (e.g. a
323
+ # cloud IAM revoke is a real remote mutation and must stay T3).
324
+ #
325
+ # Key: (base_cmd, subcommand-group) — e.g. ("gaia", "approvals").
326
+ # Value: frozenset of consent-REDUCING verbs under that group that are exempt.
327
+ CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS: Dict[Tuple[str, str], FrozenSet[str]] = {
328
+ ("gaia", "approvals"): frozenset({
329
+ "revoke", "reject", "reject-all", "clean",
330
+ }),
331
+ }
332
+
333
+
297
334
  # ============================================================================
298
335
  # Inline Code Detection — Language-Agnostic 3-Layer Approach
299
336
  # ============================================================================
@@ -1159,10 +1196,30 @@ def detect_mutative_command(command: str) -> MutativeResult:
1159
1196
  group_verb.split("-", 1)[0] in COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS
1160
1197
  or group_verb in COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS
1161
1198
  )
1162
- if (
1163
- subcommand_key in COMMAND_SUBCOMMAND_TIER_EXCEPTIONS
1164
- and not verb_is_destructive
1165
- ):
1199
+ if subcommand_key in COMMAND_SUBCOMMAND_TIER_EXCEPTIONS:
1200
+ if verb_is_destructive:
1201
+ # Whole-record destruction (e.g. `gaia plan delete`) must stay
1202
+ # T3 even inside an excepted group. Anchor it MUTATIVE here
1203
+ # instead of falling through to Step 4: the group token itself
1204
+ # (`plan`) collides lexically with SIMULATION_VERBS['plan'], so
1205
+ # the verb scanner would otherwise mis-classify the whole
1206
+ # command as SIMULATION and silently un-gate the delete. This
1207
+ # explicit return is what makes `gaia plan delete` behave like
1208
+ # `gaia brief delete` (where `brief` has no such collision).
1209
+ dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
1210
+ return MutativeResult(
1211
+ is_mutative=True,
1212
+ category=CATEGORY_MUTATIVE,
1213
+ verb=group_verb.split("-", 1)[0],
1214
+ dangerous_flags=dangerous_flags,
1215
+ cli_family=family,
1216
+ confidence="high",
1217
+ reason=(
1218
+ f"Whole-record destruction "
1219
+ f"'{base_cmd} {semantics.non_flag_tokens[0]} {group_verb}' "
1220
+ f"stays T3 despite the local bookkeeping exception"
1221
+ ),
1222
+ )
1166
1223
  dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
1167
1224
  if not dangerous_flags:
1168
1225
  target_category = COMMAND_SUBCOMMAND_TIER_EXCEPTIONS[subcommand_key]
@@ -1179,6 +1236,41 @@ def detect_mutative_command(command: str) -> MutativeResult:
1179
1236
  ),
1180
1237
  )
1181
1238
 
1239
+ # --- Step 3f: Consent-reducing operations are not T3 (anchored) ---
1240
+ # Within Gaia's own consent layer (`gaia approvals ...`), verbs that REDUCE
1241
+ # consent (revoke/reject/reject-all/clean) can only take back capability
1242
+ # already granted — they never grant anything and never reach outside the
1243
+ # local approval store, so they are not T3. The one consent-GRANTING verb
1244
+ # (`approve`) is deliberately absent from CONSENT_REDUCING_SUBCOMMAND_
1245
+ # EXCEPTIONS and falls through to Step 4, where it stays MUTATIVE/T3. That
1246
+ # asymmetry is the principle: granting capability needs consent, reducing it
1247
+ # does not. Anchored to (base_cmd, group) so it never relaxes another CLI's
1248
+ # "revoke" (e.g. a cloud IAM revoke is a real remote mutation, still T3).
1249
+ # Dangerous flags are still scanned so a slip like `--force` re-gates.
1250
+ if semantics.non_flag_tokens:
1251
+ consent_group_key = (base_cmd, semantics.non_flag_tokens[0])
1252
+ consent_verb = (
1253
+ semantics.non_flag_tokens[1]
1254
+ if len(semantics.non_flag_tokens) > 1 else ""
1255
+ )
1256
+ reducing_verbs = CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS.get(consent_group_key)
1257
+ if reducing_verbs is not None and consent_verb in reducing_verbs:
1258
+ dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
1259
+ if not dangerous_flags:
1260
+ return MutativeResult(
1261
+ is_mutative=False,
1262
+ category=CATEGORY_READ_ONLY,
1263
+ verb=consent_verb,
1264
+ cli_family=family,
1265
+ confidence="high",
1266
+ reason=(
1267
+ f"Consent-reducing operation "
1268
+ f"'{base_cmd} {semantics.non_flag_tokens[0]} {consent_verb}' "
1269
+ f"only revokes/rejects capability already granted — "
1270
+ f"not state-granting, so not T3"
1271
+ ),
1272
+ )
1273
+
1182
1274
  # --- Step 4: Scan semantic non-flag tokens near the command head ---
1183
1275
  # Priority order: SIMULATION > MUTATIVE > READ_ONLY > ALIASES
1184
1276
  for semantic_index, token in enumerate(semantics.semantic_head_tokens[1:], start=1):
@@ -23,6 +23,8 @@ Earlier flat-pipeline order preserved within phases for backward compat:
23
23
  - Blocked commands run before cloud_pipe and mutative_verbs in phase 3
24
24
  """
25
25
 
26
+ from __future__ import annotations
27
+
26
28
  import os
27
29
  import re
28
30
  import json
@@ -32,7 +34,6 @@ from dataclasses import dataclass
32
34
 
33
35
  from ..security.tiers import SecurityTier
34
36
  from ..security.blocked_commands import is_blocked_command
35
- from ..security.gitops_validator import validate_gitops_workflow
36
37
  from ..security.mutative_verbs import (
37
38
  detect_mutative_command,
38
39
  build_t3_block_response,
@@ -96,12 +97,35 @@ class BashValidationResult:
96
97
 
97
98
 
98
99
  # Patterns for AI tool attribution footers (auto-stripped from commits).
99
- # Covers Claude Code, GitHub Copilot, Aider, Windsurf, and any future
100
- # tool using the Co-authored-by git trailer convention.
100
+ # Covers Claude Code, GitHub Copilot, Aider, Windsurf, Codex, Gemini, the
101
+ # Anthropic model family (Opus/Sonnet/Haiku), and any future tool using the
102
+ # Co-authored-by git trailer convention.
103
+ #
104
+ # IMPORTANT: this list is the DETECTOR (`_detect_claude_footers`). It MUST stay
105
+ # aligned with the line patterns in `_strip_claude_footers` -- if the stripper
106
+ # can remove a footer the detector cannot see, the strip never fires (the
107
+ # early-normalization guard only strips when the detector returns True). Every
108
+ # footer shape the stripper removes has a corresponding detector entry here.
109
+ #
110
+ # None of these patterns anchor on a newline, so they also catch footers that
111
+ # arrive in a SECOND `-m "..."` argument (no preceding newline) -- the detector
112
+ # fires, and the stripper's `-m`-aware branch removes them.
101
113
  FORBIDDEN_FOOTER_PATTERNS = [
102
114
  r"Generated with\s+Claude Code",
103
115
  r"Generated with\s+\[?Claude Code\]?",
116
+ # Bare robot-emoji "Generated with ..." line (e.g. "🤖 Generated with ...")
117
+ # WITHOUT requiring the literal "Claude Code" after it -- the stripper has
118
+ # always removed this shape; the detector now sees it too.
119
+ r"🤖\s*Generated with",
120
+ # Robot emoji on its own is a strong AI-attribution signal.
121
+ r"🤖",
104
122
  r"Co-Authored-By:\s+Claude\b",
123
+ # Anthropic model family attributed via Co-Authored-By / Co-authored-with.
124
+ r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bOpus\b",
125
+ r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bSonnet\b",
126
+ r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bHaiku\b",
127
+ # "Approved-by:" attribution trailer.
128
+ r"Approved-by:",
105
129
  r"Co-authored-by:\s+GitHub Copilot\b",
106
130
  r"Co-authored-by:\s+aider\b",
107
131
  r"Co-authored-by:\s+Windsurf\b",
@@ -466,7 +490,7 @@ class BashValidator:
466
490
  # 3d. Smart sanitization (strip nohup, &, redirects)
467
491
  # 3e. Cloud pipe/redirect/chain check (corrective deny)
468
492
  # 3f. Dispatch to single/compound classification
469
- # (mutative_verbs, gitops_validator, safe-by-elimination)
493
+ # (mutative_verbs, safe-by-elimination)
470
494
  # ================================================================
471
495
 
472
496
  # 3a. Blocked commands check on FULL command (exit 2).
@@ -624,7 +648,7 @@ class BashValidator:
624
648
  if result.is_mutative:
625
649
  # Check for a DB-backed command_set grant first (M3 path).
626
650
  # Byte-for-byte match per D10: no normalization.
627
- cs_match = match_command_set_grant(command, session_id=session_id)
651
+ cs_match = match_command_set_grant(command)
628
652
  if cs_match is not None:
629
653
  cs_approval_id, cs_index = cs_match
630
654
  try:
@@ -732,17 +756,6 @@ class BashValidator:
732
756
  agent_type=agent_type,
733
757
  )
734
758
 
735
- # Check GitOps policy for kubectl/helm/flux commands
736
- if any(keyword in command for keyword in ("kubectl", "helm", "flux")):
737
- gitops_result = validate_gitops_workflow(command)
738
- if not gitops_result.allowed:
739
- return BashValidationResult(
740
- allowed=False,
741
- tier=SecurityTier.T3_BLOCKED,
742
- reason=f"GitOps policy violation: {gitops_result.reason}",
743
- suggestions=gitops_result.suggestions,
744
- )
745
-
746
759
  # Flag-dependent classification (sed -i, find -exec, tar -x, etc.)
747
760
  # This supplements mutative_verbs -- it catches flag-dependent mutations
748
761
  # that verb-based detection misses (e.g. "sed" has no mutative verb, but
@@ -775,7 +788,7 @@ class BashValidator:
775
788
  # never honoured and the command re-blocks unconditionally on
776
789
  # every retry (the flag path never reaches the matcher). The
777
790
  # consume + return semantics replicate the verb branch exactly.
778
- cs_match = match_command_set_grant(command, session_id=session_id)
791
+ cs_match = match_command_set_grant(command)
779
792
  if cs_match is not None:
780
793
  cs_approval_id, cs_index = cs_match
781
794
  try:
@@ -1004,32 +1017,77 @@ class BashValidator:
1004
1017
 
1005
1018
  def _strip_claude_footers(self, command: str) -> str:
1006
1019
  """
1007
- Strip Claude Code attribution footers from a command.
1020
+ Strip AI attribution footers from a commit command.
1008
1021
 
1009
1022
  Removes full lines matching forbidden footer patterns.
1010
1023
  Works on raw command string regardless of quoting/HEREDOC format.
1011
1024
  Preserves trailing quote/paren characters that close the commit
1012
1025
  message (e.g., the closing " in -m "...footer").
1013
1026
 
1027
+ Covers, kept ALIGNED with FORBIDDEN_FOOTER_PATTERNS (the detector):
1028
+ - Co-authored-by / Co-authored-with: Claude, Copilot, aider,
1029
+ Windsurf, Cursor, Codex, Gemini, and the Anthropic model family
1030
+ (Opus / Sonnet / Haiku)
1031
+ - "Generated with [Claude Code]" and the bare "🤖 Generated with ..."
1032
+ - a bare robot emoji 🤖 line
1033
+ - "Approved-by:" trailers
1034
+ Both newline-anchored footer LINES and footers carried in a SECOND
1035
+ ``-m "..."`` argument (no preceding newline) are handled.
1036
+
1037
+ LIMITATION -- ``git commit -F <file>`` / ``--file=<file>``: when the
1038
+ message body lives in a file, the footer is NOT in the command string
1039
+ the PreToolUse hook receives. This stripper CANNOT see or remove it,
1040
+ and deliberately does NOT read the referenced file (reading arbitrary
1041
+ paths from a hook would be an unbounded side effect and a new attack
1042
+ surface). Footer suppression for ``-F`` commits is therefore out of
1043
+ scope here and must be enforced elsewhere (e.g. a commit-msg git hook).
1044
+
1014
1045
  Args:
1015
1046
  command: Raw command string
1016
1047
 
1017
1048
  Returns:
1018
1049
  Command with footer lines removed
1019
1050
  """
1020
- # Remove full lines that contain AI attribution patterns.
1051
+ # Author/model alternation reused across line- and -m-shaped patterns.
1052
+ _authors = (
1053
+ r"Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini"
1054
+ r"|Opus|Sonnet|Haiku"
1055
+ )
1056
+
1057
+ # (1) Remove full lines that contain AI attribution patterns.
1021
1058
  # Each pattern matches the newline + footer content, then uses a
1022
1059
  # lookahead to stop before any trailing quote/paren/bracket
1023
1060
  # sequence that closes the command structure. The captured group
1024
1061
  # is replaced with empty string, leaving the closing chars intact.
1025
1062
  footer_line_patterns = [
1026
- r'\n\s*Co-[Aa]uthored-[Bb]y:\s+(?:Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini)[^\n]*?(?=["\')\]]*(?:\n|$))',
1063
+ r'\n\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):\s+(?:' + _authors + r')[^\n]*?(?=["\')\]]*(?:\n|$))',
1064
+ # Co-authored-* lines naming an Anthropic model anywhere on the line.
1065
+ r'\n\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*?\b(?:Opus|Sonnet|Haiku)\b[^\n]*?(?=["\')\]]*(?:\n|$))',
1066
+ r'\n\s*Approved-by:[^\n]*?(?=["\')\]]*(?:\n|$))',
1027
1067
  r'\n\s*Generated with\s+\[?Claude Code\]?[^\n]*?(?=["\')\]]*(?:\n|$))',
1028
1068
  r'\n\s*🤖\s*Generated with[^\n]*?(?=["\')\]]*(?:\n|$))',
1069
+ # Bare robot-emoji line (emoji not followed by "Generated with").
1070
+ r'\n\s*🤖[^\n]*?(?=["\')\]]*(?:\n|$))',
1029
1071
  ]
1030
1072
  for pattern in footer_line_patterns:
1031
1073
  command = re.sub(pattern, '', command, flags=re.IGNORECASE)
1032
1074
 
1075
+ # (2) Remove footers carried in a SEPARATE ``-m "..."`` / ``-m '...'``
1076
+ # argument. Repeated ``-m`` flags are concatenated by git as separate
1077
+ # paragraphs, so an attribution footer often arrives as
1078
+ # git commit -m "real message" -m "Co-Authored-By: ... Opus"
1079
+ # with NO preceding newline -- the line patterns above cannot see it.
1080
+ # Drop the entire trailing ``-m "<footer>"`` flag+value when its value
1081
+ # is (essentially) just an attribution footer.
1082
+ m_footer_patterns = [
1083
+ r'''\s+-m\s+(["'])\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):\s+(?:''' + _authors + r''')[^"']*\1''',
1084
+ r'''\s+-m\s+(["'])\s*Approved-by:[^"']*\1''',
1085
+ r'''\s+-m\s+(["'])\s*🤖[^"']*\1''',
1086
+ r'''\s+-m\s+(["'])\s*Generated with\s+\[?Claude Code\]?[^"']*\1''',
1087
+ ]
1088
+ for pattern in m_footer_patterns:
1089
+ command = re.sub(pattern, '', command, flags=re.IGNORECASE)
1090
+
1033
1091
  # Clean up trailing whitespace inside quotes/heredoc
1034
1092
  # Collapse 3+ consecutive newlines to 2
1035
1093
  command = re.sub(r'\n{3,}', '\n\n', command)
@@ -1222,6 +1280,7 @@ def _build_sealed_payload(
1222
1280
  verb: str,
1223
1281
  category: str,
1224
1282
  agent_type: str = "",
1283
+ command_set: list | None = None,
1225
1284
  ) -> dict:
1226
1285
  """Build a sealed_payload dict from hook-intercepted command context.
1227
1286
 
@@ -1229,16 +1288,51 @@ def _build_sealed_payload(
1229
1288
  and calls store.insert_requested(). The 7 D13 fields are populated from
1230
1289
  what is available at intercept time.
1231
1290
 
1291
+ Single vs. multi-command (COMMAND_SET):
1292
+ By default this builds a SINGLE-command payload -- ``commands`` is
1293
+ ``[command]`` and no ``command_set`` key is present, so activation
1294
+ mints a single-use SCOPE_SEMANTIC_SIGNATURE grant.
1295
+
1296
+ When ``command_set`` is supplied (a list of ``{command, rationale}``
1297
+ dicts representing more than one command the agent wants under ONE
1298
+ consent), the payload additionally carries a ``command_set`` key
1299
+ verbatim and ``commands`` lists every command string in the set. This
1300
+ is the signal ``activate_db_pending_by_prefix`` reads to branch into
1301
+ ``create_command_set_grant`` instead of degrading to a single command.
1302
+ The set is NOT collapsed -- every item survives into the grant.
1303
+
1232
1304
  Args:
1233
- command: The full Bash command string that was blocked.
1305
+ command: The full Bash command string that was blocked (the primary /
1306
+ first command; used for ``exact_content`` and the singular display).
1234
1307
  verb: The detected mutative verb (e.g. 'push', 'delete').
1235
1308
  category: The verb category string (e.g. 'MUTATIVE').
1236
1309
  agent_type: Name of the originating agent (may be empty).
1310
+ command_set: Optional list of ``{command, rationale}`` dicts. When it
1311
+ contains more than one item, the payload becomes a COMMAND_SET
1312
+ envelope. A list with a single item (or None) keeps the singular
1313
+ semantic-signature behaviour.
1237
1314
 
1238
1315
  Returns:
1239
- Dict with the 7 sealed_payload fields from D13.
1316
+ Dict with the 7 sealed_payload fields from D13, plus an optional
1317
+ ``command_set`` key when a multi-command set was supplied.
1240
1318
  """
1241
- return {
1319
+ # Normalize the command_set into the canonical [{command, rationale}, ...]
1320
+ # shape and decide whether this is a genuine multi-command envelope. A set
1321
+ # of length <= 1 is NOT multi-command -- it stays the singular path so we
1322
+ # never mint a COMMAND_SET grant for one command.
1323
+ normalized_set: list = []
1324
+ if command_set:
1325
+ for item in command_set:
1326
+ if isinstance(item, dict) and item.get("command"):
1327
+ normalized_set.append(
1328
+ {
1329
+ "command": item["command"],
1330
+ "rationale": item.get("rationale", ""),
1331
+ }
1332
+ )
1333
+ is_command_set = len(normalized_set) > 1
1334
+
1335
+ payload = {
1242
1336
  "operation": f"{category} command intercepted: {verb}",
1243
1337
  "exact_content": command,
1244
1338
  "scope": command.split()[0] if command.strip() else "unknown",
@@ -1250,9 +1344,18 @@ def _build_sealed_payload(
1250
1344
  if agent_type
1251
1345
  else f"A {category.lower()} ({verb}) command requires user approval per T3 policy."
1252
1346
  ),
1253
- "commands": [command],
1347
+ "commands": (
1348
+ [it["command"] for it in normalized_set] if is_command_set else [command]
1349
+ ),
1254
1350
  }
1255
1351
 
1352
+ if is_command_set:
1353
+ # Carry the full {command, rationale} set verbatim. This is the
1354
+ # multi-command signal the activation path branches on.
1355
+ payload["command_set"] = normalized_set
1356
+
1357
+ return payload
1358
+
1256
1359
 
1257
1360
  def decide_t3_outcome(
1258
1361
  command: str,
@@ -23,11 +23,75 @@ Usage:
23
23
  import json
24
24
  import os
25
25
  import re
26
- from typing import Dict, List, Any, Optional
26
+ from typing import Dict, List, Optional
27
27
  from datetime import datetime
28
28
  from dataclasses import dataclass
29
29
 
30
30
 
31
+ # ---------------------------------------------------------------------------
32
+ # Git commit standards -- inlined constants.
33
+ #
34
+ # These were previously loaded from config/git_standards.json. They are now
35
+ # module-level constants: commit_validator.py is the single runtime consumer
36
+ # of these format/subject/body rules, so the JSON indirection added drift risk
37
+ # without any benefit. Footer detection/stripping is NOT here -- it lives,
38
+ # hardcoded, in bash_validator (footers are bash_validator's responsibility).
39
+ # ---------------------------------------------------------------------------
40
+
41
+ FORMAT = "conventional_commits"
42
+
43
+ TYPE_ALLOWED = (
44
+ "feat",
45
+ "fix",
46
+ "refactor",
47
+ "docs",
48
+ "test",
49
+ "chore",
50
+ "ci",
51
+ "perf",
52
+ "style",
53
+ "build",
54
+ )
55
+
56
+ SCOPE_REQUIRED = False
57
+ SCOPE_EXAMPLES = ("helmrelease", "terraform", "pg-non-prod", "infrastructure")
58
+
59
+ SUBJECT_MAX_LENGTH = 72
60
+ SUBJECT_RULES = {
61
+ "capitalize_first_letter": False,
62
+ "no_period_at_end": True,
63
+ "imperative_mood": True,
64
+ "no_emoji": True,
65
+ }
66
+
67
+ BODY_MAX_LINE_LENGTH = 72
68
+ BODY_REQUIRED = False
69
+
70
+ EXAMPLES_VALID = (
71
+ "feat(helmrelease): add Phase 3.3 services",
72
+ "fix(pg-non-prod): correct API key environment variable mappings",
73
+ "refactor: simplify context provider logic",
74
+ "docs: update README with new workflow",
75
+ "chore(deps): update terraform to v1.6.0",
76
+ )
77
+
78
+ EXAMPLES_INVALID = (
79
+ "Added new feature",
80
+ "Fixed bugs",
81
+ "Updates",
82
+ "feat: add feature\n\n🤖 Generated with Claude Code",
83
+ "feat: add new feature 🚀",
84
+ "fix: 🐛 correct bug",
85
+ )
86
+
87
+ ENFORCEMENT = {
88
+ "enabled": True,
89
+ "block_on_failure": True,
90
+ "log_violations": True,
91
+ "log_path": ".claude/logs/commit-violations.jsonl",
92
+ }
93
+
94
+
31
95
  @dataclass
32
96
  class ValidationResult:
33
97
  """Result of commit message validation."""
@@ -44,43 +108,30 @@ class CommitMessageValidator:
44
108
  """
45
109
  Validates git commit messages against project standards.
46
110
 
47
- Standards are defined in .claude/config/git_standards.json
111
+ Standards are inlined as module-level constants (TYPE_ALLOWED,
112
+ SUBJECT_MAX_LENGTH, SUBJECT_RULES, etc.). Footer detection is not handled
113
+ here -- that is bash_validator's responsibility.
48
114
  """
49
115
 
50
116
  def __init__(self, config_path: Optional[str] = None):
51
117
  """
52
- Initialize validator with configuration.
118
+ Initialize validator.
53
119
 
54
120
  Args:
55
- config_path: Optional path to git_standards.json
56
- If None, uses default location
121
+ config_path: Accepted for backward compatibility only. When given,
122
+ it anchors base_path (used to resolve the relative
123
+ violation log path); the rules themselves come from
124
+ the module-level constants, not from any file.
57
125
  """
58
126
  if config_path is None:
59
- # Default path relative to this file
60
- # From hooks/modules/validation/ go up to gaia-ops root
127
+ # base_path -> gaia-ops root, used to resolve the violation log.
61
128
  # __file__ -> hooks/modules/validation/commit_validator.py
62
- # dirname(dirname(dirname(dirname(__file__)))) -> gaia-ops root
63
129
  base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
64
- config_path = os.path.join(base_path, 'config', 'git_standards.json')
65
130
  else:
66
- # If config_path provided, derive base_path from it
67
131
  base_path = os.path.dirname(os.path.dirname(config_path))
68
132
 
69
133
  self.base_path = base_path
70
- self.config_path = config_path
71
- self.config = self._load_config()
72
- self.standards = self.config.get('commit_message', {})
73
- self.enforcement = self.config.get('enforcement', {})
74
-
75
- def _load_config(self) -> Dict[str, Any]:
76
- """Load git standards configuration from JSON file."""
77
- if not os.path.exists(self.config_path):
78
- raise FileNotFoundError(
79
- f"Git standards configuration not found at: {self.config_path}"
80
- )
81
-
82
- with open(self.config_path, 'r') as f:
83
- return json.load(f)
134
+ self.enforcement = ENFORCEMENT
84
135
 
85
136
  def validate(self, message: str) -> ValidationResult:
86
137
  """
@@ -95,19 +146,19 @@ class CommitMessageValidator:
95
146
  errors = []
96
147
  warnings = []
97
148
 
98
- # 1. Check for forbidden footers (CRITICAL)
99
- footer_errors = self._check_forbidden_footers(message)
100
- errors.extend(footer_errors)
149
+ # Note: forbidden-footer detection is intentionally NOT done here.
150
+ # Footers are bash_validator's responsibility (stripping/detection
151
+ # is hardcoded there).
101
152
 
102
- # 2. Check conventional commits format
153
+ # 1. Check conventional commits format
103
154
  format_errors = self._check_conventional_format(message)
104
155
  errors.extend(format_errors)
105
156
 
106
- # 3. Check subject line rules
157
+ # 2. Check subject line rules
107
158
  subject_errors = self._check_subject_rules(message)
108
159
  errors.extend(subject_errors)
109
160
 
110
- # 4. Check body rules (warnings only)
161
+ # 3. Check body rules (warnings only)
111
162
  body_warnings = self._check_body_rules(message)
112
163
  warnings.extend(body_warnings)
113
164
 
@@ -121,22 +172,6 @@ class CommitMessageValidator:
121
172
  warnings=warnings
122
173
  )
123
174
 
124
- def _check_forbidden_footers(self, message: str) -> List[Dict[str, str]]:
125
- """Check for forbidden footers in commit message."""
126
- errors = []
127
- forbidden = self.standards.get('footer_forbidden', [])
128
-
129
- for forbidden_text in forbidden:
130
- if forbidden_text.lower() in message.lower():
131
- errors.append({
132
- 'type': 'FORBIDDEN_FOOTER',
133
- 'message': f"Commit message contains forbidden footer: '{forbidden_text}'",
134
- 'fix': f"Remove all occurrences of '{forbidden_text}'",
135
- 'severity': 'error'
136
- })
137
-
138
- return errors
139
-
140
175
  def _check_conventional_format(self, message: str) -> List[Dict[str, str]]:
141
176
  """Check if message follows Conventional Commits format."""
142
177
  errors = []
@@ -147,16 +182,16 @@ class CommitMessageValidator:
147
182
 
148
183
  # Pattern: type(scope)?: description
149
184
  # Examples: feat: add feature, fix(api): correct bug
150
- allowed_types = '|'.join(self.standards.get('type_allowed', []))
185
+ allowed_types = '|'.join(TYPE_ALLOWED)
151
186
  pattern = rf'^({allowed_types})(\(.+?\))?: .+$'
152
187
 
153
188
  if not re.match(pattern, subject):
154
189
  errors.append({
155
190
  'type': 'INVALID_FORMAT',
156
191
  'message': 'Commit message does not follow Conventional Commits format',
157
- 'fix': f"Use format: type(scope): description\nAllowed types: {', '.join(self.standards.get('type_allowed', []))}",
192
+ 'fix': f"Use format: type(scope): description\nAllowed types: {', '.join(TYPE_ALLOWED)}",
158
193
  'severity': 'error',
159
- 'examples': self.standards.get('examples_valid', [])
194
+ 'examples': list(EXAMPLES_VALID)
160
195
  })
161
196
 
162
197
  return errors
@@ -175,7 +210,7 @@ class CommitMessageValidator:
175
210
  description = match.group(2)
176
211
 
177
212
  # Check max length
178
- max_length = self.standards.get('subject_max_length', 72)
213
+ max_length = SUBJECT_MAX_LENGTH
179
214
  if len(subject) > max_length:
180
215
  errors.append({
181
216
  'type': 'SUBJECT_TOO_LONG',
@@ -185,7 +220,7 @@ class CommitMessageValidator:
185
220
  })
186
221
 
187
222
  # Check for period at end
188
- rules = self.standards.get('subject_rules', {})
223
+ rules = SUBJECT_RULES
189
224
  if rules.get('no_period_at_end', True) and description.endswith('.'):
190
225
  errors.append({
191
226
  'type': 'SUBJECT_ENDS_WITH_PERIOD',
@@ -242,7 +277,7 @@ class CommitMessageValidator:
242
277
  })
243
278
 
244
279
  # Check body line length
245
- max_length = self.standards.get('body_max_line_length', 72)
280
+ max_length = BODY_MAX_LINE_LENGTH
246
281
  for i, line in enumerate(lines[2:], start=3): # Skip subject and blank line
247
282
  if len(line) > max_length and not line.startswith('http'):
248
283
  warnings.append({
@@ -285,13 +320,13 @@ class CommitMessageValidator:
285
320
  def get_examples(self) -> Dict[str, List[str]]:
286
321
  """Get example commit messages (valid and invalid)."""
287
322
  return {
288
- 'valid': self.standards.get('examples_valid', []),
289
- 'invalid': self.standards.get('examples_invalid', [])
323
+ 'valid': list(EXAMPLES_VALID),
324
+ 'invalid': list(EXAMPLES_INVALID)
290
325
  }
291
326
 
292
327
  def get_allowed_types(self) -> List[str]:
293
328
  """Get list of allowed commit types."""
294
- return self.standards.get('type_allowed', [])
329
+ return list(TYPE_ALLOWED)
295
330
 
296
331
  def format_error_message(self, validation: ValidationResult) -> str:
297
332
  """
@@ -10,10 +10,10 @@ The Contract workflow transitions live in
10
10
  here for symmetry; the brief lifecycle transitions live in
11
11
  ``gaia.briefs.store._LEGAL_TRANSITIONS`` and are likewise imported.
12
12
 
13
- The plan and task lifecycle transitions are defined here directly (the
14
- underlying tables ``plans`` and ``tasks`` have no CLI surface yet -- a
15
- separate brief, ``cli-completion``, will add ``gaia plan`` and
16
- ``gaia task`` commands that consume these helpers).
13
+ The plan and task lifecycle transitions are defined here directly. The
14
+ ``gaia plan`` and ``gaia task`` commands (``bin/cli/plan.py`` and
15
+ ``bin/cli/task.py``) consume these helpers when driving the ``plans`` and
16
+ ``tasks`` lifecycle.
17
17
  """
18
18
 
19
19
  from __future__ import annotations