@event4u/agent-config 1.25.0 → 1.27.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.
- package/.agent-src/commands/e2e-heal.md +2 -0
- package/.agent-src/commands/e2e-plan.md +2 -0
- package/.agent-src/rules/domain-adoption-policy.md +158 -0
- package/.agent-src/rules/no-unsolicited-rebase.md +107 -0
- package/.agent-src/skills/mobile-e2e-strategy/SKILL.md +147 -0
- package/.agent-src/skills/playwright-testing/SKILL.md +1 -0
- package/.agent-src/skills/react-native-setup/SKILL.md +221 -0
- package/.claude-plugin/marketplace.json +3 -1
- package/CHANGELOG.md +32 -0
- package/README.md +2 -2
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +9 -4
- package/docs/contracts/linter-structural-model.md +180 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/ios-simulator-guide.md +383 -0
- package/docs/guidelines/agent-infra/size-and-scope.md +18 -12
- package/package.json +1 -1
- package/scripts/measure_density.py +232 -0
- package/scripts/skill_linter.py +156 -27
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# iOS Simulator Guide
|
|
2
|
+
|
|
3
|
+
> Decision matrix and reference modules for driving the iOS Simulator
|
|
4
|
+
> from the command line — `simctl`, `idb`, accessibility-driven
|
|
5
|
+
> testing, and known troubleshooting paths.
|
|
6
|
+
|
|
7
|
+
## Scope and audience
|
|
8
|
+
|
|
9
|
+
- Reference material for any work touching the iOS Simulator on macOS
|
|
10
|
+
hosts: smoke tests, accessibility audits, visual regressions, bug
|
|
11
|
+
capture, multi-device test sweeps.
|
|
12
|
+
- Intended companions: `react-native-setup` skill (environment),
|
|
13
|
+
`mobile-e2e-strategy` skill (framework selection), `playwright-testing`
|
|
14
|
+
/ `e2e-plan` skills (cross-platform E2E strategy).
|
|
15
|
+
- **macOS-only:** Xcode + simctl + (optional) idb require a macOS host.
|
|
16
|
+
On Linux/Windows this guideline is reference-only — no implementation
|
|
17
|
+
recipes are portable.
|
|
18
|
+
|
|
19
|
+
## When to consult this guideline
|
|
20
|
+
|
|
21
|
+
- Picking a simulator interaction surface (simctl vs idb vs xcodebuild).
|
|
22
|
+
- Auditing iOS UI accessibility for a release.
|
|
23
|
+
- Driving the simulator from CI for smoke or visual regression tests.
|
|
24
|
+
- Diagnosing a stuck simulator, missing target, or empty accessibility tree.
|
|
25
|
+
|
|
26
|
+
## Decision matrix — interaction surface
|
|
27
|
+
|
|
28
|
+
| Surface | Use when | Avoid when |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `xcrun simctl` | Boot/install/launch/screenshot/log capture; default for everything CLI-driven | Need accessibility tree or precise UI coordinates |
|
|
31
|
+
| `idb` (Facebook iOS Debug Bridge) | Accessibility-tree dumps, coordinate taps/swipes/text input, point-level inspection | Plain boot/launch tasks (simctl is lighter) |
|
|
32
|
+
| `xcodebuild` / `xcodebuild test` | Compile, sign, and run XCTest / XCUITest suites; CI integration | Ad-hoc scripted interaction (slow, heavyweight) |
|
|
33
|
+
| Direct UI Automation (XCUITest) | Native iOS app E2E with full Apple toolchain support | Cross-platform E2E (use Detox / Appium / Maestro — see `mobile-e2e-strategy`) |
|
|
34
|
+
|
|
35
|
+
**Rule of thumb:** start with `simctl`; reach for `idb` only when you
|
|
36
|
+
need accessibility-tree introspection or coordinate-level UI control.
|
|
37
|
+
|
|
38
|
+
## Authoritative upstream
|
|
39
|
+
|
|
40
|
+
This guideline inlines five reference modules **verbatim** from the
|
|
41
|
+
upstream `conorluddy/ios-simulator-skill` repository. The 21 Python
|
|
42
|
+
helper scripts that ship with the upstream skill (~8500 LOC, macOS-
|
|
43
|
+
and Xcode-bound) are **not forked** — script references inside the
|
|
44
|
+
modules below resolve against the upstream tree, not this suite.
|
|
45
|
+
|
|
46
|
+
- Upstream repo: `https://github.com/conorluddy/ios-simulator-skill`
|
|
47
|
+
- Pinned SHA: `3acd0717a1b571b1d051559c01ff230d6da28a05`
|
|
48
|
+
- Last checked: 2026-05-08
|
|
49
|
+
- Refresh trigger: quarterly review or sooner if any link 404s in CI.
|
|
50
|
+
|
|
51
|
+
When you need an upstream Python helper (`accessibility_audit.py`,
|
|
52
|
+
`visual_diff.py`, `app_state_capture.py`, `test_recorder`) clone the
|
|
53
|
+
upstream repo at the pinned SHA, run the helper from there, do **not**
|
|
54
|
+
copy it into a consumer project.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Module 1 — iOS Accessibility Checklist
|
|
59
|
+
|
|
60
|
+
_Verbatim from `references/accessibility_checklist.md` at the pinned SHA above._
|
|
61
|
+
|
|
62
|
+
### Critical Rules (Must Fix)
|
|
63
|
+
|
|
64
|
+
#### 1. Interactive elements need labels
|
|
65
|
+
**Check:** `accessibilityLabel != nil`
|
|
66
|
+
**Fix:** Add descriptive label
|
|
67
|
+
|
|
68
|
+
#### 2. Buttons need text
|
|
69
|
+
**Check:** `label || value != ""`
|
|
70
|
+
**Fix:** Set button title or accessibilityLabel
|
|
71
|
+
|
|
72
|
+
#### 3. Images need descriptions
|
|
73
|
+
**Check:** `isImage && accessibilityLabel`
|
|
74
|
+
**Fix:** Add alt text via accessibilityLabel
|
|
75
|
+
|
|
76
|
+
### Warnings (Should Fix)
|
|
77
|
+
|
|
78
|
+
#### 4. Complex controls need hints
|
|
79
|
+
**Check:** `accessibilityHint for custom controls`
|
|
80
|
+
**Fix:** Explain what happens on activation
|
|
81
|
+
|
|
82
|
+
#### 5. Grouped elements need containers
|
|
83
|
+
**Check:** `isAccessibilityElement on containers`
|
|
84
|
+
**Fix:** Group related elements
|
|
85
|
+
|
|
86
|
+
#### 6. Text fields need placeholders
|
|
87
|
+
**Check:** `placeholder || accessibilityLabel`
|
|
88
|
+
**Fix:** Add placeholder text
|
|
89
|
+
|
|
90
|
+
### Info (Nice to Have)
|
|
91
|
+
|
|
92
|
+
#### 7. Automation identifiers
|
|
93
|
+
**Check:** `accessibilityIdentifier != nil`
|
|
94
|
+
**Fix:** Add for UI testing
|
|
95
|
+
|
|
96
|
+
#### 8. Trait specification
|
|
97
|
+
**Check:** `accessibilityTraits set correctly`
|
|
98
|
+
**Fix:** Use .button, .link, .header appropriately
|
|
99
|
+
|
|
100
|
+
#### 9. Frame size adequate
|
|
101
|
+
**Check:** `frame.width >= 44 && frame.height >= 44`
|
|
102
|
+
**Fix:** Minimum touch target 44x44pt
|
|
103
|
+
|
|
104
|
+
### Quick Audit Command
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python scripts/accessibility_audit.py
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### iOS Code Fixes
|
|
111
|
+
|
|
112
|
+
```swift
|
|
113
|
+
// Label
|
|
114
|
+
button.accessibilityLabel = "Submit form"
|
|
115
|
+
|
|
116
|
+
// Hint
|
|
117
|
+
slider.accessibilityHint = "Adjusts volume"
|
|
118
|
+
|
|
119
|
+
// Identifier
|
|
120
|
+
view.accessibilityIdentifier = "login-button"
|
|
121
|
+
|
|
122
|
+
// Traits
|
|
123
|
+
label.accessibilityTraits = .header
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Module 2 — IDB Quick Reference
|
|
129
|
+
|
|
130
|
+
_Verbatim from `references/idb_quick.md` at the pinned SHA above._
|
|
131
|
+
|
|
132
|
+
### UI Automation Commands
|
|
133
|
+
|
|
134
|
+
#### ui describe-all
|
|
135
|
+
**Usage:** `idb ui describe-all --json --nested`
|
|
136
|
+
**Output:** Complete accessibility tree
|
|
137
|
+
**Key:** Foundation for accessibility auditing
|
|
138
|
+
|
|
139
|
+
#### ui tap
|
|
140
|
+
**Usage:** `idb ui tap <x> <y>`
|
|
141
|
+
**Output:** None (success) or error
|
|
142
|
+
|
|
143
|
+
#### ui swipe
|
|
144
|
+
**Usage:** `idb ui swipe <x1> <y1> <x2> <y2>`
|
|
145
|
+
**Output:** None (success) or error
|
|
146
|
+
|
|
147
|
+
#### ui text
|
|
148
|
+
**Usage:** `idb ui text "<text>"`
|
|
149
|
+
**Output:** None (success) or error
|
|
150
|
+
|
|
151
|
+
#### ui describe-point
|
|
152
|
+
**Usage:** `idb ui describe-point <x> <y> --json`
|
|
153
|
+
**Output:** Element at coordinates
|
|
154
|
+
|
|
155
|
+
### Other Essential Commands
|
|
156
|
+
|
|
157
|
+
#### list-targets
|
|
158
|
+
**Usage:** `idb list-targets`
|
|
159
|
+
**Output:** Available simulators with UDIDs
|
|
160
|
+
|
|
161
|
+
#### screenshot
|
|
162
|
+
**Usage:** `idb screenshot --udid <udid> output.png`
|
|
163
|
+
**Output:** PNG file saved
|
|
164
|
+
|
|
165
|
+
#### list-apps
|
|
166
|
+
**Usage:** `idb list-apps --udid <udid>`
|
|
167
|
+
**Output:** Installed apps with bundle IDs
|
|
168
|
+
|
|
169
|
+
### Common Patterns
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Get accessibility tree
|
|
173
|
+
idb ui describe-all --json --nested > tree.json
|
|
174
|
+
|
|
175
|
+
# Basic interaction
|
|
176
|
+
idb ui tap 200 400
|
|
177
|
+
idb ui text "username@example.com"
|
|
178
|
+
idb ui tap 200 500 # Submit button
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Troubleshooting
|
|
182
|
+
See Module 5 below.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Module 3 — simctl Quick Reference
|
|
187
|
+
|
|
188
|
+
_Verbatim from `references/simctl_quick.md` at the pinned SHA above._
|
|
189
|
+
|
|
190
|
+
### Essential Commands Only
|
|
191
|
+
|
|
192
|
+
#### list devices
|
|
193
|
+
**Usage:** `xcrun simctl list devices`
|
|
194
|
+
**Output:** Device list with UDIDs and states
|
|
195
|
+
**Key:** Use `booted` as UDID for current device
|
|
196
|
+
|
|
197
|
+
#### boot
|
|
198
|
+
**Usage:** `xcrun simctl boot <device-udid>`
|
|
199
|
+
**Output:** None (success) or error
|
|
200
|
+
|
|
201
|
+
#### launch
|
|
202
|
+
**Usage:** `xcrun simctl launch booted <bundle-id>`
|
|
203
|
+
**Output:** PID of launched app
|
|
204
|
+
|
|
205
|
+
#### install
|
|
206
|
+
**Usage:** `xcrun simctl install booted <app-path>`
|
|
207
|
+
**Output:** None (success) or error
|
|
208
|
+
|
|
209
|
+
#### io screenshot
|
|
210
|
+
**Usage:** `xcrun simctl io booted screenshot <file.png>`
|
|
211
|
+
**Output:** PNG file saved
|
|
212
|
+
**Options:** `--type=png|jpeg` (default: png)
|
|
213
|
+
|
|
214
|
+
#### io recordVideo
|
|
215
|
+
**Usage:** `xcrun simctl io booted recordVideo <file.mp4>`
|
|
216
|
+
**Output:** Video file (Ctrl+C to stop)
|
|
217
|
+
**Options:** `--codec=h264|hevc` (default: hevc)
|
|
218
|
+
|
|
219
|
+
#### get_app_container
|
|
220
|
+
**Usage:** `xcrun simctl get_app_container booted <bundle-id> data`
|
|
221
|
+
**Output:** Path to app's data directory
|
|
222
|
+
|
|
223
|
+
#### spawn log
|
|
224
|
+
**Usage:** `xcrun simctl spawn booted log stream --predicate 'process == "<app>"'`
|
|
225
|
+
**Output:** Live log stream
|
|
226
|
+
|
|
227
|
+
### Common Patterns
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Get booted device UDID
|
|
231
|
+
xcrun simctl list devices | grep Booted
|
|
232
|
+
|
|
233
|
+
# Quick app test
|
|
234
|
+
xcrun simctl boot <udid>
|
|
235
|
+
xcrun simctl install booted app.app
|
|
236
|
+
xcrun simctl launch booted com.example.app
|
|
237
|
+
xcrun simctl io booted screenshot test.png
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Troubleshooting
|
|
241
|
+
See Module 5 below.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Module 4 — Test Patterns
|
|
246
|
+
|
|
247
|
+
_Verbatim from `references/test_patterns.md` at the pinned SHA above._
|
|
248
|
+
|
|
249
|
+
### Smoke Test
|
|
250
|
+
```bash
|
|
251
|
+
xcrun simctl boot <udid>
|
|
252
|
+
xcrun simctl launch booted <bundle-id>
|
|
253
|
+
python scripts/accessibility_audit.py
|
|
254
|
+
xcrun simctl io booted screenshot smoke.png
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Visual Regression
|
|
258
|
+
```bash
|
|
259
|
+
# Baseline
|
|
260
|
+
xcrun simctl io booted screenshot baseline.png
|
|
261
|
+
|
|
262
|
+
# After changes
|
|
263
|
+
xcrun simctl io booted screenshot current.png
|
|
264
|
+
python scripts/visual_diff.py baseline.png current.png
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Full Accessibility Audit
|
|
268
|
+
```bash
|
|
269
|
+
# Each screen
|
|
270
|
+
for screen in home login settings; do
|
|
271
|
+
# Navigate to screen (app-specific)
|
|
272
|
+
python scripts/accessibility_audit.py --output $screen.json
|
|
273
|
+
done
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Bug Report Capture
|
|
277
|
+
```bash
|
|
278
|
+
python scripts/app_state_capture.py \
|
|
279
|
+
--app-bundle-id com.example.app \
|
|
280
|
+
--output bug-report/
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Multi-Device Test
|
|
284
|
+
```bash
|
|
285
|
+
for device in "iPhone 15" "iPad Pro"; do
|
|
286
|
+
udid=$(xcrun simctl create test-$device "$device")
|
|
287
|
+
xcrun simctl boot $udid
|
|
288
|
+
xcrun simctl install $udid app.app
|
|
289
|
+
xcrun simctl launch $udid com.example.app
|
|
290
|
+
xcrun simctl io $udid screenshot $device.png
|
|
291
|
+
xcrun simctl delete $udid
|
|
292
|
+
done
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Performance Baseline
|
|
296
|
+
```bash
|
|
297
|
+
# Capture initial state
|
|
298
|
+
xcrun simctl io booted screenshot perf-before.png
|
|
299
|
+
# Run performance test
|
|
300
|
+
xcrun simctl launch booted com.example.app
|
|
301
|
+
sleep 5
|
|
302
|
+
xcrun simctl io booted screenshot perf-after.png
|
|
303
|
+
python scripts/visual_diff.py perf-before.png perf-after.png
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Login Flow Test
|
|
307
|
+
```python
|
|
308
|
+
from scripts.test_recorder import TestRecorder
|
|
309
|
+
|
|
310
|
+
rec = TestRecorder("Login Test")
|
|
311
|
+
rec.step("Launch app")
|
|
312
|
+
# idb ui tap 200 400 # Login button
|
|
313
|
+
rec.step("Enter credentials")
|
|
314
|
+
# idb ui text "user@example.com"
|
|
315
|
+
rec.step("Submit")
|
|
316
|
+
# idb ui tap 200 500
|
|
317
|
+
rec.generate_report()
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Module 5 — Troubleshooting
|
|
323
|
+
|
|
324
|
+
_Verbatim from `references/troubleshooting.md` at the pinned SHA above._
|
|
325
|
+
|
|
326
|
+
### Problem → Solution Format
|
|
327
|
+
|
|
328
|
+
#### Simulator won't boot
|
|
329
|
+
**Fix:** `killall Simulator && xcrun simctl erase <udid>`
|
|
330
|
+
|
|
331
|
+
#### IDB not connecting
|
|
332
|
+
**Fix:** `idb kill && idb companion --boot-status-check`
|
|
333
|
+
|
|
334
|
+
#### App won't launch
|
|
335
|
+
**Fix:** `xcrun simctl terminate booted <bundle-id> && xcrun simctl launch booted <bundle-id>`
|
|
336
|
+
|
|
337
|
+
#### Screenshot fails
|
|
338
|
+
**Fix:** Ensure simulator booted: `xcrun simctl boot <udid>`
|
|
339
|
+
|
|
340
|
+
#### "No booted devices"
|
|
341
|
+
**Fix:** `open -a Simulator` or `xcrun simctl boot <udid>`
|
|
342
|
+
|
|
343
|
+
#### IDB "Target not found"
|
|
344
|
+
**Fix:** `idb list-targets` to verify UDID
|
|
345
|
+
|
|
346
|
+
#### Permission denied
|
|
347
|
+
**Fix:** `chmod +x scripts/*.sh`
|
|
348
|
+
|
|
349
|
+
#### Python module not found
|
|
350
|
+
**Fix:** `pip3 install pillow` (for visual_diff.py)
|
|
351
|
+
|
|
352
|
+
#### Accessibility tree empty
|
|
353
|
+
**Fix:** App must be in foreground: `xcrun simctl launch booted <bundle-id>`
|
|
354
|
+
|
|
355
|
+
#### Video recording hangs
|
|
356
|
+
**Fix:** Ctrl+C to stop recording, file saves on interrupt
|
|
357
|
+
|
|
358
|
+
#### Logs not showing
|
|
359
|
+
**Fix:** Use correct app name: `xcrun simctl spawn booted log stream --predicate 'process == "AppName"'`
|
|
360
|
+
|
|
361
|
+
#### Device storage full
|
|
362
|
+
**Fix:** `xcrun simctl erase <udid>` (warning: deletes all data)
|
|
363
|
+
|
|
364
|
+
### Quick Diagnostics
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
# Check simulator state
|
|
368
|
+
xcrun simctl list devices | grep Booted
|
|
369
|
+
|
|
370
|
+
# Verify IDB connection
|
|
371
|
+
idb list-targets
|
|
372
|
+
|
|
373
|
+
# Test basic interaction
|
|
374
|
+
xcrun simctl io booted screenshot test.png
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## Source attribution
|
|
378
|
+
|
|
379
|
+
Modules 1–5 above are reproduced verbatim from
|
|
380
|
+
`conorluddy/ios-simulator-skill` (MIT License) at SHA
|
|
381
|
+
`3acd0717a1b571b1d051559c01ff230d6da28a05`. Header levels were
|
|
382
|
+
demoted by one to integrate with this guideline's outline; module
|
|
383
|
+
content (text, code, command examples) is unchanged.
|
|
@@ -33,10 +33,14 @@ Size is a signal — not the goal.
|
|
|
33
33
|
- Acceptable: **< 100–120 lines**
|
|
34
34
|
- Hard limit: **< 200 lines**
|
|
35
35
|
|
|
36
|
-
Linter (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
Linter (structural model, 2026-05-08 — see
|
|
37
|
+
[`docs/contracts/linter-structural-model.md`](../../contracts/linter-structural-model.md)):
|
|
38
|
+
the long-rule warning fires only when the rule is **> 60 non-empty
|
|
39
|
+
lines AND density < 0.50 AND ships no Iron-Law block**. Rules whose
|
|
40
|
+
body is a verbatim ALL-CAPS imperative (`commit-policy`,
|
|
41
|
+
`ask-when-uncertain`, `direct-answers`) are auto-exempt — no
|
|
42
|
+
frontmatter flag required. The 200-line hard error stays
|
|
43
|
+
unconditional.
|
|
40
44
|
|
|
41
45
|
Reason:
|
|
42
46
|
- Loaded frequently
|
|
@@ -48,10 +52,11 @@ Reason:
|
|
|
48
52
|
## Skills
|
|
49
53
|
|
|
50
54
|
- Target: **300–900 words**
|
|
51
|
-
- Warning: **> 400 lines
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
- Warning: **> 400 lines AND (density < 0.60 OR ≥ 2 `## Procedure`
|
|
56
|
+
blocks)** — structural model, 2026-05-08
|
|
57
|
+
- Reference-rich skills with high density (`quality-tools` at 0.83,
|
|
58
|
+
catalogue-style skills) pass without splitting; the multi-procedure
|
|
59
|
+
trigger flags genuine cluster-split candidates regardless of size
|
|
55
60
|
|
|
56
61
|
Focus:
|
|
57
62
|
- scanability
|
|
@@ -64,10 +69,11 @@ Focus:
|
|
|
64
69
|
|
|
65
70
|
- Target: **200–600 words**
|
|
66
71
|
- Acceptable: **up to ~1000 words**
|
|
67
|
-
- Warning: **> 1000 words AND
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
- Warning: **> 1000 words AND no delegation signal AND density < 0.65**
|
|
73
|
+
— structural model, 2026-05-08. A delegation signal is either
|
|
74
|
+
frontmatter (`cluster:` / `routes_to:`) OR ≥ 3 markdown links to
|
|
75
|
+
other `.md` files. Well-factored orchestrators pass automatically;
|
|
76
|
+
inlined logic in a non-orchestrator command warns.
|
|
71
77
|
|
|
72
78
|
Commands orchestrate — not implement.
|
|
73
79
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure structural density across the artifact corpus.
|
|
3
|
+
|
|
4
|
+
Phase 1.1 of `agents/roadmaps/road-to-structural-linter-reform.md`.
|
|
5
|
+
|
|
6
|
+
Density score = structured_lines / total_lines, where structured_lines
|
|
7
|
+
sum lines inside fenced blocks + markdown-table rows + bullet lines +
|
|
8
|
+
numbered/ordered-list lines + section-heading lines. Higher = more
|
|
9
|
+
structured (catalogue, orchestrator, Iron-Law block); lower = prose-
|
|
10
|
+
dominant.
|
|
11
|
+
|
|
12
|
+
Companion signals collected per artifact (consumed by Phases 1.2-1.4):
|
|
13
|
+
|
|
14
|
+
- ``multi_workflow`` ≥ 2 ``## Procedure`` (or ``## Procedure: …``)
|
|
15
|
+
blocks in a skill — candidate for cluster split.
|
|
16
|
+
- ``delegation`` command frontmatter has ``cluster:`` or
|
|
17
|
+
``routes_to:``, or the body links to ≥ 3 other
|
|
18
|
+
commands/skills via ``](...md)``.
|
|
19
|
+
- ``iron_law_block`` ≥ 1 fenced block whose body is ≥ 60 % ALL-CAPS
|
|
20
|
+
across ≥ 3 non-empty lines.
|
|
21
|
+
|
|
22
|
+
Output:
|
|
23
|
+
- Default stdout: per-type distribution buckets + tail (lowest density).
|
|
24
|
+
- ``--json`` deterministic JSON of every artifact.
|
|
25
|
+
- ``--snapshot`` writes JSONL to ``agents/.density-snapshot.jsonl``.
|
|
26
|
+
|
|
27
|
+
Stdlib only; no network. Re-runnable.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Dict, List
|
|
37
|
+
|
|
38
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
39
|
+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
40
|
+
|
|
41
|
+
from skill_linter import ( # noqa: E402
|
|
42
|
+
detect_artifact_type,
|
|
43
|
+
extract_frontmatter,
|
|
44
|
+
gather_all_candidate_files,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
SNAPSHOT_FILE = REPO_ROOT / "agents" / ".density-snapshot.jsonl"
|
|
48
|
+
|
|
49
|
+
_TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
|
|
50
|
+
_BULLET = re.compile(r"^\s*[-*]\s+\S")
|
|
51
|
+
_NUMBERED = re.compile(r"^\s*\d+\.\s+\S")
|
|
52
|
+
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
|
|
53
|
+
_PROCEDURE = re.compile(r"^##\s+Procedure(\s*:.*)?\s*$", re.MULTILINE)
|
|
54
|
+
_LINK_MD = re.compile(r"\]\([^)]+\.md[^)]*\)")
|
|
55
|
+
_FRONTMATTER_KEY = re.compile(r"^(cluster|routes_to)\s*:", re.MULTILINE)
|
|
56
|
+
_ALLCAPS_LINE = re.compile(r"[A-Z]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _classify_lines(text: str) -> Dict[str, int]:
|
|
60
|
+
"""Bucket every non-blank line into one structural category."""
|
|
61
|
+
inside_fence = False
|
|
62
|
+
counts = {
|
|
63
|
+
"total": 0,
|
|
64
|
+
"fenced": 0,
|
|
65
|
+
"table": 0,
|
|
66
|
+
"bullet": 0,
|
|
67
|
+
"numbered": 0,
|
|
68
|
+
"heading": 0,
|
|
69
|
+
"prose": 0,
|
|
70
|
+
}
|
|
71
|
+
for raw in text.splitlines():
|
|
72
|
+
stripped = raw.strip()
|
|
73
|
+
if stripped.startswith("```"):
|
|
74
|
+
inside_fence = not inside_fence
|
|
75
|
+
counts["total"] += 1
|
|
76
|
+
counts["fenced"] += 1
|
|
77
|
+
continue
|
|
78
|
+
if not stripped:
|
|
79
|
+
continue
|
|
80
|
+
counts["total"] += 1
|
|
81
|
+
if inside_fence:
|
|
82
|
+
counts["fenced"] += 1
|
|
83
|
+
elif _TABLE_ROW.match(raw):
|
|
84
|
+
counts["table"] += 1
|
|
85
|
+
elif _HEADING.match(raw):
|
|
86
|
+
counts["heading"] += 1
|
|
87
|
+
elif _BULLET.match(raw):
|
|
88
|
+
counts["bullet"] += 1
|
|
89
|
+
elif _NUMBERED.match(raw):
|
|
90
|
+
counts["numbered"] += 1
|
|
91
|
+
else:
|
|
92
|
+
counts["prose"] += 1
|
|
93
|
+
return counts
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _detect_iron_law_blocks(text: str) -> int:
|
|
97
|
+
"""Count fenced blocks that look like verbatim Iron-Law imperatives.
|
|
98
|
+
|
|
99
|
+
Heuristic: fenced block with ≥ 1 non-empty line whose alphabetical
|
|
100
|
+
body is ≥ 60 % uppercase AND has ≥ 30 letters total (filters single
|
|
101
|
+
short ALL-CAPS markers like ``OK``). Also matches blockquote-style
|
|
102
|
+
Iron Laws (``> NEVER COMMIT``).
|
|
103
|
+
"""
|
|
104
|
+
blocks = 0
|
|
105
|
+
inside = False
|
|
106
|
+
body: list[str] = []
|
|
107
|
+
for raw in text.splitlines():
|
|
108
|
+
if raw.strip().startswith("```"):
|
|
109
|
+
if inside and body:
|
|
110
|
+
non_empty = [b for b in body if b.strip()]
|
|
111
|
+
letters = "".join(non_empty)
|
|
112
|
+
upper = sum(1 for c in letters if c.isalpha() and c.isupper())
|
|
113
|
+
total = sum(1 for c in letters if c.isalpha())
|
|
114
|
+
if total >= 30 and upper / total >= 0.6 and non_empty:
|
|
115
|
+
blocks += 1
|
|
116
|
+
inside = not inside
|
|
117
|
+
body = []
|
|
118
|
+
continue
|
|
119
|
+
if inside:
|
|
120
|
+
body.append(raw)
|
|
121
|
+
return blocks
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _count_procedures(text: str) -> int:
|
|
125
|
+
return len(_PROCEDURE.findall(text))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _delegation_signal(text: str, frontmatter: str | None) -> Dict[str, Any]:
|
|
129
|
+
fm_keys = bool(frontmatter and _FRONTMATTER_KEY.search(frontmatter))
|
|
130
|
+
md_links = len(_LINK_MD.findall(text))
|
|
131
|
+
return {"frontmatter_routes": fm_keys, "md_links": md_links,
|
|
132
|
+
"has_signal": fm_keys or md_links >= 3}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def measure(path: Path) -> Dict[str, Any]:
|
|
136
|
+
text = path.read_text(encoding="utf-8")
|
|
137
|
+
rel = path.relative_to(REPO_ROOT) if path.is_absolute() else path
|
|
138
|
+
artifact_type = detect_artifact_type(rel, text)
|
|
139
|
+
frontmatter = extract_frontmatter(text)
|
|
140
|
+
counts = _classify_lines(text)
|
|
141
|
+
structured = counts["fenced"] + counts["table"] + counts["bullet"] + \
|
|
142
|
+
counts["numbered"] + counts["heading"]
|
|
143
|
+
density = structured / counts["total"] if counts["total"] else 0.0
|
|
144
|
+
return {
|
|
145
|
+
"file": str(rel),
|
|
146
|
+
"type": artifact_type,
|
|
147
|
+
"lines": counts["total"],
|
|
148
|
+
"words": len(text.split()),
|
|
149
|
+
"density": round(density, 3),
|
|
150
|
+
"fenced": counts["fenced"],
|
|
151
|
+
"table": counts["table"],
|
|
152
|
+
"bullet": counts["bullet"],
|
|
153
|
+
"numbered": counts["numbered"],
|
|
154
|
+
"heading": counts["heading"],
|
|
155
|
+
"prose": counts["prose"],
|
|
156
|
+
"iron_law_blocks": _detect_iron_law_blocks(text),
|
|
157
|
+
"procedures": _count_procedures(text),
|
|
158
|
+
"delegation": _delegation_signal(text, frontmatter),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def collect() -> List[Dict[str, Any]]:
|
|
163
|
+
paths = gather_all_candidate_files(REPO_ROOT)
|
|
164
|
+
return [measure(p) for p in paths]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _bucketize(values: List[float]) -> Dict[str, int]:
|
|
168
|
+
buckets = {"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0,
|
|
169
|
+
"0.6-0.8": 0, "0.8-1.0": 0}
|
|
170
|
+
for v in values:
|
|
171
|
+
if v < 0.2:
|
|
172
|
+
buckets["0.0-0.2"] += 1
|
|
173
|
+
elif v < 0.4:
|
|
174
|
+
buckets["0.2-0.4"] += 1
|
|
175
|
+
elif v < 0.6:
|
|
176
|
+
buckets["0.4-0.6"] += 1
|
|
177
|
+
elif v < 0.8:
|
|
178
|
+
buckets["0.6-0.8"] += 1
|
|
179
|
+
else:
|
|
180
|
+
buckets["0.8-1.0"] += 1
|
|
181
|
+
return buckets
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def report(results: List[Dict[str, Any]]) -> str:
|
|
185
|
+
by_type: Dict[str, List[Dict[str, Any]]] = {}
|
|
186
|
+
for r in results:
|
|
187
|
+
by_type.setdefault(r["type"], []).append(r)
|
|
188
|
+
lines: List[str] = ["# Structural Density Snapshot", "",
|
|
189
|
+
f"Total artifacts: {len(results)}", ""]
|
|
190
|
+
for t in sorted(by_type):
|
|
191
|
+
rows = by_type[t]
|
|
192
|
+
densities = [r["density"] for r in rows]
|
|
193
|
+
avg = sum(densities) / len(densities) if densities else 0.0
|
|
194
|
+
med = sorted(densities)[len(densities) // 2] if densities else 0.0
|
|
195
|
+
buckets = _bucketize(densities)
|
|
196
|
+
lines.append(f"## {t} ({len(rows)} artifacts)")
|
|
197
|
+
lines.append(f"avg density={avg:.2f} median={med:.2f}")
|
|
198
|
+
lines.append("buckets " + " ".join(
|
|
199
|
+
f"[{k}]={v}" for k, v in buckets.items()))
|
|
200
|
+
tail = sorted(rows, key=lambda r: r["density"])[:5]
|
|
201
|
+
lines.append("lowest density:")
|
|
202
|
+
for r in tail:
|
|
203
|
+
lines.append(f" {r['density']:.2f} {r['lines']:>4}L "
|
|
204
|
+
f"proc={r['procedures']} "
|
|
205
|
+
f"iron={r['iron_law_blocks']} "
|
|
206
|
+
f"deleg={int(r['delegation']['has_signal'])} "
|
|
207
|
+
f"{r['file']}")
|
|
208
|
+
lines.append("")
|
|
209
|
+
return "\n".join(lines)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main() -> int:
|
|
213
|
+
p = argparse.ArgumentParser()
|
|
214
|
+
p.add_argument("--json", action="store_true")
|
|
215
|
+
p.add_argument("--snapshot", action="store_true",
|
|
216
|
+
help=f"write JSONL to {SNAPSHOT_FILE.relative_to(REPO_ROOT)}")
|
|
217
|
+
args = p.parse_args()
|
|
218
|
+
results = collect()
|
|
219
|
+
if args.snapshot:
|
|
220
|
+
SNAPSHOT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
with SNAPSHOT_FILE.open("w", encoding="utf-8") as fh:
|
|
222
|
+
for r in sorted(results, key=lambda x: x["file"]):
|
|
223
|
+
fh.write(json.dumps(r, sort_keys=True) + "\n")
|
|
224
|
+
if args.json:
|
|
225
|
+
print(json.dumps(results, sort_keys=True, indent=2))
|
|
226
|
+
else:
|
|
227
|
+
print(report(results))
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
raise SystemExit(main())
|