qualia-framework 4.0.0 → 4.0.5

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 (47) hide show
  1. package/CLAUDE.md +23 -11
  2. package/agents/plan-checker.md +1 -1
  3. package/agents/roadmapper.md +10 -5
  4. package/bin/cli.js +139 -17
  5. package/bin/install.js +47 -47
  6. package/bin/qualia-ui.js +2 -2
  7. package/bin/state.js +126 -9
  8. package/bin/statusline.js +63 -38
  9. package/docs/erp-contract.md +49 -2
  10. package/guide.md +1 -1
  11. package/hooks/migration-guard.js +23 -9
  12. package/hooks/pre-compact.js +39 -11
  13. package/hooks/pre-deploy-gate.js +3 -4
  14. package/hooks/pre-push.js +6 -3
  15. package/hooks/session-start.js +8 -8
  16. package/package.json +1 -1
  17. package/rules/frontend.md +5 -13
  18. package/skills/qualia/SKILL.md +5 -0
  19. package/skills/qualia-build/SKILL.md +10 -0
  20. package/skills/qualia-debug/SKILL.md +6 -0
  21. package/skills/qualia-design/SKILL.md +9 -1
  22. package/skills/qualia-discuss/SKILL.md +6 -0
  23. package/skills/qualia-handoff/SKILL.md +5 -0
  24. package/skills/qualia-help/SKILL.md +18 -4
  25. package/skills/qualia-idk/SKILL.md +6 -0
  26. package/skills/qualia-learn/SKILL.md +6 -0
  27. package/skills/qualia-map/SKILL.md +7 -0
  28. package/skills/qualia-milestone/SKILL.md +6 -0
  29. package/skills/qualia-new/SKILL.md +31 -4
  30. package/skills/qualia-optimize/SKILL.md +8 -0
  31. package/skills/qualia-pause/SKILL.md +5 -0
  32. package/skills/qualia-plan/SKILL.md +11 -1
  33. package/skills/qualia-polish/SKILL.md +8 -0
  34. package/skills/qualia-quick/SKILL.md +7 -0
  35. package/skills/qualia-report/SKILL.md +146 -60
  36. package/skills/qualia-research/SKILL.md +7 -0
  37. package/skills/qualia-resume/SKILL.md +3 -0
  38. package/skills/qualia-review/SKILL.md +7 -0
  39. package/skills/qualia-ship/SKILL.md +5 -0
  40. package/skills/qualia-skill-new/SKILL.md +6 -0
  41. package/skills/qualia-task/SKILL.md +8 -1
  42. package/skills/qualia-test/SKILL.md +7 -0
  43. package/skills/qualia-verify/SKILL.md +8 -0
  44. package/templates/help.html +4 -4
  45. package/templates/tracking.json +1 -0
  46. package/tests/hooks.test.sh +5 -5
  47. package/tests/runner.js +310 -3
@@ -1,12 +1,22 @@
1
1
  ---
2
2
  name: qualia-report
3
3
  description: "Generate session report and commit to repo. Mandatory before clock-out."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
4
9
  ---
5
10
 
6
11
  # /qualia-report — Session Report
7
12
 
8
13
  Generate a concise report of what was done. Committed to git and uploaded to the ERP for clock-out.
9
14
 
15
+ ## Flags
16
+
17
+ - `/qualia-report` — normal flow (generate, commit, push, upload to ERP)
18
+ - `/qualia-report --dry-run` — generate + show payload, SKIP upload and SKIP commit. Useful for debugging or previewing before a real clock-out.
19
+
10
20
  ## Process
11
21
 
12
22
  ```bash
@@ -64,16 +74,33 @@ None. / - {blocker}
64
74
  {list from git log}
65
75
  ```
66
76
 
67
- ### 4. Commit and Push
77
+ ### 4. Obtain Client Report ID (QS-REPORT-NN)
78
+
79
+ Each session report gets a stable, sequential client-side identifier that travels with the report all the way to the ERP. The sequence is per-project, persisted in `tracking.json.report_seq`.
68
80
 
