skilldrop-cli 1.6.0 → 1.7.0

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 (123) hide show
  1. package/README.md +2 -0
  2. package/catalogue.json +18 -2
  3. package/model-routing.json +16 -0
  4. package/package.json +1 -1
  5. package/packs/event-planner/pack.json +61 -0
  6. package/packs/event-planner/skills/run-of-show/SKILL.md +141 -0
  7. package/packs/event-planner/skills/run-of-show/evals/eval_queries.json +30 -0
  8. package/packs/event-planner/skills/run-of-show/evals/evals.json +103 -0
  9. package/packs/event-planner/skills/run-of-show/evals/files/1/agenda.md +16 -0
  10. package/packs/event-planner/skills/run-of-show/evals/files/2/schedule.csv +8 -0
  11. package/packs/event-planner/skills/run-of-show/evals/files/3/notes.txt +3 -0
  12. package/packs/event-planner/skills/run-of-show/evals/files/4/sessions_export.csv +25 -0
  13. package/packs/event-planner/skills/run-of-show/evals/files/4/venue_and_crew.txt +27 -0
  14. package/packs/event-planner/skills/run-of-show/evals/files/5/runsheet_v4.csv +28 -0
  15. package/packs/event-planner/skills/run-of-show/evals/files/5/venue_and_suppliers.txt +22 -0
  16. package/packs/event-planner/skills/run-of-show/evals/files/6/acts_from_booker.csv +26 -0
  17. package/packs/event-planner/skills/run-of-show/evals/files/6/venue_rooms_and_crew.txt +14 -0
  18. package/packs/event-planner/skills/run-of-show/evals/files/7/runsheet_draft_v4.csv +25 -0
  19. package/packs/event-planner/skills/run-of-show/evals/files/7/venue_and_fixed_times.txt +24 -0
  20. package/packs/event-planner/skills/run-of-show/examples/product-launch.md +77 -0
  21. package/packs/event-planner/skills/run-of-show/examples/product-launch.svg +58 -0
  22. package/packs/event-planner/skills/run-of-show/manifest.json +44 -0
  23. package/packs/event-planner/skills/run-of-show/scripts/show_check.py +295 -0
  24. package/packs/event-planner/skills/run-of-show/templates/show.json +15 -0
  25. package/packs/event-planner/skills/seating-plan/SKILL.md +152 -0
  26. package/packs/event-planner/skills/seating-plan/evals/eval_queries.json +30 -0
  27. package/packs/event-planner/skills/seating-plan/evals/evals.json +134 -0
  28. package/packs/event-planner/skills/seating-plan/evals/files/1/guests.csv +31 -0
  29. package/packs/event-planner/skills/seating-plan/evals/files/2/table-plan.txt +8 -0
  30. package/packs/event-planner/skills/seating-plan/evals/files/3/brief.txt +4 -0
  31. package/packs/event-planner/skills/seating-plan/evals/files/3/guests.csv +49 -0
  32. package/packs/event-planner/skills/seating-plan/evals/files/4/rsvp_export.csv +127 -0
  33. package/packs/event-planner/skills/seating-plan/evals/files/4/seating_notes.txt +21 -0
  34. package/packs/event-planner/skills/seating-plan/evals/files/5/organiser_notes.txt +10 -0
  35. package/packs/event-planner/skills/seating-plan/evals/files/5/registrations.csv +93 -0
  36. package/packs/event-planner/skills/seating-plan/evals/files/5/table_plan_v3.csv +94 -0
  37. package/packs/event-planner/skills/seating-plan/evals/files/6/bridgets_notes.txt +30 -0
  38. package/packs/event-planner/skills/seating-plan/evals/files/6/ticket_export.csv +117 -0
  39. package/packs/event-planner/skills/seating-plan/evals/files/6/venue_floorplan_notes.txt +14 -0
  40. package/packs/event-planner/skills/seating-plan/evals/files/7/keep_apart_CONFIDENTIAL.txt +9 -0
  41. package/packs/event-planner/skills/seating-plan/evals/files/7/prom_seating_v3.csv +101 -0
  42. package/packs/event-planner/skills/seating-plan/evals/files/7/ticket_list.csv +101 -0
  43. package/packs/event-planner/skills/seating-plan/evals/files/7/venue_info.txt +16 -0
  44. package/packs/event-planner/skills/seating-plan/evals/files/8/registrations_export.csv +147 -0
  45. package/packs/event-planner/skills/seating-plan/evals/files/8/room_layout.md +22 -0
  46. package/packs/event-planner/skills/seating-plan/evals/files/8/seating_requests.txt +31 -0
  47. package/packs/event-planner/skills/seating-plan/evals/files/9/guest_list.csv +84 -0
  48. package/packs/event-planner/skills/seating-plan/evals/files/9/notes.txt +16 -0
  49. package/packs/event-planner/skills/seating-plan/evals/files/9/table_plan.csv +85 -0
  50. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.md +61 -0
  51. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.svg +39 -0
  52. package/packs/event-planner/skills/seating-plan/manifest.json +44 -0
  53. package/packs/event-planner/skills/seating-plan/scripts/seating_check.py +324 -0
  54. package/packs/event-planner/skills/seating-plan/templates/seating.json +28 -0
  55. package/packs/experience-design/pack.json +5 -0
  56. package/packs/experience-design/skills/locale-check/SKILL.md +139 -0
  57. package/packs/experience-design/skills/locale-check/evals/eval_queries.json +38 -0
  58. package/packs/experience-design/skills/locale-check/evals/evals.json +104 -0
  59. package/packs/experience-design/skills/locale-check/evals/files/1/budget.json +1 -0
  60. package/packs/experience-design/skills/locale-check/evals/files/1/locales/ar.json +16 -0
  61. package/packs/experience-design/skills/locale-check/evals/files/1/locales/de.json +12 -0
  62. package/packs/experience-design/skills/locale-check/evals/files/1/locales/en.json +12 -0
  63. package/packs/experience-design/skills/locale-check/evals/files/1/locales/pl.json +12 -0
  64. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values/strings.xml +12 -0
  65. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-es/strings.xml +11 -0
  66. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-fr/strings.xml +12 -0
  67. package/packs/experience-design/skills/locale-check/evals/files/3/po/ja.po +20 -0
  68. package/packs/experience-design/skills/locale-check/evals/files/3/po/messages.pot +23 -0
  69. package/packs/experience-design/skills/locale-check/evals/files/3/po/ru.po +22 -0
  70. package/packs/experience-design/skills/locale-check/evals/files/4/budgets.json +15 -0
  71. package/packs/experience-design/skills/locale-check/evals/files/4/locales/ar.json +265 -0
  72. package/packs/experience-design/skills/locale-check/evals/files/4/locales/de.json +265 -0
  73. package/packs/experience-design/skills/locale-check/evals/files/4/locales/en.json +265 -0
  74. package/packs/experience-design/skills/locale-check/evals/files/4/locales/fr.json +265 -0
  75. package/packs/experience-design/skills/locale-check/evals/files/4/locales/pl.json +265 -0
  76. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values/strings.xml +77 -0
  77. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-es/strings.xml +63 -0
  78. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-ja/strings.xml +61 -0
  79. package/packs/experience-design/skills/locale-check/evals/files/6/po/cs.po +605 -0
  80. package/packs/experience-design/skills/locale-check/evals/files/6/po/marlin.pot +602 -0
  81. package/packs/experience-design/skills/locale-check/evals/files/6/po/pt_BR.po +602 -0
  82. package/packs/experience-design/skills/locale-check/evals/files/6/po/ru.po +608 -0
  83. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.strings +160 -0
  84. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.stringsdict +70 -0
  85. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.strings +157 -0
  86. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.stringsdict +86 -0
  87. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.strings +160 -0
  88. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.stringsdict +68 -0
  89. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.strings +160 -0
  90. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.stringsdict +62 -0
  91. package/packs/experience-design/skills/locale-check/examples/settings-release.md +107 -0
  92. package/packs/experience-design/skills/locale-check/manifest.json +45 -0
  93. package/packs/experience-design/skills/locale-check/scripts/locale_check.py +1105 -0
  94. package/packs/operations-manager/pack.json +60 -0
  95. package/packs/operations-manager/skills/shift-roster/SKILL.md +166 -0
  96. package/packs/operations-manager/skills/shift-roster/evals/eval_queries.json +38 -0
  97. package/packs/operations-manager/skills/shift-roster/evals/evals.json +135 -0
  98. package/packs/operations-manager/skills/shift-roster/evals/files/1/shifts.csv +3 -0
  99. package/packs/operations-manager/skills/shift-roster/evals/files/1/staff.csv +8 -0
  100. package/packs/operations-manager/skills/shift-roster/evals/files/2/rota.csv +22 -0
  101. package/packs/operations-manager/skills/shift-roster/evals/files/2/staff.csv +6 -0
  102. package/packs/operations-manager/skills/shift-roster/evals/files/3/team.md +14 -0
  103. package/packs/operations-manager/skills/shift-roster/evals/files/4/availability_export_wk47.csv +23 -0
  104. package/packs/operations-manager/skills/shift-roster/evals/files/4/rota_notes.txt +20 -0
  105. package/packs/operations-manager/skills/shift-roster/evals/files/5/rota_draft_16-29nov.csv +107 -0
  106. package/packs/operations-manager/skills/shift-roster/evals/files/5/staff_list.csv +17 -0
  107. package/packs/operations-manager/skills/shift-roster/evals/files/6/availability_notes.txt +24 -0
  108. package/packs/operations-manager/skills/shift-roster/evals/files/6/peoplehub_staff_export.csv +14 -0
  109. package/packs/operations-manager/skills/shift-roster/evals/files/6/rota_template.csv +25 -0
  110. package/packs/operations-manager/skills/shift-roster/evals/files/7/leave_and_availability.txt +18 -0
  111. package/packs/operations-manager/skills/shift-roster/evals/files/7/rota_wc_30nov.csv +47 -0
  112. package/packs/operations-manager/skills/shift-roster/evals/files/7/staff_list.csv +15 -0
  113. package/packs/operations-manager/skills/shift-roster/evals/files/8/availability_form_responses.csv +22 -0
  114. package/packs/operations-manager/skills/shift-roster/evals/files/8/cover_requirements.csv +15 -0
  115. package/packs/operations-manager/skills/shift-roster/evals/files/8/staff_export.csv +29 -0
  116. package/packs/operations-manager/skills/shift-roster/evals/files/9/leave_and_restrictions.txt +16 -0
  117. package/packs/operations-manager/skills/shift-roster/evals/files/9/rota_export_07-13Dec.csv +85 -0
  118. package/packs/operations-manager/skills/shift-roster/evals/files/9/staff_list.csv +27 -0
  119. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.md +72 -0
  120. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.svg +123 -0
  121. package/packs/operations-manager/skills/shift-roster/manifest.json +34 -0
  122. package/packs/operations-manager/skills/shift-roster/scripts/roster_check.py +633 -0
  123. package/packs/operations-manager/skills/shift-roster/templates/roster.json +22 -0
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ """Check a table seating plan against its rules and draw the room. Stdlib only.
3
+
4
+ Reads a seating.json: the tables (capacity, shape, position in metres, accessible or not),
5
+ the guests (name, party or household, needs), the rules (groups that sit together, pairs
6
+ kept apart, fixed seats) and the assignments (table -> guests). It fails a plan where
7
+ - a guest is unseated, or seated twice
8
+ - a table holds more guests than its capacity
9
+ - a together group is split across tables
10
+ - an apart pair shares a table
11
+ - a wheelchair user sits at a table not marked accessible
12
+ - a guest with a fixed seat is not at that table
13
+ and warns where
14
+ - a household or party that would fit at one table is split
15
+ - a table is under half full, or empty
16
+ - a guest who came alone sits at a table where everyone else is one other party
17
+ - a guest who needs to be near an exit is further than --exit-distance from one (when
18
+ the room's exits are given)
19
+ Then it lists every table with its guests and dietary needs, and with --svg draws the room:
20
+ tables at their positions, labelled with their fill, names around each, failures in red.
21
+
22
+ Usage:
23
+ python3 seating_check.py seating.json [--svg room.svg] [--json] [--exit-distance 8]
24
+ Exit codes: 0 no failures, 1 a failure, 2 bad input.
25
+ """
26
+ import argparse
27
+ import json
28
+ import math
29
+ import sys
30
+
31
+
32
+ class BadInput(ValueError):
33
+ pass
34
+
35
+
36
+ def _num(v, what):
37
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v):
38
+ raise BadInput(f"{what} must be a number, got {v!r}")
39
+ return float(v)
40
+
41
+
42
+ def _point(v, what):
43
+ if not isinstance(v, (list, tuple)) or len(v) != 2:
44
+ raise BadInput(f"{what} must be [x, y] in metres")
45
+ return (_num(v[0], what + " x"), _num(v[1], what + " y"))
46
+
47
+
48
+ def load(data):
49
+ """Validate the raw JSON and return a normalised plan. Raises BadInput."""
50
+ if not isinstance(data, dict):
51
+ raise BadInput("the file must hold a JSON object")
52
+ tables, guests = {}, {}
53
+ for i, t in enumerate(data.get("tables") or []):
54
+ if not isinstance(t, dict) or not t.get("id"):
55
+ raise BadInput(f"table {i + 1} needs an id")
56
+ tid = str(t["id"])
57
+ if tid in tables:
58
+ raise BadInput(f"two tables are called {tid!r}")
59
+ cap = _num(t.get("capacity", 10), f"table {tid} capacity")
60
+ if cap < 1 or cap != int(cap):
61
+ raise BadInput(f"table {tid} capacity must be a whole number of seats")
62
+ shape = str(t.get("shape", "round"))
63
+ if shape not in ("round", "rect"):
64
+ raise BadInput(f"table {tid} shape must be round or rect")
65
+ tables[tid] = {"id": tid, "capacity": int(cap), "shape": shape,
66
+ "at": _point(t["at"], f"table {tid} position") if "at" in t else None,
67
+ "accessible": bool(t.get("accessible", False)), "label": str(t.get("label", ""))}
68
+ if not tables:
69
+ raise BadInput("no tables")
70
+ for i, g in enumerate(data.get("guests") or []):
71
+ if not isinstance(g, dict) or not g.get("id"):
72
+ raise BadInput(f"guest {i + 1} needs an id")
73
+ gid = str(g["id"])
74
+ if gid in guests:
75
+ raise BadInput(f"two guests have the id {gid!r}")
76
+ needs = g.get("needs") or []
77
+ if isinstance(needs, str):
78
+ needs = [needs]
79
+ guests[gid] = {"id": gid, "name": str(g.get("name") or gid), "party": str(g.get("party") or ""),
80
+ "needs": [str(n).lower() for n in needs], "dietary": str(g.get("dietary") or "")}
81
+ if not guests:
82
+ raise BadInput("no guests")
83
+
84
+ def known(gid, where):
85
+ if str(gid) not in guests:
86
+ raise BadInput(f"{where} names a guest {gid!r} who is not on the guest list")
87
+ return str(gid)
88
+
89
+ together = []
90
+ for i, grp in enumerate(data.get("together") or []):
91
+ if not isinstance(grp, list) or len(grp) < 2:
92
+ raise BadInput(f"together group {i + 1} must list two or more guests")
93
+ together.append([known(x, f"together group {i + 1}") for x in grp])
94
+ apart = []
95
+ for i, pair in enumerate(data.get("apart") or []):
96
+ if not isinstance(pair, list) or len(pair) != 2:
97
+ raise BadInput(f"apart pair {i + 1} must list exactly two guests")
98
+ apart.append([known(x, f"apart pair {i + 1}") for x in pair])
99
+ fixed = {}
100
+ if not isinstance(data.get("fixed") or {}, dict):
101
+ raise BadInput("fixed must map a guest id to a table id")
102
+ for gid, tid in (data.get("fixed") or {}).items():
103
+ if str(tid) not in tables:
104
+ raise BadInput(f"the fixed seat for {gid} names a table {tid!r} that does not exist")
105
+ fixed[known(gid, "fixed")] = str(tid)
106
+
107
+ raw = data.get("assignments") or {}
108
+ if not isinstance(raw, dict):
109
+ raise BadInput("assignments must map each table id to a list of guest ids")
110
+ seats = {tid: [] for tid in tables}
111
+ for tid, gl in raw.items():
112
+ if str(tid) not in tables:
113
+ raise BadInput(f"assignments name a table {tid!r} that does not exist")
114
+ if not isinstance(gl, list):
115
+ raise BadInput(f"assignments for table {tid} must be a list of guest ids")
116
+ seats[str(tid)] += [known(g, f"table {tid}") for g in gl]
117
+ exits = [_point(e, f"exit {i + 1}") for i, e in enumerate(data.get("exits") or [])]
118
+ room = data.get("room") or {}
119
+ if not isinstance(room, dict):
120
+ raise BadInput("room must be {\"width\": metres, \"depth\": metres}")
121
+ return {"tables": tables, "guests": guests, "together": together, "apart": apart, "fixed": fixed,
122
+ "seats": seats, "exits": exits,
123
+ "room": (_num(room["width"], "room width"), _num(room["depth"], "room depth"))
124
+ if room.get("width") and room.get("depth") else None}
125
+
126
+
127
+ def check(plan, exit_distance=8.0):
128
+ tables, guests, seats = plan["tables"], plan["guests"], plan["seats"]
129
+ fails, warns, bad_tables = [], [], set()
130
+ where = {}
131
+ for tid, gl in seats.items():
132
+ for g in gl:
133
+ where.setdefault(g, []).append(tid)
134
+ name = lambda g: guests[g]["name"]
135
+ for g in guests:
136
+ at = where.get(g, [])
137
+ if not at:
138
+ fails.append(f"{name(g)} has no seat")
139
+ elif len(at) > 1:
140
+ fails.append(f"{name(g)} is seated more than once ({', '.join(at)})")
141
+ bad_tables.update(at)
142
+ table_of = {g: at[0] for g, at in where.items() if len(at) == 1}
143
+ for tid, gl in seats.items():
144
+ t = tables[tid]
145
+ if len(gl) > t["capacity"]:
146
+ fails.append(f"table {tid} has {len(gl)} guests for {t['capacity']} seats")
147
+ bad_tables.add(tid)
148
+ for grp in plan["together"]:
149
+ at = {table_of.get(g) for g in grp if g in table_of}
150
+ if len(at) > 1:
151
+ fails.append("must sit together but are split: " + ", ".join(f"{name(g)} ({table_of.get(g, 'unseated')})" for g in grp))
152
+ bad_tables.update(t for t in at if t)
153
+ for a, b in plan["apart"]:
154
+ if a in table_of and table_of.get(a) == table_of.get(b):
155
+ fails.append(f"{name(a)} and {name(b)} must be kept apart but both sit at table {table_of[a]}")
156
+ bad_tables.add(table_of[a])
157
+ for g, tid in table_of.items():
158
+ if "wheelchair" in guests[g]["needs"] and not tables[tid]["accessible"]:
159
+ fails.append(f"{name(g)} uses a wheelchair but table {tid} is not marked accessible")
160
+ bad_tables.add(tid)
161
+ for g, tid in plan["fixed"].items():
162
+ if table_of.get(g) != tid:
163
+ fails.append(f"{name(g)} has a fixed seat at table {tid} but sits at {table_of.get(g, 'no table')}")
164
+ bad_tables.add(tid)
165
+
166
+ biggest = max(t["capacity"] for t in tables.values())
167
+ parties = {}
168
+ for g, info in guests.items():
169
+ if info["party"]:
170
+ parties.setdefault(info["party"], []).append(g)
171
+ for party, members in sorted(parties.items()):
172
+ at = sorted({table_of[g] for g in members if g in table_of})
173
+ if len(at) > 1 and len(members) <= biggest:
174
+ warns.append(f"the {party} party ({len(members)}) is split across tables {', '.join(at)}")
175
+ for tid, gl in seats.items():
176
+ cap = tables[tid]["capacity"]
177
+ if not gl:
178
+ warns.append(f"table {tid} is empty")
179
+ elif len(gl) < cap / 2:
180
+ warns.append(f"table {tid} is under half full ({len(gl)} of {cap})")
181
+ in_group = {g for grp in plan["together"] for g in grp}
182
+ for g, tid in table_of.items():
183
+ p = guests[g]["party"]
184
+ alone = (not p or len(parties.get(p, [])) == 1) and g not in in_group
185
+ others = [o for o in seats[tid] if o != g]
186
+ other_parties = {guests[o]["party"] for o in others}
187
+ if alone and len(others) >= 2 and len(other_parties) == 1 and "" not in other_parties:
188
+ warns.append(f"{name(g)} came alone and is the only guest at table {tid} not in the {other_parties.pop()} party")
189
+ if plan["exits"]:
190
+ for g, tid in table_of.items():
191
+ t = tables[tid]
192
+ if "near exit" in guests[g]["needs"] and t["at"]:
193
+ d = min(math.dist(t["at"], e) for e in plan["exits"])
194
+ if d > exit_distance:
195
+ warns.append(f"{name(g)} needs to be near an exit but table {tid} is {d:.1f} m from the nearest")
196
+ return {"fails": fails, "warns": warns, "bad_tables": sorted(bad_tables), "table_of": table_of,
197
+ "seated": len(table_of), "guests": len(guests),
198
+ "seats": sum(t["capacity"] for t in tables.values())}
199
+
200
+
201
+ def table_rows(plan, r):
202
+ rows = []
203
+ for tid, t in plan["tables"].items():
204
+ gl = plan["seats"][tid]
205
+ diet = {}
206
+ for g in gl:
207
+ d = plan["guests"][g]["dietary"]
208
+ if d:
209
+ diet[d] = diet.get(d, 0) + 1
210
+ rows.append({"table": tid, "shape": t["shape"], "capacity": t["capacity"], "accessible": t["accessible"],
211
+ "seated": len(gl), "guests": [plan["guests"][g]["name"] for g in gl], "dietary": diet,
212
+ "ok": tid not in r["bad_tables"]})
213
+ return rows
214
+
215
+
216
+ def report(plan, r):
217
+ out = [f"{r['seated']} of {r['guests']} guests seated, {len(plan['tables'])} tables, {r['seats']} seats."]
218
+ for row in table_rows(plan, r):
219
+ flags = ("accessible, " if row["accessible"] else "") + f"{row['seated']}/{row['capacity']}"
220
+ diet = ("; " + ", ".join(f"{n} {d}" for d, n in sorted(row["dietary"].items()))) if row["dietary"] else ""
221
+ out.append(f" {row['table']} ({row['shape']}, {flags}{diet}): {', '.join(row['guests']) or '-'}")
222
+ out += [f"FAIL {f}" for f in r["fails"]] + [f"WARN {w}" for w in r["warns"]]
223
+ out.append("PASS" if not r["fails"] else f"{len(r['fails'])} failure(s)")
224
+ return "\n".join(out) + "\n"
225
+
226
+
227
+ def esc(t):
228
+ return str(t).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
229
+
230
+
231
+ def layout(plan):
232
+ """Positions for every table: given ones, or a grid for tables without one."""
233
+ pos, n = {}, 0
234
+ for tid, t in plan["tables"].items():
235
+ if t["at"]:
236
+ pos[tid] = t["at"]
237
+ cols = max(1, math.ceil(math.sqrt(len(plan["tables"]))))
238
+ for tid, t in plan["tables"].items():
239
+ if tid not in pos:
240
+ pos[tid] = (2.5 + 4.0 * (n % cols), 2.5 + 4.0 * (n // cols))
241
+ n += 1
242
+ return pos
243
+
244
+
245
+ def svg(plan, r, scale=40):
246
+ pos = layout(plan)
247
+ radius = lambda t: 0.45 + 0.09 * t["capacity"] # metres, so a 10-seat round is about 1.35 m
248
+ xs = [p[0] for p in pos.values()] + [e[0] for e in plan["exits"]]
249
+ ys = [p[1] for p in pos.values()] + [e[1] for e in plan["exits"]]
250
+ w, d = plan["room"] or (max(xs) + 3, max(ys) + 3)
251
+ W, H = w * scale + 80, d * scale + 110
252
+ P = lambda x, y: (40 + x * scale, 60 + y * scale)
253
+ out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:.0f} {H:.0f}" width="{W:.0f}" height="{H:.0f}" '
254
+ 'font-family="Helvetica, Arial, sans-serif">',
255
+ f'<rect width="{W:.0f}" height="{H:.0f}" fill="#ffffff"/>',
256
+ f'<text x="40" y="30" font-size="16" font-weight="bold">Seating plan: {r["seated"]} of {r["guests"]} guests, '
257
+ f'{len(plan["tables"])} tables</text>',
258
+ f'<rect x="40" y="60" width="{w * scale:.0f}" height="{d * scale:.0f}" fill="#fafafa" stroke="#444" stroke-width="2"/>']
259
+ for i, e in enumerate(plan["exits"]):
260
+ x, y = P(*e)
261
+ out.append(f'<rect x="{x - 14:.0f}" y="{y - 6:.0f}" width="28" height="12" fill="#2e7d32"/>'
262
+ f'<text x="{x:.0f}" y="{y + 22:.0f}" font-size="10" text-anchor="middle" fill="#2e7d32">EXIT</text>')
263
+ for tid, t in plan["tables"].items():
264
+ x, y = P(*pos[tid])
265
+ rad = radius(t) * scale
266
+ bad = tid in r["bad_tables"]
267
+ stroke, fill = ("#c62828", "#ffebee") if bad else ("#455a64", "#eceff1")
268
+ if t["shape"] == "round":
269
+ out.append(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{rad:.0f}" fill="{fill}" stroke="{stroke}" stroke-width="{3 if bad else 1.5}"/>')
270
+ else:
271
+ out.append(f'<rect x="{x - rad * 1.4:.0f}" y="{y - rad * 0.6:.0f}" width="{rad * 2.8:.0f}" height="{rad * 1.2:.0f}" '
272
+ f'fill="{fill}" stroke="{stroke}" stroke-width="{3 if bad else 1.5}"/>')
273
+ gl = plan["seats"][tid]
274
+ mark = " ✗" if bad else ""
275
+ acc = " · accessible" if t["accessible"] else ""
276
+ out.append(f'<text x="{x:.0f}" y="{y - 2:.0f}" font-size="13" font-weight="bold" text-anchor="middle" '
277
+ f'fill="{stroke}">{esc(tid)}{mark}</text>'
278
+ f'<text x="{x:.0f}" y="{y + 13:.0f}" font-size="10" text-anchor="middle" fill="#555">{len(gl)}/{t["capacity"]}{acc}</text>')
279
+ rx, ry = (rad + 8, rad + 8) if t["shape"] == "round" else (rad * 1.4 + 10, rad * 0.6 + 10)
280
+ for k, g in enumerate(gl):
281
+ a = -math.pi / 2 + 2 * math.pi * k / max(len(gl), 1)
282
+ gx, gy = x + rx * math.cos(a), y + ry * math.sin(a)
283
+ anchor = "start" if math.cos(a) > 0.2 else ("end" if math.cos(a) < -0.2 else "middle")
284
+ out.append(f'<text x="{gx:.0f}" y="{gy + 3:.0f}" font-size="9" text-anchor="{anchor}" fill="#222">'
285
+ f'{esc(plan["guests"][g]["name"])}</text>')
286
+ ly = 60 + d * scale + 28
287
+ status = "PASS: every rule met" if not r["fails"] else f"{len(r['fails'])} rule(s) broken: tables marked ✗ in red"
288
+ out.append(f'<text x="40" y="{ly:.0f}" font-size="12" fill="{"#2e7d32" if not r["fails"] else "#c62828"}">{esc(status)}</text>')
289
+ out.append(f'<text x="40" y="{ly + 18:.0f}" font-size="10" fill="#666">Positions in metres, '
290
+ f'1 m = {scale} px · names run clockwise from the top of each table</text>')
291
+ out.append("</svg>")
292
+ return "\n".join(out) + "\n"
293
+
294
+
295
+ def main(argv=None):
296
+ ap = argparse.ArgumentParser(description="Check a seating plan against its rules and draw the room.")
297
+ ap.add_argument("seating")
298
+ ap.add_argument("--svg", help="write the room plan here")
299
+ ap.add_argument("--json", action="store_true", help="print the result as JSON")
300
+ ap.add_argument("--exit-distance", type=float, default=8.0,
301
+ help="metres from an exit that counts as near one (default 8)")
302
+ args = ap.parse_args(argv)
303
+ try:
304
+ with open(args.seating, encoding="utf-8") as fh:
305
+ plan = load(json.load(fh))
306
+ except (OSError, ValueError) as e: # BadInput and JSONDecodeError are ValueErrors
307
+ print(f"FAIL cannot read {args.seating}: {e}")
308
+ return 2
309
+ r = check(plan, args.exit_distance)
310
+ if args.json:
311
+ print(json.dumps({"pass": not r["fails"], "fails": r["fails"], "warns": r["warns"],
312
+ "seated": r["seated"], "guests": r["guests"], "tables": table_rows(plan, r)}, indent=1))
313
+ else:
314
+ print(report(plan, r), end="")
315
+ if args.svg:
316
+ with open(args.svg, "w", encoding="utf-8") as fh:
317
+ fh.write(svg(plan, r))
318
+ if not args.json:
319
+ print(f"\nWrote {args.svg}")
320
+ return 1 if r["fails"] else 0
321
+
322
+
323
+ if __name__ == "__main__":
324
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ {
2
+ "room": {"width": 14, "depth": 10},
3
+ "exits": [[0, 5], [14, 9]],
4
+ "tables": [
5
+ {"id": "Head", "capacity": 8, "shape": "rect", "at": [7, 1.5], "accessible": true},
6
+ {"id": "T1", "capacity": 10, "shape": "round", "at": [3, 5], "accessible": true},
7
+ {"id": "T2", "capacity": 10, "shape": "round", "at": [7, 5.5]},
8
+ {"id": "T3", "capacity": 10, "shape": "round", "at": [11, 5]}
9
+ ],
10
+ "guests": [
11
+ {"id": "g1", "name": "Mira Osei", "party": "Couple"},
12
+ {"id": "g2", "name": "Tom Hale", "party": "Couple"},
13
+ {"id": "g3", "name": "Ruth Osei", "party": "Osei", "needs": ["wheelchair"], "dietary": "vegetarian"},
14
+ {"id": "g4", "name": "Kwame Osei", "party": "Osei"},
15
+ {"id": "g5", "name": "Dana Hale", "party": "Hale", "needs": ["near exit"]},
16
+ {"id": "g6", "name": "Pete Hale", "party": "Hale", "dietary": "gluten-free"},
17
+ {"id": "g7", "name": "Lena Brook", "party": ""}
18
+ ],
19
+ "together": [["g3", "g4"]],
20
+ "apart": [["g4", "g6"]],
21
+ "fixed": {"g1": "Head", "g2": "Head"},
22
+ "assignments": {
23
+ "Head": ["g1", "g2"],
24
+ "T1": ["g3", "g4", "g7"],
25
+ "T2": [],
26
+ "T3": ["g5", "g6"]
27
+ }
28
+ }
@@ -57,6 +57,11 @@
57
57
  "you_get": "A blueprint table and Mermaid diagram with fail points, waits, pains traced to causes, and 2–3 ranked fixes.",
58
58
  "decide": "Which fix becomes committed work."
59
59
  },
