@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.
Files changed (63) hide show
  1. package/README-zh-TW.md +12 -4
  2. package/README.md +55 -11
  3. package/bin/maggie.js +26 -4
  4. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  5. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  6. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  7. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  8. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  9. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  10. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  11. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  12. package/bundled-marketplace/README.md +17 -0
  13. package/bundled-marketplace/catalog.json +56 -7
  14. package/bundled-references/browser-inspection.md +25 -0
  15. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  16. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  17. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  18. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  19. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  20. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  21. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  22. package/bundled-references/maggiedash-booking/README.md +23 -6
  23. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  24. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  25. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  26. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  27. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  28. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  29. package/bundled-references/memory-hook.md +18 -5
  30. package/bundled-references/provider-adapter-contract.md +33 -0
  31. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  32. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  33. package/bundled-skills/maggie-deployment/SKILL.md +59 -1
  34. package/bundled-skills/maggie-deployment/references/vps.md +41 -0
  35. package/bundled-skills/maggie-design/SKILL.md +99 -0
  36. package/bundled-skills/maggie-feedback/SKILL.md +41 -2
  37. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  38. package/bundled-skills/maggie-memory/SKILL.md +10 -1
  39. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  40. package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
  41. package/bundled-skills/maggie-social-share/SKILL.md +37 -5
  42. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
  43. package/bundled-skills/maggie-template/SKILL.md +57 -3
  44. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  45. package/bundled-tools/clis/maggie_booking.py +199 -26
  46. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  47. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  48. package/bundled-tools/clis/maggie_deployment.py +295 -0
  49. package/bundled-tools/clis/maggie_design.py +9 -0
  50. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  51. package/bundled-tools/clis/maggie_feedback.py +107 -3
  52. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  53. package/bundled-tools/clis/maggie_memory.py +10 -3
  54. package/bundled-tools/clis/maggie_palette.py +280 -0
  55. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  56. package/bundled-tools/clis/maggie_social_queue.py +338 -0
  57. package/bundled-tools/clis/maggie_social_share.py +42 -9
  58. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  59. package/bundled-tools/runtime/maggie_memory.py +17 -2
  60. package/package.json +1 -1
  61. package/references/browser-inspection.md +25 -0
  62. package/references/memory-hook.md +18 -5
  63. 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 = dict(data)
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=("bug", "feature", "quality", "general"), default="bug")
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)