@topy-ai/maggie 0.7.46 → 0.7.48
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/README-zh-TW.md +12 -4
- package/README.md +55 -11
- package/bin/maggie.js +26 -4
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/memory-hook.md +18 -5
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-deployment/SKILL.md +59 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +41 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +295 -0
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +107 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/memory-hook.md +18 -5
- package/references/provider-adapter-contract.md +33 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Static design checks that catch defects before a browser run.
|
|
3
|
+
|
|
4
|
+
``pressable-check`` flags a pressable or button rendered inside another
|
|
5
|
+
pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
|
|
6
|
+
``Touchable*`` family as ``<button>``-like elements, so a chip with a nested
|
|
7
|
+
remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
|
|
8
|
+
land on the wrong control.
|
|
9
|
+
|
|
10
|
+
``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities.
|
|
11
|
+
Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
|
|
12
|
+
wins over every layer regardless of specificity, so an unlayered
|
|
13
|
+
``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
|
|
14
|
+
beats ``hidden`` and ``group-open:inline``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SKIP_DIRS = {"node_modules", "dist", "build", ".git", ".expo", ".next", ".astro", ".output", "coverage", ".maggie", ".turbo", ".vercel", ".wrangler"}
|
|
26
|
+
PRESSABLES = ("Pressable", "TouchableOpacity", "TouchableHighlight", "TouchableWithoutFeedback", "TouchableNativeFeedback", "button", "a")
|
|
27
|
+
ALLOW_MARKER = "maggie-allow-nested-pressable"
|
|
28
|
+
JSX_KEYWORDS = {"return", "yield", "await", "default", "case", "in", "of", "else", "do"}
|
|
29
|
+
|
|
30
|
+
TAILWIND_V4_IMPORT = re.compile(r"""@import\s+(?:url\()?["']tailwindcss["']""")
|
|
31
|
+
LAYERED_IMPORT = re.compile(r"""@import\s+(?:url\()?["']([^"']+)["']\)?\s+layer\(""")
|
|
32
|
+
# At-rules whose bodies are Tailwind theme/utility definitions or descriptors, not competing rules.
|
|
33
|
+
SKIPPED_AT_RULES = {"theme", "utility", "variant", "custom-variant", "keyframes", "font-face", "property", "plugin", "source", "page", "counter-style", "font-feature-values", "starting-style"}
|
|
34
|
+
NESTING_AT_RULES = {"media", "supports", "container", "scope", "document"}
|
|
35
|
+
COMPETING_PROPERTIES = re.compile(
|
|
36
|
+
r"^(display|visibility|font-family|font-size|font-weight|font-style|line-height|letter-spacing|text-transform|"
|
|
37
|
+
r"text-align|color|background(-color)?|margin(-[a-z]+)?|padding(-[a-z]+)?|width|height|min-width|max-width|"
|
|
38
|
+
r"min-height|max-height|gap|border(-[a-z]+)*|position|inset|top|right|bottom|left|z-index|opacity)$"
|
|
39
|
+
)
|
|
40
|
+
ICON_CLASS = re.compile(r"\.(ph|ph-[\w-]+|fa|fa[srb]|fa-[\w-]+|bi|bi-[\w-]+|material-icons[\w-]*|material-symbols[\w-]*|icon|icon-[\w-]+|lucide|lucide-[\w-]+|iconify)(?![\w-])")
|
|
41
|
+
ICON_ATTRIBUTE = re.compile(r"""\[class[\^*|]?=\s*["']?(ph|fa|bi|icon)""")
|
|
42
|
+
TYPE_SELECTOR = re.compile(r"^(\*|[a-zA-Z][a-zA-Z0-9-]*)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def iter_files(roots: list[Path], suffixes: tuple[str, ...]) -> list[Path]:
|
|
46
|
+
found: list[Path] = []
|
|
47
|
+
for root in roots:
|
|
48
|
+
if root.is_file():
|
|
49
|
+
if root.suffix in suffixes:
|
|
50
|
+
found.append(root)
|
|
51
|
+
continue
|
|
52
|
+
for path in sorted(root.rglob("*")):
|
|
53
|
+
if path.suffix in suffixes and path.is_file() and not any(part in SKIP_DIRS for part in path.relative_to(root).parts):
|
|
54
|
+
found.append(path)
|
|
55
|
+
return found
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _display(path: Path, project: Path) -> str:
|
|
59
|
+
try:
|
|
60
|
+
return path.resolve().relative_to(project.resolve()).as_posix()
|
|
61
|
+
except ValueError:
|
|
62
|
+
return path.name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------- pressables
|
|
66
|
+
|
|
67
|
+
def _strip_jsx_comments(source: str) -> str:
|
|
68
|
+
"""Blank comments while keeping offsets, so line numbers stay correct."""
|
|
69
|
+
def blank(match: re.Match[str]) -> str:
|
|
70
|
+
return re.sub(r"[^\n]", " ", match.group(0))
|
|
71
|
+
source = re.sub(r"/\*.*?\*/", blank, source, flags=re.S)
|
|
72
|
+
return re.sub(r"(?m)^[ \t]*//[^\n]*", blank, source)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _scan_tag(source: str, start: int) -> tuple[int, bool, str] | None:
|
|
76
|
+
"""Return (end offset, self-closing, attribute text) for a tag at ``start``."""
|
|
77
|
+
depth = 0
|
|
78
|
+
quote = ""
|
|
79
|
+
index = start
|
|
80
|
+
while index < len(source):
|
|
81
|
+
char = source[index]
|
|
82
|
+
if quote:
|
|
83
|
+
if char == quote and source[index - 1] != "\\":
|
|
84
|
+
quote = ""
|
|
85
|
+
elif char in "\"'`":
|
|
86
|
+
quote = char
|
|
87
|
+
elif char == "{":
|
|
88
|
+
depth += 1
|
|
89
|
+
elif char == "}":
|
|
90
|
+
depth -= 1
|
|
91
|
+
elif char == ">" and depth == 0:
|
|
92
|
+
return index + 1, source[index - 1] == "/", source[start:index]
|
|
93
|
+
index += 1
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pressable_findings(path: Path, names: set[str], display: str) -> list[dict[str, object]]:
|
|
98
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
99
|
+
source = _strip_jsx_comments(raw)
|
|
100
|
+
raw_lines = raw.splitlines()
|
|
101
|
+
findings: list[dict[str, object]] = []
|
|
102
|
+
stack: list[tuple[str, bool, int]] = [] # (tag, is_pressable_boundary, line)
|
|
103
|
+
for match in re.finditer(r"<(/?)([A-Za-z][\w.]*)", source):
|
|
104
|
+
before = source[:match.start()].rstrip()
|
|
105
|
+
closing = match.group(1) == "/"
|
|
106
|
+
name = match.group(2)
|
|
107
|
+
# A preceding identifier character means a TypeScript generic, not JSX.
|
|
108
|
+
if not closing and before and (before[-1].isalnum() or before[-1] in "_$."):
|
|
109
|
+
word = re.search(r"[\w$]+$", before)
|
|
110
|
+
if not word or word.group(0) not in JSX_KEYWORDS:
|
|
111
|
+
continue
|
|
112
|
+
line = source.count("\n", 0, match.start()) + 1
|
|
113
|
+
if closing:
|
|
114
|
+
for position in range(len(stack) - 1, -1, -1):
|
|
115
|
+
if stack[position][0] == name:
|
|
116
|
+
del stack[position:]
|
|
117
|
+
break
|
|
118
|
+
continue
|
|
119
|
+
scanned = _scan_tag(source, match.end())
|
|
120
|
+
if scanned is None:
|
|
121
|
+
continue
|
|
122
|
+
_, self_closing, attributes = scanned
|
|
123
|
+
is_pressable = name in names
|
|
124
|
+
# `asChild` delegates rendering to the child (expo-router Link), so it is not a second control.
|
|
125
|
+
boundary = is_pressable and not re.search(r"\basChild\b", attributes)
|
|
126
|
+
if is_pressable:
|
|
127
|
+
outer = next((entry for entry in reversed(stack) if entry[1]), None)
|
|
128
|
+
allowed = ALLOW_MARKER in (raw_lines[line - 1] if line - 1 < len(raw_lines) else "")
|
|
129
|
+
if outer and not allowed:
|
|
130
|
+
findings.append({
|
|
131
|
+
"file": display, "line": line, "inner": name, "outer": outer[0], "outerLine": outer[2],
|
|
132
|
+
"message": f"<{name}> is nested inside <{outer[0]}> (line {outer[2]}); render them as sibling pressables",
|
|
133
|
+
})
|
|
134
|
+
if not self_closing:
|
|
135
|
+
stack.append((name, boundary, line))
|
|
136
|
+
return findings
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def pressable_check(project: Path, paths: list[Path], extra: list[str]) -> dict[str, object]:
|
|
140
|
+
names = set(PRESSABLES) | set(extra)
|
|
141
|
+
files = iter_files(paths or [project], (".tsx", ".jsx"))
|
|
142
|
+
findings: list[dict[str, object]] = []
|
|
143
|
+
for path in files:
|
|
144
|
+
findings.extend(pressable_findings(path, names, _display(path, project)))
|
|
145
|
+
return {"schemaVersion": "maggie-design-pressable-check.v1", "passed": not findings, "checkedFiles": len(files), "pressables": sorted(names), "findings": findings}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------- CSS layers
|
|
149
|
+
|
|
150
|
+
def _strip_css_comments(source: str) -> str:
|
|
151
|
+
return re.sub(r"/\*.*?\*/", lambda match: re.sub(r"[^\n]", " ", match.group(0)), source, flags=re.S)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _block_end(source: str, open_index: int) -> int:
|
|
155
|
+
depth = 0
|
|
156
|
+
quote = ""
|
|
157
|
+
for index in range(open_index, len(source)):
|
|
158
|
+
char = source[index]
|
|
159
|
+
if quote:
|
|
160
|
+
if char == quote and source[index - 1] != "\\":
|
|
161
|
+
quote = ""
|
|
162
|
+
elif char in "\"'":
|
|
163
|
+
quote = char
|
|
164
|
+
elif char == "{":
|
|
165
|
+
depth += 1
|
|
166
|
+
elif char == "}":
|
|
167
|
+
depth -= 1
|
|
168
|
+
if depth == 0:
|
|
169
|
+
return index
|
|
170
|
+
return len(source) - 1
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _selector_kind(selector: str) -> str | None:
|
|
174
|
+
if ICON_CLASS.search(selector) or ICON_ATTRIBUTE.search(selector):
|
|
175
|
+
return "icon"
|
|
176
|
+
kind = None
|
|
177
|
+
for part in selector.split(","):
|
|
178
|
+
compounds = [item for item in re.split(r"\s*[>+~]\s*|\s+", part.strip()) if item]
|
|
179
|
+
if not compounds:
|
|
180
|
+
continue
|
|
181
|
+
last = re.sub(r"::?[\w-]+(\([^)]*\))?", "", compounds[-1])
|
|
182
|
+
match = TYPE_SELECTOR.match(last)
|
|
183
|
+
if not match:
|
|
184
|
+
continue
|
|
185
|
+
if match.group(1).lower() in {"html", "body"}:
|
|
186
|
+
kind = kind or "root"
|
|
187
|
+
else:
|
|
188
|
+
return "element"
|
|
189
|
+
return kind
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _rule_findings(source: str, start: int, end: int, layered: bool, display: str, findings: list[dict[str, object]]) -> None:
|
|
193
|
+
index = start
|
|
194
|
+
while index < end:
|
|
195
|
+
brace = source.find("{", index, end)
|
|
196
|
+
semicolon = source.find(";", index, end)
|
|
197
|
+
if brace == -1:
|
|
198
|
+
return
|
|
199
|
+
if semicolon != -1 and semicolon < brace:
|
|
200
|
+
index = semicolon + 1 # statement at-rule such as @import or @layer a, b;
|
|
201
|
+
continue
|
|
202
|
+
prelude = source[index:brace].strip()
|
|
203
|
+
close = _block_end(source, brace)
|
|
204
|
+
if prelude.startswith("@"):
|
|
205
|
+
at_name = re.match(r"@([\w-]+)", prelude)
|
|
206
|
+
name = at_name.group(1).lower() if at_name else ""
|
|
207
|
+
if name == "layer":
|
|
208
|
+
_rule_findings(source, brace + 1, close, True, display, findings)
|
|
209
|
+
elif name in NESTING_AT_RULES:
|
|
210
|
+
_rule_findings(source, brace + 1, close, layered, display, findings)
|
|
211
|
+
elif not layered and prelude:
|
|
212
|
+
kind = _selector_kind(prelude)
|
|
213
|
+
body = source[brace + 1:close]
|
|
214
|
+
properties = sorted({item.split(":", 1)[0].strip().lower() for item in body.split(";") if ":" in item and "{" not in item.split(":", 1)[0]})
|
|
215
|
+
competing = [item for item in properties if COMPETING_PROPERTIES.match(item)]
|
|
216
|
+
line = source.count("\n", 0, index + (len(source[index:brace]) - len(source[index:brace].lstrip()))) + 1
|
|
217
|
+
if kind == "root" and competing:
|
|
218
|
+
findings.append({
|
|
219
|
+
"file": display, "line": line, "selector": " ".join(prelude.split()), "kind": kind, "properties": competing, "severity": "warning",
|
|
220
|
+
"message": "unlayered html/body rule overrides utilities placed on that element; prefer @layer base",
|
|
221
|
+
})
|
|
222
|
+
elif kind and competing:
|
|
223
|
+
findings.append({
|
|
224
|
+
"file": display, "line": line, "selector": " ".join(prelude.split()), "kind": kind, "properties": competing, "severity": "error",
|
|
225
|
+
"message": f"unlayered {kind} rule sets {', '.join(competing)} and overrides Tailwind v4 utilities; move it into @layer {'components' if kind == 'icon' else 'base'}",
|
|
226
|
+
})
|
|
227
|
+
elif not kind and "display" in competing:
|
|
228
|
+
findings.append({
|
|
229
|
+
"file": display, "line": line, "selector": " ".join(prelude.split()), "kind": "class", "properties": ["display"], "severity": "warning",
|
|
230
|
+
"message": "unlayered rule sets display, so utilities such as hidden cannot toggle this element; prefer @layer components",
|
|
231
|
+
})
|
|
232
|
+
index = close + 1
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
|
|
236
|
+
files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
|
|
237
|
+
sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
|
|
238
|
+
tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
|
|
239
|
+
if not tailwind_v4:
|
|
240
|
+
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": []}
|
|
241
|
+
layered_files: set[Path] = set()
|
|
242
|
+
for path, source in sources.items():
|
|
243
|
+
for target in LAYERED_IMPORT.findall(source):
|
|
244
|
+
layered_files.add((path.parent / target).resolve())
|
|
245
|
+
findings: list[dict[str, object]] = []
|
|
246
|
+
for path, source in sources.items():
|
|
247
|
+
_rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
|
|
248
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
249
|
+
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main(argv: list[str] | None = None) -> int:
|
|
253
|
+
parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
|
|
254
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
255
|
+
pressable = sub.add_parser("pressable-check", help="flag pressables or buttons nested inside another pressable (.tsx/.jsx)")
|
|
256
|
+
pressable.add_argument("--project", type=Path, default=Path.cwd())
|
|
257
|
+
pressable.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
258
|
+
pressable.add_argument("--pressable", action="append", default=[], help="extra component name that renders a pressable, for example Chip")
|
|
259
|
+
layers = sub.add_parser("layer-check", help="flag unlayered CSS that overrides Tailwind v4 utilities")
|
|
260
|
+
layers.add_argument("--project", type=Path, default=Path.cwd())
|
|
261
|
+
layers.add_argument("--css", type=Path, action="append", default=[], help="CSS file or directory to scan; defaults to the project")
|
|
262
|
+
layers.add_argument("--tailwind-v4", action="store_true", help="check even when no @import \"tailwindcss\" entry is found")
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
try:
|
|
265
|
+
if args.command == "pressable-check":
|
|
266
|
+
report = pressable_check(args.project, args.path, args.pressable)
|
|
267
|
+
else:
|
|
268
|
+
report = layer_check(args.project, args.css, args.tailwind_v4)
|
|
269
|
+
except OSError as error:
|
|
270
|
+
print(f"BLOCKED: maggie-design {args.command}: {error}", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
273
|
+
return 0 if report["passed"] else 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
raise SystemExit(main())
|
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import platform
|
|
11
11
|
import re
|
|
12
12
|
import secrets
|
|
13
|
+
import subprocess
|
|
13
14
|
import sys
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
from pathlib import Path
|
|
@@ -41,6 +42,14 @@ EVIDENCE_KINDS = {"test", "report", "screenshot", "command", "deployment", "issu
|
|
|
41
42
|
RELATION_KINDS = {"related", "same-run", "same-fingerprint", "duplicates", "validates", "remediates", "caused-by"}
|
|
42
43
|
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
43
44
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
45
|
+
FEEDBACK_TYPES = ("bug", "feature", "quality", "improvement", "general")
|
|
46
|
+
# The hosted endpoint accepts these types. Local-only types are submitted as a
|
|
47
|
+
# hosted type with the original kept in `subtype`, so no endpoint change is needed.
|
|
48
|
+
HOSTED_TYPES = {"bug", "feature", "quality", "general"}
|
|
49
|
+
HOSTED_TYPE_FALLBACK = {"improvement": "feature"}
|
|
50
|
+
REVIEW_MARKER = "REVIEW:"
|
|
51
|
+
FIX_SUBJECT_RE = re.compile(r"^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:\s*|^(?:fix|fixes|fixed)\b\s*", re.IGNORECASE)
|
|
52
|
+
FEATURE_SUBJECT_RE = re.compile(r"^(?:feat|perf|refactor)(?:\([^)]*\))?!?:\s*", re.IGNORECASE)
|
|
44
53
|
|
|
45
54
|
|
|
46
55
|
def now() -> str:
|
|
@@ -230,6 +239,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
230
239
|
# Persist one canonical representation so batch review and the hosted
|
|
231
240
|
# API do not have to guess which indexing convention was used.
|
|
232
241
|
batch_index -= batch_base
|
|
242
|
+
if args.type not in FEEDBACK_TYPES:
|
|
243
|
+
raise ValueError(f"type must be one of: {', '.join(FEEDBACK_TYPES)}")
|
|
233
244
|
priority = safe_text(getattr(args, "priority", "") or "normal").lower()
|
|
234
245
|
if priority not in {"low", "normal", "high", "critical"}:
|
|
235
246
|
raise ValueError("priority must be low, normal, high, or critical")
|
|
@@ -321,6 +332,16 @@ def preview(args: argparse.Namespace) -> int:
|
|
|
321
332
|
return 0
|
|
322
333
|
|
|
323
334
|
|
|
335
|
+
def hosted_payload(data: dict) -> dict:
|
|
336
|
+
"""Map local-only feedback types onto the hosted contract without losing them."""
|
|
337
|
+
payload = dict(data)
|
|
338
|
+
local_type = payload.get("type")
|
|
339
|
+
if local_type not in HOSTED_TYPES:
|
|
340
|
+
payload["type"] = HOSTED_TYPE_FALLBACK.get(local_type, "general")
|
|
341
|
+
payload["subtype"] = local_type
|
|
342
|
+
return payload
|
|
343
|
+
|
|
344
|
+
|
|
324
345
|
def submit(args: argparse.Namespace) -> int:
|
|
325
346
|
if not args.confirm:
|
|
326
347
|
raise ValueError("submit requires --confirm; feedback is never sent implicitly")
|
|
@@ -330,7 +351,7 @@ def submit(args: argparse.Namespace) -> int:
|
|
|
330
351
|
parsed = urlparse(endpoint)
|
|
331
352
|
if parsed.scheme != "https" or not parsed.netloc:
|
|
332
353
|
raise ValueError("endpoint must be an absolute HTTPS URL")
|
|
333
|
-
payload =
|
|
354
|
+
payload = hosted_payload(data)
|
|
334
355
|
payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
|
|
335
356
|
request = Request(endpoint, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
|
|
336
357
|
try:
|
|
@@ -352,7 +373,7 @@ def list_feedback(args: argparse.Namespace) -> int:
|
|
|
352
373
|
for path in sorted(directory.glob("*.json")):
|
|
353
374
|
try:
|
|
354
375
|
data = read_feedback(path)
|
|
355
|
-
records.append({"feedbackId": data.get("feedbackId"), "skill": data.get("skill"), "status": data.get("submission", {}).get("status", "draft"), "path": str(path)})
|
|
376
|
+
records.append({"feedbackId": data.get("feedbackId"), "skill": data.get("skill"), "type": data.get("type"), "status": data.get("submission", {}).get("status", "draft"), "path": str(path)})
|
|
356
377
|
except (OSError, ValueError):
|
|
357
378
|
continue
|
|
358
379
|
print(json.dumps(records, indent=2, ensure_ascii=False))
|
|
@@ -383,6 +404,9 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
383
404
|
for index, item in enumerate(items):
|
|
384
405
|
if not isinstance(item, dict):
|
|
385
406
|
raise ValueError(f"items[{index}] must be an object")
|
|
407
|
+
unedited = [field for field in ("summary", "expected", "actual", "resolution", "validation") if REVIEW_MARKER in str(item.get(field) or "")]
|
|
408
|
+
if unedited:
|
|
409
|
+
raise ValueError(f"items[{index}] still has {REVIEW_MARKER} placeholders in: {', '.join(unedited)}; edit or remove the item before creating drafts")
|
|
386
410
|
values = {
|
|
387
411
|
"project": args.project,
|
|
388
412
|
"run_report": None,
|
|
@@ -422,6 +446,75 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
422
446
|
return 0
|
|
423
447
|
|
|
424
448
|
|
|
449
|
+
def session_summary(args: argparse.Namespace) -> int:
|
|
450
|
+
"""Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
|
|
451
|
+
repo = Path(args.repo).expanduser().resolve()
|
|
452
|
+
since = str(args.since).strip()
|
|
453
|
+
if not since or since.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", since):
|
|
454
|
+
raise ValueError("--since must be a git revision such as a tag, branch, or commit")
|
|
455
|
+
batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
|
456
|
+
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
457
|
+
raise ValueError("batch ID is invalid")
|
|
458
|
+
completed = subprocess.run(
|
|
459
|
+
["git", "-C", str(repo), "log", "--no-merges", "--format=%h%x1f%s", f"{since}..HEAD"],
|
|
460
|
+
capture_output=True, text=True, check=False,
|
|
461
|
+
)
|
|
462
|
+
if completed.returncode:
|
|
463
|
+
raise ValueError("git log failed; check --repo and --since")
|
|
464
|
+
items = []
|
|
465
|
+
skipped = 0
|
|
466
|
+
for line in completed.stdout.splitlines():
|
|
467
|
+
sha, _, subject = line.partition("\x1f")
|
|
468
|
+
subject = safe_text(subject)[:200]
|
|
469
|
+
if not sha or not subject:
|
|
470
|
+
continue
|
|
471
|
+
if FIX_SUBJECT_RE.match(subject):
|
|
472
|
+
kind, text = "bug", FIX_SUBJECT_RE.sub("", subject, count=1)
|
|
473
|
+
elif FEATURE_SUBJECT_RE.match(subject):
|
|
474
|
+
kind, text = "improvement", FEATURE_SUBJECT_RE.sub("", subject, count=1)
|
|
475
|
+
else:
|
|
476
|
+
skipped += 1
|
|
477
|
+
continue
|
|
478
|
+
text = text[:1].upper() + text[1:] if text else subject
|
|
479
|
+
items.append({
|
|
480
|
+
"type": kind,
|
|
481
|
+
"summary": text,
|
|
482
|
+
"expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
|
|
483
|
+
"actual": f"{REVIEW_MARKER} what happened before commit {sha}",
|
|
484
|
+
"fixed": True,
|
|
485
|
+
"resolution": text,
|
|
486
|
+
"validation": f"{REVIEW_MARKER} the test or check that verified commit {sha}",
|
|
487
|
+
"evidence": [f"commit:{sha}"],
|
|
488
|
+
})
|
|
489
|
+
if len(items) >= 49:
|
|
490
|
+
break
|
|
491
|
+
items.append({
|
|
492
|
+
"type": "feature",
|
|
493
|
+
"summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
|
|
494
|
+
"expected": "",
|
|
495
|
+
"actual": "",
|
|
496
|
+
})
|
|
497
|
+
manifest = {
|
|
498
|
+
"batchId": batch_id,
|
|
499
|
+
"skill": safe_text(args.skill),
|
|
500
|
+
"runId": safe_text(args.run_id),
|
|
501
|
+
"phase": safe_text(args.phase) or "session-summary",
|
|
502
|
+
"reviewRequired": True,
|
|
503
|
+
"note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
|
|
504
|
+
"items": items,
|
|
505
|
+
}
|
|
506
|
+
project = Path(args.project).resolve()
|
|
507
|
+
output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-draft.json"
|
|
508
|
+
if not output.is_absolute():
|
|
509
|
+
output = project / output
|
|
510
|
+
if output.exists():
|
|
511
|
+
raise ValueError("output already exists; choose another --output or --batch-id")
|
|
512
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
513
|
+
output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
514
|
+
print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "output": str(output), "submitted": False}, ensure_ascii=False))
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
|
|
425
518
|
def batch_review(args: argparse.Namespace) -> int:
|
|
426
519
|
"""Aggregate one bounded batch without exposing raw payloads or paths."""
|
|
427
520
|
project = Path(args.project).resolve()
|
|
@@ -534,7 +627,7 @@ def main() -> int:
|
|
|
534
627
|
collect_parser.add_argument("--skill", default="")
|
|
535
628
|
collect_parser.add_argument("--run-id", default="")
|
|
536
629
|
collect_parser.add_argument("--run-report")
|
|
537
|
-
collect_parser.add_argument("--type", choices=
|
|
630
|
+
collect_parser.add_argument("--type", choices=FEEDBACK_TYPES, default="bug", help="improvement: a proposal to improve a skill or CLI (submitted as feature with subtype)")
|
|
538
631
|
collect_parser.add_argument("--phase", default="")
|
|
539
632
|
collect_parser.add_argument("--summary", required=True)
|
|
540
633
|
collect_parser.add_argument("--expected", default="")
|
|
@@ -563,6 +656,15 @@ def main() -> int:
|
|
|
563
656
|
batch_parser = sub.add_parser("batch")
|
|
564
657
|
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
565
658
|
batch_parser.add_argument("--batch-file", required=True)
|
|
659
|
+
summary_parser = sub.add_parser("session-summary", help="draft batch items from git commit subjects for review")
|
|
660
|
+
summary_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
661
|
+
summary_parser.add_argument("--since", required=True, help="git revision; commits after it are summarized")
|
|
662
|
+
summary_parser.add_argument("--repo", default=".", help="git repository to read (default: current directory)")
|
|
663
|
+
summary_parser.add_argument("--batch-id", default="")
|
|
664
|
+
summary_parser.add_argument("--skill", default="")
|
|
665
|
+
summary_parser.add_argument("--run-id", default="")
|
|
666
|
+
summary_parser.add_argument("--phase", default="")
|
|
667
|
+
summary_parser.add_argument("--output")
|
|
566
668
|
review_parser = sub.add_parser("batch-review")
|
|
567
669
|
review_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
568
670
|
review_parser.add_argument("--batch-id", required=True)
|
|
@@ -575,11 +677,13 @@ def main() -> int:
|
|
|
575
677
|
submit_parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
|
|
576
678
|
submit_parser.add_argument("--confirm", action="store_true")
|
|
577
679
|
list_parser = sub.add_parser("list")
|
|
680
|
+
list_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
578
681
|
args = parser.parse_args()
|
|
579
682
|
try:
|
|
580
683
|
if args.command == "collect": return collect(args)
|
|
581
684
|
if args.command == "batch": return collect_batch(args)
|
|
582
685
|
if args.command == "batch-review": return batch_review(args)
|
|
686
|
+
if args.command == "session-summary": return session_summary(args)
|
|
583
687
|
if args.command == "preview": return preview(args)
|
|
584
688
|
if args.command == "submit": return submit(args)
|
|
585
689
|
return list_feedback(args)
|