60
+ {
61
+ "skill": "locale-check",
62
+ "you_do": "Hand over the source strings and the translated files before release.",
63
+ "you_get": "Whether the release is safe to ship, and each failure by locale with the corrected string or a note for the translator."
64
+ },
60
65
  {
61
66
  "skill": "doc-critique",
62
67
  "you_do": "Run it on any of the specs before you share them.",
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: locale-check
3
+ description: Check a product's translations against the source strings before they ship — every key present, every placeholder ({name}, %s, %1$d, ICU plural and select) carried over intact, the plural forms each language needs, markup tags balanced, and strings over their character budget — with a script that reads JSON, ARB, gettext .po, Android strings.xml and iOS .strings, and lists each problem with its key and locale. Use when someone asks to "check the translations", "QA these locale files", "will the German fit", "why does the French build crash on this string", or hands over locale files before a release.
4
+ ---
5
+
6
+ # locale-check
7
+
8
+ Decides whether a set of translated string files is safe to ship, and says exactly what to fix
9
+ in each one. The defects that matter are mechanical and easy to miss by reading: a `{count}`
10
+ dropped from one German string, Polish shipped with two plural forms where it needs four, an
11
+ Arabic file missing `zero`, two printf arguments swapped in French, an apostrophe that stops an
12
+ Android build. A read-through of 500 strings misses them; a check of every string doesn't. This
13
+ skill does not write the source strings (that's `ux-writing`) or the page content around them
14
+ (that's `content-design`), and it does not translate.
15
+
16
+ ## How to respond
17
+
18
+ 1. **Run the check on the source and every target before reading anything.** The source is
19
+ the language the product is written in, often `en`; the targets are the translations.
20
+
21
+ ```bash
22
+ python3 "${CLAUDE_SKILL_DIR}/scripts/locale_check.py" en.json de.json fr.json pl.json --json
23
+ # Other IDEs (from the skill folder)
24
+ python3 scripts/locale_check.py values/strings.xml values-ar/strings.xml values-fr/strings.xml
25
+ python3 scripts/locale_check.py messages.pot fr.po --budget budget.json
26
+ ```
27
+
28
+ It reads JSON (i18next `key_one` / `key_other` plurals included), ARB, `.po` against a
29
+ `.pot` or source `.po`, Android `strings.xml`, iOS `.strings` and `.stringsdict`. The locale
30
+ comes from the path (`de.json`, `values-fr/`, `fr.lproj`); when a path doesn't say, pass
31
+ `--locale` once per target, in order. A file in another format (XLIFF, YAML, a
32
+ spreadsheet) is read by hand: list its keys, placeholders and plural forms, and apply the
33
+ rules below to every row. Exit 0 means no failures, 1 failures, 2 a file it couldn't read.
34
+
35
+ 2. **Read the source string beside every finding, and beside every reordered placeholder.**
36
+ The check matches tokens, not meaning, so a clean result is not the answer.
37
+ - **A reorder is valid syntax and can still be wrong.** For every target whose numbered
38
+ placeholders (`%2$s … %1$s`, `{1} … {0}`) appear in a different order from the source,
39
+ read both sentences and confirm each argument still fills the same role. ✅ *"de %2$s a
40
+ %1$s" for "from %1$s to %2$s" sends the parcel the wrong way: a failure, with the fix
41
+ "de %1$s a %2$s".* A reorder that only follows the target's word order is fine.
42
+ - **Read in full the strings on any screen the person names** (checkout, payment, sign-in),
43
+ whatever the check says.
44
+ A placeholder that moved is fine only once its role is confirmed; one that vanished is not. A select keyword is code, so
45
+ `male` translated as `masculin` breaks the branch. A plural form that exists but says the
46
+ wrong number is still wrong. Confirm each finding against the source before reporting it.
47
+
48
+ 3. **Rank by what breaks first.**
49
+ 1. **The build or the app crashes:** a file that doesn't parse, an ICU message that
50
+ doesn't parse, an Android apostrophe without a backslash, printf arguments whose types
51
+ changed or swapped, a missing key the code reads.
52
+ 2. **The screen shows the wrong thing:** a dropped placeholder, plural forms the language
53
+ needs but doesn't have, markup that doesn't balance.
54
+ 3. **The screen overflows:** strings over their budget.
55
+ 4. **Style:** warnings such as possibly untranslated, punctuation dropped, a stale key.
56
+ A warning is reported as a warning. It never sits in the failures table.
57
+
58
+ 4. **Give the corrected string when the fix is mechanical, and only then.**
59
+ - **Restore a dropped placeholder** where the sentence's grammar puts it, and say so:
60
+ ✅ *`"Hallo, {name}!"` — restored `{name}` after the greeting, as in the source.*
61
+ - **Number swapped printf arguments** so the order can stay as the translator wrote it:
62
+ ✅ *`"Vous avez %2$d messages, %1$s"`.*
63
+ - **Add a missing plural form** by repeating the language's pattern only when the
64
+ form is regular and you are sure of it (Polish `few` for 2–4: *"2 pliki"*). Otherwise
65
+ mark it for a translator with the numbers it covers: *"pl `few` (2–4, 22–24, …):
66
+ needs a translator"*.
67
+ - **Escape** an Android apostrophe as `\'`.
68
+ - **Never write new translated text silently.** An untranslated string, a wrong meaning or a
69
+ missing key goes to a translator with the source string, the locale and the slot. When you
70
+ do suggest wording for a missing key, label it *suggested, for a translator to confirm*.
71
+
72
+ 5. **Change no file unless asked.** Offer a patched copy of each target with the mechanical
73
+ fixes applied, written beside the original (`de.fixed.json`), and list what it changed.
74
+
75
+ 6. **Measure every length the person gives you.** A limit stated in the request ("buttons
76
+ max 24 characters", "the German must fit a 120 px tab") is a budget: pass it with
77
+ `--budget`, or count the characters of every string it covers, and report each one over
78
+ with its length and the limit. Never say a string fits without counting it. Only when no
79
+ limit is given anywhere, say in one line that lengths weren't checked, and name the slots
80
+ most at risk (buttons, tabs, toasts).
81
+ A format read by hand, and a locale whose plural rules aren't known, are named too.
82
+
83
+ 7. **Reply in this order.** First line: whether the release is safe to ship, as a plain yes
84
+ or no, with the count of failures by locale, counted from the table below it so the two
85
+ agree. Then the failures, in one table per locale
86
+ or one table with a locale column: key, what breaks, source, target, and the corrected
87
+ string or *needs a translator*. Then the warnings, shorter. Then what wasn't checked. Write
88
+ for the person shipping the release: no tool names, no script output pasted, no paths
89
+ inside this skill's folder. Asked which string caused a crash, name every string that would
90
+ cause it, and pick one only when the evidence (a log, a stack trace, a screen) says which.
91
+
92
+ **Non-interactive runs:** a limit in the request is a budget (step 6); with none anywhere,
93
+ there is no length check, and the reply says so. A missing
94
+ locale code is inferred from the strings' script and language, tagged `[assumption]`, and
95
+ listed first. Emit `BLOCKED: need the source strings and at least one translation` only when
96
+ there is no source file or no target at all; with one of several targets unreadable, check the
97
+ rest and report that one as a failure.
98
+
99
+ ## Useful references in this skill
100
+
101
+ - [`scripts/locale_check.py`](scripts/locale_check.py) — every check in this skill, with CLDR plural categories for about 60 languages and gettext `nplurals` (stdlib only)
102
+ - [`examples/settings-release.md`](examples/settings-release.md) — worked example: a settings screen in German, Russian and Arabic, with translated select keywords, missing Russian plurals and a toggle over budget
103
+
104
+ ## Quality bar
105
+
106
+ - **Every target is checked, every finding read against its source string.** A count with no check behind it is a guess.
107
+ - **Crashes first, then wrong screens, then overflow, then style.** The reader fixes in that order.
108
+ - **A mechanical fix comes with the corrected string;** a wording fix goes to a translator.
109
+ - **Plural forms are judged by the target language's rules,** not the source's: Polish needs four, Arabic six, Japanese one.
110
+ - **Nothing is translated or edited unasked,** and nothing is cut: a dubious string is flagged, not removed.
111
+ - **Every reordered placeholder is read for meaning,** not passed because the syntax is valid.
112
+ - **Every length limit the person states is measured.** "It fits" always has a count behind it.
113
+ - **What wasn't checked is said in one line.**
114
+
115
+ ## When to use this skill
116
+
117
+ - ✅ "Check these translations before the release" with locale files
118
+ - ✅ "Why does the French build crash on this string?"
119
+ - ✅ "Will the German fit on the buttons?" with a budget or the widths
120
+ - ✅ "QA the strings the agency sent back"
121
+ - ✅ A pull request that adds or updates locale files
122
+
123
+ ## When NOT to use this skill
124
+
125
+ - ❌ Writing the source strings, button labels or error messages: that's `ux-writing`
126
+ - ❌ Writing or restructuring the page content itself: that's `content-design`
127
+ - ❌ "Translate this into French": that's translation, and this skill only checks it
128
+
129
+ ## Anti-patterns to avoid
130
+
131
+ - ❌ **Reading the files instead of checking them.** A dropped `{count}` in string 312 of 500 is invisible to a skim.
132
+ - ❌ **Judging plurals by the source.** English has two forms; shipping Russian with two is the bug.
133
+ - ❌ **"Fixing" a translation by rewriting it.** Restore the placeholder; leave the wording to the translator.
134
+ - ❌ **Calling a missing key a warning.** The code reads it; the user sees the raw key or the source text.
135
+ - ❌ **Calling `masculin` a good translation of `male`.** Select keywords are code, never text.
136
+ - ❌ **"Lengths look fine" with no budget.** Say they weren't checked.
137
+ - ❌ **Trusting a clean check on a reordered sentence.** `%2$s … %1$s` passes the syntax and can still swap the sender and the receiver.
138
+ - ❌ **Naming one string as the crash on a guess.** Name every candidate until the evidence picks one.
139
+ - ❌ **Pasting the raw report.** Lead with the ship decision and the fixes, by locale.
@@ -0,0 +1,38 @@
1
+ [
2
+ {
3
+ "query": "check these translation files before we release",
4
+ "should_trigger": true
5
+ },
6
+ {
7
+ "query": "QA the locale JSON the agency sent back for German and Polish",
8
+ "should_trigger": true
9
+ },
10
+ {
11
+ "query": "why does the French Android build crash on this strings.xml",
12
+ "should_trigger": true
13
+ },
14
+ {
15
+ "query": "will the German strings fit on the buttons? here are the files and the limits",
16
+ "should_trigger": true
17
+ },
18
+ {
19
+ "query": "are the plural forms right in our ru.po and ar.json?",
20
+ "should_trigger": true
21
+ },
22
+ {
23
+ "query": "translate these button labels into French",
24
+ "should_trigger": false
25
+ },
26
+ {
27
+ "query": "write the error messages for our checkout form",
28
+ "should_trigger": false
29
+ },
30
+ {
31
+ "query": "rewrite our help page in plain English",
32
+ "should_trigger": false
33
+ },
34
+ {
35
+ "query": "make the wording consistent across the settings screens",
36
+ "should_trigger": false
37
+ }
38
+ ]