@jaguilar87/gaia 5.0.8 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +341 -238
- package/bin/cli/brief.py +13 -0
- package/bin/cli/doctor.py +1 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-ops/hooks/post_compact.py +1 -0
- package/dist/gaia-ops/hooks/pre_compact.py +1 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
- package/dist/gaia-ops/tools/migration/README.md +10 -12
- package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/store.py +87 -9
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +19 -85
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/context/context_injector.py +23 -7
- package/hooks/modules/events/event_writer.py +63 -96
- package/hooks/modules/security/__init__.py +0 -2
- package/hooks/modules/security/approval_cleanup.py +238 -69
- package/hooks/modules/security/approval_grants.py +506 -1103
- package/hooks/modules/security/mutative_verbs.py +24 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/post_compact.py +1 -0
- package/hooks/pre_compact.py +1 -0
- package/hooks/user_prompt_submit.py +20 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/bootstrap_database.sh +66 -17
- package/scripts/migrations/README.md +26 -14
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v18_to_v19.sql +36 -0
- package/scripts/migrations/v19_to_v20.sql +20 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -7
- package/skills/agent-approval-protocol/reference.md +11 -6
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +10 -5
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +20 -6
- package/skills/subagent-request-approval/reference.md +23 -15
- package/tools/migration/README.md +10 -12
- package/tools/scan/orchestrator.py +194 -10
- package/tools/scan/tests/test_integration.py +1 -2
- package/bin/cli/plans.py +0 -517
- package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
- package/dist/gaia-ops/tools/scan/merge.py +0 -213
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
- package/tools/context/deep_merge.py +0 -159
- package/tools/migration/migrate_04_harness_events.py +0 -132
- package/tools/migration/migrate_04_harness_events.sh +0 -23
- package/tools/scan/merge.py +0 -213
- 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
|