@jaguilar87/gaia 5.0.7 → 5.0.9

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 (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +486 -474
  6. package/bin/cli/brief.py +13 -0
  7. package/bin/cli/doctor.py +1 -1
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +92 -86
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  12. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  13. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  14. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  15. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  16. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  17. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  18. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  19. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  20. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  21. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  22. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  23. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
  24. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
  25. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  26. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  27. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
  28. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  29. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
  30. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  31. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
  32. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
  33. package/dist/gaia-ops/tools/migration/README.md +10 -12
  34. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  35. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  36. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  37. package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
  38. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  39. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  40. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  41. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  42. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  43. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  44. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  45. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  46. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  47. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  48. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  49. package/gaia/approvals/__init__.py +2 -1
  50. package/gaia/approvals/store.py +165 -15
  51. package/gaia/store/schema.sql +38 -1
  52. package/gaia/store/writer.py +400 -0
  53. package/hooks/adapters/claude_code.py +92 -86
  54. package/hooks/elicitation_result.py +20 -75
  55. package/hooks/modules/agents/handoff_persister.py +13 -2
  56. package/hooks/modules/context/context_injector.py +23 -7
  57. package/hooks/modules/events/event_writer.py +63 -96
  58. package/hooks/modules/security/__init__.py +0 -2
  59. package/hooks/modules/security/approval_cleanup.py +238 -69
  60. package/hooks/modules/security/approval_grants.py +506 -1103
  61. package/hooks/modules/security/mutative_verbs.py +24 -1
  62. package/hooks/modules/session/pending_scanner.py +150 -90
  63. package/hooks/modules/session/session_manifest.py +257 -28
  64. package/hooks/modules/tools/bash_validator.py +19 -0
  65. package/hooks/post_compact.py +1 -0
  66. package/hooks/pre_compact.py +1 -0
  67. package/hooks/user_prompt_submit.py +20 -0
  68. package/package.json +1 -1
  69. package/pyproject.toml +1 -1
  70. package/scripts/bootstrap_database.sh +66 -17
  71. package/scripts/migrations/README.md +26 -14
  72. package/scripts/migrations/schema.checksum +2 -2
  73. package/scripts/migrations/v18_to_v19.sql +36 -0
  74. package/scripts/migrations/v19_to_v20.sql +20 -0
  75. package/skills/agent-approval-protocol/SKILL.md +50 -14
  76. package/skills/agent-approval-protocol/reference.md +16 -9
  77. package/skills/agent-protocol/examples.md +12 -1
  78. package/skills/gaia-patterns/reference.md +2 -2
  79. package/skills/orchestrator-present-approval/SKILL.md +69 -22
  80. package/skills/orchestrator-present-approval/reference.md +16 -3
  81. package/skills/orchestrator-present-approval/template.md +20 -14
  82. package/skills/pending-approvals/SKILL.md +16 -11
  83. package/skills/subagent-request-approval/SKILL.md +28 -3
  84. package/skills/subagent-request-approval/reference.md +34 -8
  85. package/tools/migration/README.md +10 -12
  86. package/tools/scan/orchestrator.py +194 -10
  87. package/tools/scan/tests/test_integration.py +1 -2
  88. package/bin/cli/plans.py +0 -517
  89. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  90. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  91. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  92. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  93. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  94. package/gaia/approvals/revert.py +0 -282
  95. package/tools/context/deep_merge.py +0 -159
  96. package/tools/migration/migrate_04_harness_events.py +0 -132
  97. package/tools/migration/migrate_04_harness_events.sh +0 -23
  98. package/tools/scan/merge.py +0 -213
  99. package/tools/scan/tests/test_merge.py +0 -269
package/bin/cli/plans.py DELETED
@@ -1,517 +0,0 @@
1
- """
2
- gaia plans -- List and display project briefs/plans.
3
-
4
- Subcommands:
5
- gaia plans list [--json] List all briefs with status info
6
- gaia plans show <name> [--json] Show brief.md + plan.md for a named brief
7
- (accepts name with or without prefix)
8
- gaia plans rename <name> [--all] Sync directory prefix to frontmatter status
9
- (accepts name with or without prefix)
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
- import sys
16
- from pathlib import Path
17
-
18
-
19
- # ---------------------------------------------------------------------------
20
- # Root detection
21
- # ---------------------------------------------------------------------------
22
-
23
- def _find_project_root(start: Path) -> Path | None:
24
- """Locate the project root that owns .claude/project-context/briefs/.
25
-
26
- Resolution order:
27
- 1. CLAUDE_PLUGIN_DATA env var (set by Claude Code at runtime) -- its
28
- parent is the project root.
29
- 2. Walk up from ``start`` looking for .claude/project-context/briefs/
30
- that actually exists (contains user brief data, not plugin config).
31
- 3. Walk up from ``start`` looking for .claude/project-context/ (has
32
- project-context data even if briefs/ is absent).
33
- 4. Walk up from ``start`` for any .claude/ directory (original fallback).
34
-
35
- Strategies 2-3 ensure the CLI skips a plugin's own .claude/ config dir
36
- (e.g., gaia-ops-dev/.claude/) and continues up to the user's project root
37
- (e.g., ~/ws/me/.claude/) when the CLI is invoked from inside the plugin
38
- subdirectory.
39
- """
40
- import os
41
- plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA")
42
- if plugin_data:
43
- candidate = Path(plugin_data)
44
- # CLAUDE_PLUGIN_DATA points to .claude/ itself; its parent is the root.
45
- if candidate.is_dir():
46
- return candidate.parent
47
- # If the path doesn't exist yet, still trust the env var.
48
- return candidate.parent
49
-
50
- current = start.resolve()
51
- candidates = [current, *current.parents]
52
-
53
- # Pass 1: prefer a root that has the actual briefs data directory.
54
- for parent in candidates:
55
- if (parent / ".claude" / "project-context" / "briefs").is_dir():
56
- return parent
57
-
58
- # Pass 2: accept any root that has project-context/ (data dir present).
59
- for parent in candidates:
60
- if (parent / ".claude" / "project-context").is_dir():
61
- return parent
62
-
63
- # Pass 3: original fallback -- any .claude/ directory.
64
- for parent in candidates:
65
- if (parent / ".claude").is_dir():
66
- return parent
67
-
68
- return None
69
-
70
-
71
- def _get_briefs_dir(project_root: Path) -> Path:
72
- return project_root / ".claude" / "project-context" / "briefs"
73
-
74
-
75
- # ---------------------------------------------------------------------------
76
- # Frontmatter parsing
77
- # ---------------------------------------------------------------------------
78
-
79
- def _parse_frontmatter(text: str) -> dict:
80
- """Extract key: value pairs from YAML frontmatter (no external deps).
81
-
82
- Returns an empty dict if no frontmatter found.
83
- """
84
- if not text.startswith("---"):
85
- return {}
86
- try:
87
- end = text.index("---", 3)
88
- except ValueError:
89
- return {}
90
- fm_text = text[3:end]
91
- result = {}
92
- for line in fm_text.splitlines():
93
- stripped = line.strip()
94
- if not stripped or stripped.startswith("#"):
95
- continue
96
- if ":" in stripped:
97
- key, _, value = stripped.partition(":")
98
- key = key.strip()
99
- value = value.strip()
100
- if value:
101
- result[key] = value
102
- return result
103
-
104
-
105
- # ---------------------------------------------------------------------------
106
- # Prefix helpers
107
- # ---------------------------------------------------------------------------
108
-
109
- _KNOWN_PREFIXES = ("open_", "in-progress_", "closed_")
110
-
111
- _STATUS_TO_PREFIX: dict[str, str] = {
112
- "draft": "open_",
113
- "ready": "open_",
114
- "in-progress": "in-progress_",
115
- "complete": "closed_",
116
- "verified": "closed_",
117
- "done": "closed_",
118
- }
119
-
120
-
121
- def _strip_prefix(name: str) -> str:
122
- """Return the bare feature name without any known prefix."""
123
- for prefix in _KNOWN_PREFIXES:
124
- if name.startswith(prefix):
125
- return name[len(prefix):]
126
- return name
127
-
128
-
129
- def _resolve_brief_dir(briefs_dir: Path, name: str) -> Path | None:
130
- """Find the brief directory for ``name`` regardless of prefix.
131
-
132
- If ``name`` already contains a valid prefix, look for that exact path first.
133
- Otherwise (or if not found), search by stripping all known prefixes from
134
- existing directories and matching the bare suffix.
135
-
136
- Returns the Path if a unique match is found, None if not found.
137
- Raises ValueError if multiple directories match the same bare name.
138
- """
139
- if not briefs_dir.is_dir():
140
- return None
141
-
142
- # Exact match first (name may already have the right prefix).
143
- exact = briefs_dir / name
144
- if exact.is_dir():
145
- return exact
146
-
147
- # Fuzzy match: strip prefix from ``name``, then compare bare suffixes.
148
- bare = _strip_prefix(name)
149
- matches = [
150
- entry for entry in briefs_dir.iterdir()
151
- if entry.is_dir() and _strip_prefix(entry.name) == bare
152
- ]
153
- if len(matches) == 1:
154
- return matches[0]
155
- if len(matches) > 1:
156
- raise ValueError(
157
- f"Ambiguous brief name '{name}': multiple matches {[m.name for m in matches]}"
158
- )
159
- return None
160
-
161
-
162
- # ---------------------------------------------------------------------------
163
- # Core helpers
164
- # ---------------------------------------------------------------------------
165
-
166
- def _collect_briefs(briefs_dir: Path) -> list[dict]:
167
- """Walk briefs_dir and return a list of brief info dicts."""
168
- results = []
169
- if not briefs_dir.is_dir():
170
- return results
171
-
172
- for entry in sorted(briefs_dir.iterdir()):
173
- if not entry.is_dir():
174
- continue
175
- brief_file = entry / "brief.md"
176
- plan_file = entry / "plan.md"
177
- if not brief_file.exists():
178
- continue
179
-
180
- brief_text = brief_file.read_text(encoding="utf-8")
181
- brief_fm = _parse_frontmatter(brief_text)
182
-
183
- plan_fm: dict = {}
184
- if plan_file.exists():
185
- plan_text = plan_file.read_text(encoding="utf-8")
186
- plan_fm = _parse_frontmatter(plan_text)
187
-
188
- results.append(
189
- {
190
- "name": entry.name,
191
- "brief_status": brief_fm.get("status", "(none)"),
192
- "plan_file_status": plan_fm.get("status", "(absent)") if plan_file.exists() else "(absent)",
193
- "has_plan": plan_file.exists(),
194
- }
195
- )
196
- return results
197
-
198
-
199
- # ---------------------------------------------------------------------------
200
- # Subcommand handlers
201
- # ---------------------------------------------------------------------------
202
-
203
- def _cmd_list(args) -> int:
204
- """Handle `gaia plans list`.
205
-
206
- Delegates to the substrate (SQLite) via gaia.briefs.list_briefs -- same
207
- source of truth as `gaia brief list`. The legacy filesystem reader
208
- (.claude/project-context/briefs/) is no longer used here.
209
- """
210
- try:
211
- from gaia.briefs import list_briefs
212
- from gaia.project import current as _project_current
213
- workspace = _project_current()
214
- briefs = list_briefs(workspace)
215
- except Exception as exc:
216
- msg = f"gaia plans list: failed to read store: {exc}"
217
- if getattr(args, "json", False):
218
- print(json.dumps({"error": msg}))
219
- else:
220
- print(f"Error: {msg}", file=sys.stderr)
221
- return 1
222
-
223
- if getattr(args, "json", False):
224
- print(json.dumps({"briefs": briefs}, indent=2, default=str))
225
- return 0
226
-
227
- if not briefs:
228
- print("(no briefs)")
229
- return 0
230
-
231
- # Human-readable table -- matches `gaia brief list` table style
232
- name_w = max(4, max(len(b["name"]) for b in briefs))
233
- status_w = max(6, max(len((b.get("status") or "")) for b in briefs))
234
- title_w = max(5, max(len((b.get("title") or "")) for b in briefs))
235
- print(f"{'NAME':<{name_w}} {'STATUS':<{status_w}} {'TITLE':<{title_w}}")
236
- print("-" * (name_w + status_w + title_w + 4))
237
- for b in briefs:
238
- print(f"{b['name']:<{name_w}} {(b.get('status') or ''):<{status_w}} "
239
- f"{(b.get('title') or ''):<{title_w}}")
240
- return 0
241
-
242
-
243
- def _cmd_show(args) -> int:
244
- """Handle `gaia plans show <name>` (prefix-tolerant)."""
245
- project_root = _find_project_root(Path.cwd())
246
- if project_root is None:
247
- msg = "gaia plans: could not find project root (.claude/ directory)"
248
- if getattr(args, "json", False):
249
- print(json.dumps({"error": msg}))
250
- else:
251
- print(f"Error: {msg}", file=sys.stderr)
252
- return 1
253
-
254
- brief_name: str = args.name
255
- briefs_dir = _get_briefs_dir(project_root)
256
-
257
- try:
258
- brief_dir = _resolve_brief_dir(briefs_dir, brief_name)
259
- except ValueError as exc:
260
- msg = str(exc)
261
- if getattr(args, "json", False):
262
- print(json.dumps({"error": msg}))
263
- else:
264
- print(f"Error: {msg}", file=sys.stderr)
265
- return 1
266
-
267
- if brief_dir is None:
268
- msg = f"Brief '{brief_name}' not found in {briefs_dir}"
269
- if getattr(args, "json", False):
270
- print(json.dumps({"error": msg}))
271
- else:
272
- print(f"Error: {msg}", file=sys.stderr)
273
- return 1
274
-
275
- # Use the resolved directory name for display.
276
- brief_name = brief_dir.name
277
-
278
- brief_file = brief_dir / "brief.md"
279
- plan_file = brief_dir / "plan.md"
280
-
281
- if not brief_file.exists():
282
- msg = f"brief.md not found for '{brief_name}'"
283
- if getattr(args, "json", False):
284
- print(json.dumps({"error": msg}))
285
- else:
286
- print(f"Error: {msg}", file=sys.stderr)
287
- return 1
288
-
289
- brief_content = brief_file.read_text(encoding="utf-8")
290
- plan_content = plan_file.read_text(encoding="utf-8") if plan_file.exists() else None
291
-
292
- if getattr(args, "json", False):
293
- payload: dict = {"name": brief_name, "brief": brief_content}
294
- if plan_content is not None:
295
- payload["plan"] = plan_content
296
- print(json.dumps(payload, indent=2))
297
- return 0
298
-
299
- # Human-readable output
300
- print(f"=== {brief_name}/brief.md ===")
301
- print(brief_content)
302
- if plan_content is not None:
303
- print(f"=== {brief_name}/plan.md ===")
304
- print(plan_content)
305
- else:
306
- print(f"(no plan.md for '{brief_name}')")
307
- return 0
308
-
309
-
310
- def _rename_one(briefs_dir: Path, name: str) -> dict:
311
- """Rename a single brief directory to match its frontmatter status.
312
-
313
- Returns a result dict with keys: old_name, new_name, status, action.
314
- action is "renamed" or "already-correct".
315
- Raises ValueError on ambiguous match or missing brief.
316
- """
317
- brief_dir = _resolve_brief_dir(briefs_dir, name)
318
- if brief_dir is None:
319
- raise ValueError(f"Brief '{name}' not found in {briefs_dir}")
320
-
321
- brief_file = brief_dir / "brief.md"
322
- if not brief_file.exists():
323
- raise ValueError(f"brief.md not found in '{brief_dir.name}'")
324
-
325
- brief_fm = _parse_frontmatter(brief_file.read_text(encoding="utf-8"))
326
- status = brief_fm.get("status", "")
327
-
328
- expected_prefix = _STATUS_TO_PREFIX.get(status)
329
- if expected_prefix is None:
330
- raise ValueError(
331
- f"Unknown status '{status}' in '{brief_dir.name}'. "
332
- f"Known values: {sorted(_STATUS_TO_PREFIX)}"
333
- )
334
-
335
- bare = _strip_prefix(brief_dir.name)
336
- expected_name = expected_prefix + bare
337
-
338
- if brief_dir.name == expected_name:
339
- return {
340
- "old_name": brief_dir.name,
341
- "new_name": brief_dir.name,
342
- "status": status,
343
- "action": "already-correct",
344
- }
345
-
346
- new_dir = briefs_dir / expected_name
347
- brief_dir.rename(new_dir)
348
- return {
349
- "old_name": brief_dir.name,
350
- "new_name": expected_name,
351
- "status": status,
352
- "action": "renamed",
353
- }
354
-
355
-
356
- def _cmd_rename(args) -> int:
357
- """Handle `gaia plans rename <name>` and `gaia plans rename --all`."""
358
- project_root = _find_project_root(Path.cwd())
359
- if project_root is None:
360
- msg = "gaia plans: could not find project root (.claude/ directory)"
361
- if getattr(args, "json", False):
362
- print(json.dumps({"error": msg}))
363
- else:
364
- print(f"Error: {msg}", file=sys.stderr)
365
- return 1
366
-
367
- briefs_dir = _get_briefs_dir(project_root)
368
-
369
- rename_all = getattr(args, "all", False)
370
-
371
- if rename_all:
372
- if not briefs_dir.is_dir():
373
- result: dict = {"results": [], "error": None}
374
- if getattr(args, "json", False):
375
- print(json.dumps(result, indent=2))
376
- else:
377
- print("No briefs directory found.")
378
- return 0
379
-
380
- results = []
381
- errors = []
382
- for entry in sorted(briefs_dir.iterdir()):
383
- if not entry.is_dir():
384
- continue
385
- if not (entry / "brief.md").exists():
386
- continue
387
- try:
388
- res = _rename_one(briefs_dir, entry.name)
389
- results.append(res)
390
- except ValueError as exc:
391
- errors.append({"name": entry.name, "error": str(exc)})
392
-
393
- if getattr(args, "json", False):
394
- print(json.dumps({"results": results, "errors": errors}, indent=2))
395
- else:
396
- for res in results:
397
- action_label = "renamed" if res["action"] == "renamed" else "ok"
398
- print(f"[{action_label}] {res['old_name']} -> {res['new_name']} (status: {res['status']})")
399
- for err in errors:
400
- print(f"[error] {err['name']}: {err['error']}", file=sys.stderr)
401
- return 0
402
-
403
- # Single brief rename.
404
- brief_name: str = getattr(args, "name", None)
405
- if not brief_name:
406
- print("Error: provide a brief name or use --all", file=sys.stderr)
407
- return 1
408
-
409
- try:
410
- result_single = _rename_one(briefs_dir, brief_name)
411
- except ValueError as exc:
412
- msg = str(exc)
413
- if getattr(args, "json", False):
414
- print(json.dumps({"error": msg}))
415
- else:
416
- print(f"Error: {msg}", file=sys.stderr)
417
- return 1
418
-
419
- if getattr(args, "json", False):
420
- print(json.dumps(result_single, indent=2))
421
- else:
422
- if result_single["action"] == "renamed":
423
- print(
424
- f"Renamed: {result_single['old_name']} -> {result_single['new_name']} "
425
- f"(status: {result_single['status']})"
426
- )
427
- else:
428
- print(
429
- f"Already correct: {result_single['new_name']} "
430
- f"(status: {result_single['status']})"
431
- )
432
- return 0
433
-
434
-
435
- # ---------------------------------------------------------------------------
436
- # Plugin registration
437
- # ---------------------------------------------------------------------------
438
-
439
- def register(subparsers) -> None:
440
- """Register the `plans` subcommand with the root parser."""
441
- plans_parser = subparsers.add_parser(
442
- "plans",
443
- help="List and display project briefs/plans",
444
- )
445
- plans_subparsers = plans_parser.add_subparsers(dest="plans_cmd", metavar="<action>")
446
-
447
- # gaia plans list
448
- list_parser = plans_subparsers.add_parser("list", help="List all briefs")
449
- list_parser.add_argument(
450
- "--json",
451
- action="store_true",
452
- default=False,
453
- help="Output as JSON",
454
- )
455
-
456
- # gaia plans show <name>
457
- show_parser = plans_subparsers.add_parser(
458
- "show", help="Show brief content (prefix-tolerant)"
459
- )
460
- show_parser.add_argument(
461
- "name", help="Brief name with or without prefix (e.g. evidence-runner or open_evidence-runner)"
462
- )
463
- show_parser.add_argument(
464
- "--json",
465
- action="store_true",
466
- default=False,
467
- help="Output as JSON",
468
- )
469
-
470
- # gaia plans rename <name> [--all]
471
- rename_parser = plans_subparsers.add_parser(
472
- "rename", help="Sync directory prefix to frontmatter status"
473
- )
474
- rename_parser.add_argument(
475
- "name",
476
- nargs="?",
477
- default=None,
478
- help="Brief name with or without prefix. Omit when using --all.",
479
- )
480
- rename_parser.add_argument(
481
- "--all",
482
- action="store_true",
483
- default=False,
484
- help="Sync all briefs in the briefs directory",
485
- )
486
- rename_parser.add_argument(
487
- "--json",
488
- action="store_true",
489
- default=False,
490
- help="Output as JSON",
491
- )
492
-
493
-
494
- def cmd_plans(args) -> int:
495
- """Dispatch handler for `gaia plans`."""
496
- plans_cmd = getattr(args, "plans_cmd", None)
497
- if plans_cmd == "list":
498
- return _cmd_list(args)
499
- if plans_cmd == "show":
500
- return _cmd_show(args)
501
- if plans_cmd == "rename":
502
- return _cmd_rename(args)
503
-
504
- # No sub-action: print help for the plans subcommand
505
- import argparse
506
-
507
- # Re-parse with just `plans --help` to show the sub-help
508
- tmp_parser = argparse.ArgumentParser(prog="gaia plans")
509
- tmp_sub = tmp_parser.add_subparsers(dest="plans_cmd", metavar="<action>")
510
- tmp_sub.add_parser("list", help="List all briefs")
511
- show_p = tmp_sub.add_parser("show", help="Show brief content")
512
- show_p.add_argument("name")
513
- rename_p = tmp_sub.add_parser("rename", help="Sync directory prefix to frontmatter status")
514
- rename_p.add_argument("name", nargs="?")
515
- rename_p.add_argument("--all", action="store_true")
516
- tmp_parser.print_help()
517
- return 0
@@ -1,159 +0,0 @@
1
- """
2
- Deep merge utility for project-context.json updates.
3
-
4
- Merges two dicts recursively following the gaia-ops merge decision tree:
5
- 1. Key missing in current -> ADD
6
- 2. Both values are dicts -> RECURSE (deep merge)
7
- 3. Both values are lists -> UNION (primitives: sorted set union;
8
- dicts with "name": merge by name;
9
- other dicts: concatenate + deduplicate)
10
- 4. Both values are scalars -> OVERWRITE (new replaces old)
11
- 5. Type mismatch -> OVERWRITE with warning
12
-
13
- No-Delete Policy: keys in current but NOT in update are always preserved.
14
- """
15
-
16
- import copy
17
- import json
18
- import logging
19
-
20
- logger = logging.getLogger(__name__)
21
-
22
-
23
- def deep_merge(current: dict, update: dict) -> tuple[dict, dict]:
24
- """Merge *update* into *current* returning ``(merged, diff)``.
25
-
26
- Parameters
27
- ----------
28
- current:
29
- The existing data (will NOT be mutated).
30
- update:
31
- New data to merge on top of *current*.
32
-
33
- Returns
34
- -------
35
- tuple[dict, dict]
36
- ``merged`` – the result of the merge.
37
- ``diff`` – audit trail recording changes (``{key: {old, new}}``).
38
- """
39
- merged = copy.deepcopy(current)
40
- diff: dict = {}
41
-
42
- for key, new_value in update.items():
43
- if key not in merged:
44
- # Rule 1: ADD missing key
45
- merged[key] = copy.deepcopy(new_value)
46
- continue
47
-
48
- old_value = merged[key]
49
-
50
- # Rule 2: Both dicts -> recurse
51
- if isinstance(old_value, dict) and isinstance(new_value, dict):
52
- sub_merged, sub_diff = deep_merge(old_value, new_value)
53
- merged[key] = sub_merged
54
- if sub_diff:
55
- diff[key] = sub_diff
56
- continue
57
-
58
- # Rule 3: Both lists -> union strategy
59
- if isinstance(old_value, list) and isinstance(new_value, list):
60
- merged_list = _merge_lists(old_value, new_value)
61
- if merged_list != old_value:
62
- diff[key] = {"old": old_value, "new": merged_list}
63
- merged[key] = merged_list
64
- continue
65
-
66
- # Rule 5: Type mismatch -> overwrite with warning
67
- if type(old_value) is not type(new_value):
68
- logger.warning(
69
- "Type mismatch for key '%s': %s -> %s. New value wins.",
70
- key,
71
- type(old_value).__name__,
72
- type(new_value).__name__,
73
- )
74
- diff[key] = {"old": old_value, "new": new_value}
75
- merged[key] = copy.deepcopy(new_value)
76
- continue
77
-
78
- # Rule 4: Both scalars -> overwrite
79
- if old_value != new_value:
80
- diff[key] = {"old": old_value, "new": new_value}
81
- merged[key] = copy.deepcopy(new_value)
82
-
83
- return merged, diff
84
-
85
-
86
- # ---------------------------------------------------------------------------
87
- # List merge helpers
88
- # ---------------------------------------------------------------------------
89
-
90
- def _merge_lists(current: list, update: list) -> list:
91
- """Merge two lists following the union strategy.
92
-
93
- a) All items are primitives (str, int, float, bool) -> sorted set union.
94
- b) Items are dicts with a ``"name"`` key -> merge by name, preserve missing.
95
- c) Otherwise -> concatenate, deduplicate by JSON equality.
96
- """
97
- if _all_primitives(current) and _all_primitives(update):
98
- return sorted(set(current) | set(update))
99
-
100
- if _all_dicts_with_name(current) and _all_dicts_with_name(update):
101
- return _merge_named_dicts(current, update)
102
-
103
- # Fallback: concatenate + deduplicate by JSON equality
104
- return _concat_deduplicate(current, update)
105
-
106
-
107
- def _all_primitives(items: list) -> bool:
108
- """Return True if every item is a primitive (str, int, float, bool)."""
109
- return all(isinstance(i, (str, int, float, bool)) for i in items)
110
-
111
-
112
- def _all_dicts_with_name(items: list) -> bool:
113
- """Return True if every item is a dict containing a ``"name"`` key."""
114
- return bool(items) and all(
115
- isinstance(i, dict) and "name" in i for i in items
116
- )
117
-
118
-
119
- def _merge_named_dicts(current: list[dict], update: list[dict]) -> list[dict]:
120
- """Merge lists of dicts by their ``"name"`` field.
121
-
122
- - Matching names: deep-merge the dict fields.
123
- - Names only in current: preserved (no-delete).
124
- - Names only in update: appended.
125
- """
126
- result_by_name: dict[str, dict] = {}
127
- order: list[str] = []
128
-
129
- # Seed with current entries (preserves order + no-delete)
130
- for item in current:
131
- name = item["name"]
132
- result_by_name[name] = copy.deepcopy(item)
133
- order.append(name)
134
-
135
- # Merge / add from update
136
- for item in update:
137
- name = item["name"]
138
- if name in result_by_name:
139
- merged_item, _ = deep_merge(result_by_name[name], item)
140
- result_by_name[name] = merged_item
141
- else:
142
- result_by_name[name] = copy.deepcopy(item)
143
- order.append(name)
144
-
145
- return [result_by_name[n] for n in order]
146
-
147
-
148
- def _concat_deduplicate(current: list, update: list) -> list:
149
- """Concatenate two lists, deduplicating by JSON equality."""
150
- seen: list[str] = []
151
- result: list = []
152
-
153
- for item in current + update:
154
- serialized = json.dumps(item, sort_keys=True)
155
- if serialized not in seen:
156
- seen.append(serialized)
157
- result.append(copy.deepcopy(item))
158
-
159
- return result