69
81
  ```bash
70
- mkdir -p .planning/reports
71
- git add .planning/reports/report-{date}.md
72
- git commit -m "report: session {YYYY-MM-DD}"
73
- git push
82
+ # --dry-run: peek without incrementing
83
+ if [ "$DRY_RUN" = "true" ]; then
84
+ CLIENT_REPORT_ID=$(node ~/.claude/bin/state.js next-report-id --peek 2>/dev/null | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).report_id||'')")
85
+ else
86
+ CLIENT_REPORT_ID=$(node ~/.claude/bin/state.js next-report-id 2>/dev/null | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).report_id||'')")
87
+ fi
74
88
  ```
75
89
 
76
- ### 5. Upload to ERP (if enabled)
90
+ Example: first report on a fresh project → `QS-REPORT-01`. Next → `QS-REPORT-02`. Etc.
91
+
92
+ ### 5. Commit and Push (SKIP on --dry-run)
93
+
94
+ ```bash
95
+ if [ "$DRY_RUN" != "true" ]; then
96
+ mkdir -p .planning/reports
97
+ git add .planning/reports/report-{date}.md .planning/tracking.json
98
+ git commit -m "report: {CLIENT_REPORT_ID} session {YYYY-MM-DD}"
99
+ git push
100
+ fi
101
+ ```
102
+
103
+ ### 6. Upload to ERP (SKIP on --dry-run)
77
104
 
78
105
  Read `~/.claude/.qualia-config.json` and check the `erp` object:
79
106
  - If `erp.enabled` is `false`, skip this step and print: "ERP upload skipped (disabled in config)."
@@ -89,67 +116,126 @@ REPORT_FILE=".planning/reports/report-{date}.md"
89
116
  SUBMITTED_BY=$(git config user.name)
90
117
  SUBMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
91
118
 
92
- # Only upload if ERP is enabled
119
+ # Build structured JSON payload from tracking.json (matches ERP contract /api/v1/reports)
120
+ # v4: include milestone_name, milestones[], team_id, project_id, git_remote,
121
+ # session_started_at, last_pushed_at, build_count, deploy_count — the ERP
122
+ # uses these to render the project tree (milestone → phases → unphased) correctly.
123
+ # v4.0.4: client_report_id carries the QS-REPORT-NN identifier.
124
+ PAYLOAD=$(node -e "
125
+ const fs = require('fs');
126
+ const t = JSON.parse(fs.readFileSync('.planning/tracking.json', 'utf8'));
127
+ const notes = fs.readFileSync('$REPORT_FILE', 'utf8').substring(0, 60000);
128
+ const commits = [];
129
+ try {
130
+ const { spawnSync } = require('child_process');
131
+ const r = spawnSync('git', ['log', '--oneline', '--since=8 hours ago', '--format=%h'], { encoding: 'utf8', timeout: 3000 });
132
+ if (r.stdout) commits.push(...r.stdout.trim().split('\n').filter(Boolean));
133
+ } catch {}
134
+ console.log(JSON.stringify({
135
+ project: t.project || require('path').basename(process.cwd()),
136
+ project_id: t.project_id || '',
137
+ team_id: t.team_id || '',
138
+ git_remote: t.git_remote || '',
139
+ client: t.client || '',
140
+ client_report_id: '$CLIENT_REPORT_ID',
141
+ milestone: t.milestone || 1,
142
+ milestone_name: t.milestone_name || '',
143
+ milestones: Array.isArray(t.milestones) ? t.milestones : [],
144
+ phase: t.phase,
145
+ phase_name: t.phase_name,
146
+ total_phases: t.total_phases,
147
+ status: t.status,
148
+ tasks_done: t.tasks_done || 0,
149
+ tasks_total: t.tasks_total || 0,
150
+ verification: t.verification || 'pending',
151
+ gap_cycles: (t.gap_cycles || {})[String(t.phase)] || 0,
152
+ build_count: t.build_count || 0,
153
+ deploy_count: t.deploy_count || 0,
154
+ deployed_url: t.deployed_url || '',
155
+ session_started_at: t.session_started_at || '',
156
+ last_pushed_at: t.last_pushed_at || '',
157
+ lifetime: t.lifetime || {},
158
+ commits: commits,
159
+ notes: notes,
160
+ submitted_by: '$SUBMITTED_BY',
161
+ submitted_at: '$SUBMITTED_AT'
162
+ }));
163
+ ")
164
+
165
+ # --dry-run: print payload and stop (no POST, no commit, no increment already handled in step 4)
166
+ if [ "$DRY_RUN" = "true" ]; then
167
+ echo "--- DRY RUN · payload ---"
168
+ echo "$PAYLOAD" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(JSON.stringify(d,null,2))"
169
+ echo "--- DRY RUN · would POST to: $ERP_URL/api/v1/reports ---"
170
+ echo "--- DRY RUN · client_report_id would be: $CLIENT_REPORT_ID ---"
171
+ exit 0
172
+ fi
173
+
174
+ # Real upload — 3 attempts with exponential backoff (1s, 3s, 9s).
175
+ # The local report file is already committed, so a failed upload doesn't
176
+ # lose data — it just leaves the ERP view stale until the next push or
177
+ # manual retry.
93
178
  if [ "$ERP_ENABLED" = "true" ]; then
94
- # Build structured JSON payload from tracking.json (matches ERP contract /api/v1/reports)
95
- # v4: include milestone_name, milestones[], team_id, project_id, git_remote,
96
- # session_started_at, last_pushed_at, build_count, deploy_count — the ERP
97
- # uses these to render the project tree (milestone → phases → unphased) correctly.
98
- PAYLOAD=$(node -e "
99
- const fs = require('fs');
100
- const t = JSON.parse(fs.readFileSync('.planning/tracking.json', 'utf8'));
101
- const notes = fs.readFileSync('$REPORT_FILE', 'utf8').substring(0, 60000);
102
- const commits = [];
103
- try {
104
- const { spawnSync } = require('child_process');
105
- const r = spawnSync('git', ['log', '--oneline', '--since=8 hours ago', '--format=%h'], { encoding: 'utf8', timeout: 3000 });
106
- if (r.stdout) commits.push(...r.stdout.trim().split('\n').filter(Boolean));
107
- } catch {}
108
- console.log(JSON.stringify({
109
- project: t.project || require('path').basename(process.cwd()),
110
- project_id: t.project_id || '',
111
- team_id: t.team_id || '',
112
- git_remote: t.git_remote || '',
113
- client: t.client || '',
114
- milestone: t.milestone || 1,
115
- milestone_name: t.milestone_name || '',
116
- milestones: Array.isArray(t.milestones) ? t.milestones : [],
117
- phase: t.phase,
118
- phase_name: t.phase_name,
119
- total_phases: t.total_phases,
120
- status: t.status,
121
- tasks_done: t.tasks_done || 0,
122
- tasks_total: t.tasks_total || 0,
123
- verification: t.verification || 'pending',
124
- gap_cycles: (t.gap_cycles || {})[String(t.phase)] || 0,
125
- build_count: t.build_count || 0,
126
- deploy_count: t.deploy_count || 0,
127
- deployed_url: t.deployed_url || '',
128
- session_started_at: t.session_started_at || '',
129
- last_pushed_at: t.last_pushed_at || '',
130
- lifetime: t.lifetime || {},
131
- commits: commits,
132
- notes: notes,
133
- submitted_by: '$SUBMITTED_BY',
134
- submitted_at: '$SUBMITTED_AT'
135
- }));
136
- ")
137
-
138
- curl -s -X POST "$ERP_URL/api/v1/reports" \
139
- -H "Authorization: Bearer $API_KEY" \
140
- -H "Content-Type: application/json" \
141
- -d "$PAYLOAD"
179
+ MAX_ATTEMPTS=3
180
+ ATTEMPT=1
181
+ SUCCESS=false
182
+ while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
183
+ RESPONSE=$(curl -sS -X POST "$ERP_URL/api/v1/reports" \
184
+ -H "Authorization: Bearer $API_KEY" \
185
+ -H "Content-Type: application/json" \
186
+ -d "$PAYLOAD" \
187
+ --max-time 10 \
188
+ -w "\n__HTTP__%{http_code}" 2>&1)
189
+ HTTP_CODE=$(echo "$RESPONSE" | grep -o "__HTTP__[0-9]*" | sed 's/__HTTP__//')
190
+ BODY=$(echo "$RESPONSE" | sed 's/__HTTP__[0-9]*//g')
191
+
192
+ if [ "$HTTP_CODE" = "200" ]; then
193
+ SUCCESS=true
194
+ # Parse and display the ERP-returned report_id alongside our local QS-REPORT-NN
195
+ ERP_REPORT_ID=$(echo "$BODY" | node -e "try{const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(d.report_id||'')}catch{}")
196
+ node ~/.claude/bin/qualia-ui.js ok "Uploaded as $CLIENT_REPORT_ID (ERP: ${ERP_REPORT_ID:-none})"
197
+ break
198
+ fi
199
+
200
+ # 401 / 422 are permanent failures — no retry.
201
+ if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
202
+ node ~/.claude/bin/qualia-ui.js warn "ERP rejected report (HTTP $HTTP_CODE). Ask Fawzi."
203
+ echo "$BODY" | head -3
204
+ break
205
+ fi
206
+
207
+ # Transient failure — back off and retry.
208
+ if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
209
+ SLEEP=$(( 1 * 3 ** (ATTEMPT - 1) ))
210
+ node ~/.claude/bin/qualia-ui.js warn "ERP upload attempt $ATTEMPT failed (HTTP ${HTTP_CODE:-timeout}), retrying in ${SLEEP}s..."
211
+ sleep $SLEEP
212
+ fi
213
+ ATTEMPT=$(( ATTEMPT + 1 ))
214
+ done
215
+
216
+ if [ "$SUCCESS" != "true" ]; then
217
+ node ~/.claude/bin/qualia-ui.js warn "ERP upload failed after $MAX_ATTEMPTS attempts. $CLIENT_REPORT_ID is committed locally; it will NOT appear in the ERP until you retry with 'curl' or re-run /qualia-report."
218
+ fi
219
+ fi
220
+
221
+ if [ "$ERP_ENABLED" != "true" ]; then
222
+ node ~/.claude/bin/qualia-ui.js info "ERP upload skipped (disabled in config). Report committed locally as $CLIENT_REPORT_ID."
142
223
  fi
143
224
  ```
144
225
 
145
- If the upload succeeds, print: "Report uploaded to ERP. You can now clock out."
146
- If it fails (no API key, network error), print the error and tell the employee to ask Fawzi.
147
- If ERP is disabled, print: "ERP upload skipped (disabled in config)."
226
+ Summary rules:
227
+ - **Upload succeeds:** print "Uploaded as QS-REPORT-NN (ERP: {uuid})". Employee can clock out.
228
+ - **401/422:** no retry. Print the error, tell the employee to ask Fawzi.
229
+ - **Transient (timeout, 5xx, network):** retry 3x with 1s/3s/9s backoff.
230
+ - **All retries fail:** tell employee the report is committed locally, ERP will be stale until retry.
231
+ - **ERP disabled:** skip silently with a note, local commit still happens.
148
232
 
149
- ### 6. Update State
233
+ ### 7. Update State (SKIP on --dry-run)
150
234
 
151
235
  ```bash
152
- node ~/.claude/bin/state.js transition --to activity --notes "Session report generated"
236
+ if [ "$DRY_RUN" != "true" ]; then
237
+ node ~/.claude/bin/state.js transition --to activity --notes "Session report $CLIENT_REPORT_ID generated"
238
+ fi
153
239
  ```
154
240
 
155
241
  Do NOT manually edit STATE.md or tracking.json — state.js handles both.
@@ -1,6 +1,13 @@
1
1
  ---
2
2
  name: qualia-research
3
3
  description: "Deep-research a niche domain or library BEFORE planning a specific phase. Spawns the researcher agent with Context7/WebFetch access. Writes to .planning/phase-{N}-research.md."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Agent
9
+ - WebFetch
10
+ - WebSearch
4
11
  ---
5
12
 
6
13
  # /qualia-research — Per-Phase Deep Research
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  name: qualia-resume
3
3
  description: "Restore context from a previous session. Reads .continue-here.md or STATE.md, summarizes where you left off, routes to next action. Trigger on 'resume', 'continue', 'pick up where I left off', 'what was I doing'."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
4
7
  ---
5
8
 
6
9
  # /qualia-resume — Resume Work
@@ -1,6 +1,13 @@
1
1
  ---
2
2
  name: qualia-review
3
3
  description: "Production audit with scored diagnostics. Runs real commands, scores findings by severity. Trigger on 'review', 'audit', 'code review', 'security check', 'production check'."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Grep
9
+ - Glob
10
+ - Agent
4
11
  ---
5
12
 
6
13
  # /qualia-review — Production Audit
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  name: qualia-ship
3
3
  description: "Deploy to production — quality gates, commit, push, deploy, verify. Use when ready to go live."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
4
9
  ---
5
10
 
6
11
  # /qualia-ship — Deploy
@@ -1,6 +1,12 @@
1
1
  ---
2
2
  name: qualia-skill-new
3
3
  description: "Author a new Qualia skill or agent. Use when the user says 'create a new skill', 'add a skill', 'I want to build a skill', 'make this a reusable command', 'turn this into a skill'. Generates the SKILL.md, registers it in the right location, and optionally ships to the framework repo."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
9
+ - AskUserQuestion
4
10
  ---
5
11
 
6
12
  # /qualia-skill-new — Author a New Skill
@@ -1,6 +1,13 @@
1
1
  ---
2
2
  name: qualia-task
3
3
  description: "Build a single task — more structured than /qualia-quick, lighter than /qualia-build. Spawns a fresh builder agent for one focused task."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
9
+ - Agent
10
+ - AskUserQuestion
4
11
  ---
5
12
 
6
13
  # /qualia-task — Single Task Builder
@@ -61,7 +68,7 @@ Agent(subagent_type: "qualia-builder")
61
68
 
62
69
  Task: {task description}
63
70
  Files: {files to create/modify}
64
- Done when: {completion criteria}
71
+ Acceptance Criteria: {observable completion criteria, 1-3 bullet points}
65
72
 
66
73
  Context: Read PROJECT.md if it exists. Follow all rules (security, frontend, deployment).
67
74
  ```
@@ -1,6 +1,13 @@
1
1
  ---
2
2
  name: qualia-test
3
3
  description: "Generate or run tests for client projects. Trigger on 'write tests', 'add tests', 'test this', 'run tests', 'test coverage', 'need tests for'."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
9
+ - Grep
10
+ - Glob
4
11
  ---
5
12
 
6
13
  # /qualia-test — Test Generator
@@ -1,6 +1,14 @@
1
1
  ---
2
2
  name: qualia-verify
3
3
  description: "Goal-backward verification — checks if the phase ACTUALLY works, not just if tasks completed. Spawns verifier agent."
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
9
+ - Grep
10
+ - Glob
11
+ - Agent
4
12
  ---
5
13
 
6
14
  # /qualia-verify — Verify a Phase
@@ -291,13 +291,13 @@
291
291
  </head>
292
292
  <body>
293
293
 
294
- <div class="version-pill">v3.6.0</div>
294
+ <div class="version-pill">{{VERSION}}</div>
295
295
 
296
296
  <div class="header">
297
297
  <div class="header-content">
298
298
  <h1><span>Qualia</span> Framework</h1>
299
299
  <p>Plan, build, verify, ship. The AI-powered workflow for Qualia Solutions.</p>
300
- <div class="version">v3.6.0 &middot; 26 skills</div>
300
+ <div class="version">{{VERSION}} &middot; 26 skills</div>
301
301
  </div>
302
302
  </div>
303
303
 
@@ -430,7 +430,7 @@
430
430
  <div class="cmd"><span class="cmd-name">qualia-framework install</span><span class="cmd-desc">Install or reinstall the framework.</span></div>
431
431
  <div class="cmd"><span class="cmd-name">qualia-framework update</span><span class="cmd-desc">Update to the latest version.</span></div>
432
432
  <div class="cmd"><span class="cmd-name">qualia-framework version</span><span class="cmd-desc">Show installed version + check for updates.</span></div>
433
- <div class="cmd"><span class="cmd-name">qualia-framework migrate</span><span class="cmd-desc">Upgrade v2 settings to v3.</span></div>
433
+ <div class="cmd"><span class="cmd-name">qualia-framework migrate</span><span class="cmd-desc">Upgrade legacy settings.json to the current hook layout.</span></div>
434
434
  <div class="cmd"><span class="cmd-name">qualia-framework analytics</span><span class="cmd-desc">Hook telemetry, verification pass rates, gap cycles.</span></div>
435
435
  <div class="cmd"><span class="cmd-name">qualia-framework team</span><span class="cmd-desc">List, add, or remove team members.</span></div>
436
436
  <div class="cmd"><span class="cmd-name">qualia-framework traces</span><span class="cmd-desc">View recent hook activity.</span></div>
@@ -536,7 +536,7 @@
536
536
  <div class="footer">
537
537
  <strong>Welcome to the future with Qualia.</strong><br>
538
538
  Qualia Solutions &mdash; Nicosia, Cyprus
539
- <span class="footer-version">qualia-framework v3.6.0 &middot; 26 skills</span>
539
+ <span class="footer-version">qualia-framework {{VERSION}} &middot; 26 skills</span>
540
540
  </div>
541
541
 
542
542
  </body>
@@ -26,6 +26,7 @@
26
26
  "build_count": 0,
27
27
  "deploy_count": 0,
28
28
  "deployed_url": "",
29
+ "report_seq": 0,
29
30
  "notes": "",
30
31
  "submitted_by": "",
31
32
  "lifetime": {
@@ -218,25 +218,25 @@ export default function P(){return null}
218
218
  EOF
219
219
  OUT=$(cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" 2>&1)
220
220
  RC=$?
221
- if [ "$RC" -eq 1 ] \
221
+ if [ "$RC" -eq 2 ] \
222
222
  && echo "$OUT" | grep -q "BLOCKED" \
223
223
  && echo "$OUT" | grep -q "service_role"; then
224
- echo " ✓ service_role leak in app/ → blocked with diagnostic"
224
+ echo " ✓ service_role leak in app/ → blocked with diagnostic (exit 2)"
225
225
  PASS=$((PASS + 1))
226
226
  else
227
- echo " ✗ service_role leak in app/ → blocked (exit=$RC)"
227
+ echo " ✗ service_role leak in app/ → blocked (exit=$RC, expected 2)"
228
228
  FAIL=$((FAIL + 1))
229
229
  fi
230
230
  rm -rf "$TMP"
231
231
 
232
- # service_role leak in components/ → BLOCKED
232
+ # service_role leak in components/ → BLOCKED (exit 2 per PreToolUse contract)
233
233
  TMP=$(mktemp -d)
234
234
  mkdir -p "$TMP/components"
235
235
  cat > "$TMP/components/Widget.tsx" <<'EOF'
236
236
  const key = "service_role_literal_leak";
237
237
  EOF
238
238
  (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
239
- assert_exit "service_role in components/ → blocked" 1 $?
239
+ assert_exit "service_role in components/ → blocked (exit 2)" 2 $?
240
240
  rm -rf "$TMP"
241
241
 
242
242
  # service_role in a *.server.ts file → allowed (skip convention)