qualia-framework 5.8.0 → 5.9.1

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.
@@ -168,6 +168,17 @@ REPORT_FILE=".planning/reports/report-{date}.md"
168
168
  SUBMITTED_BY=$(git config user.name || echo "unknown")
169
169
  SUBMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
170
170
 
171
+ # Idempotency key — deterministic per (client_report_id, submitted_at). A retry
172
+ # of the same shift report carries the same key, so the ERP can dedupe at the
173
+ # header level in addition to the UPSERT on (project_id, client_report_id).
174
+ IDEMPOTENCY_KEY=$(node -e "
175
+ const c=require('crypto');
176
+ const seed='$CLIENT_REPORT_ID|$SUBMITTED_AT|'+require('path').basename(process.cwd());
177
+ // RFC 4122 v5-style: deterministic UUID from sha1 of the seed
178
+ const h=c.createHash('sha1').update(seed).digest('hex');
179
+ console.log([h.slice(0,8),h.slice(8,12),'5'+h.slice(13,16),'8'+h.slice(17,20),h.slice(20,32)].join('-'));
180
+ ")
181
+
171
182
  # Guard: API key required for upload (otherwise curl posts an empty bearer)
172
183
  if [ "$ERP_ENABLED" = "true" ] && [ -z "$API_KEY" ] && [ "$DRY_RUN" != "true" ]; then
173
184
  node ~/.claude/bin/qualia-ui.js warn "ERP API key missing (~/.claude/.erp-api-key). Run: qualia-framework set-erp-key <key>"
@@ -189,6 +200,17 @@ PAYLOAD=$(
189
200
  const commits=[];try{const r=spawnSync('git',['log','--oneline','--since=8 hours ago','--format=%h'],{encoding:'utf8',timeout:3000});if(r.stdout)commits.push(...r.stdout.trim().split('\n').filter(Boolean));}catch{}
190
201
  const gitRemote=t.git_remote||git(['config','--get','remote.origin.url']);
191
202
  const projectKey=t.project_id||repoSlug(gitRemote)||require('path').basename(process.cwd());
203
+ // Session duration: minutes from session_started_at to submitted_at. The ERP's
204
+ // example payload (docs/erp-contract.md:93) includes this; without it the ERP
205
+ // can't compute shift-length analytics without parsing notes.
206
+ let sessionDurationMinutes=0;
207
+ if(t.session_started_at){
208
+ const startMs=Date.parse(t.session_started_at);
209
+ const endMs=Date.parse(process.env.SUBMITTED_AT)||Date.now();
210
+ if(!Number.isNaN(startMs)&&endMs>startMs){
211
+ sessionDurationMinutes=Math.round((endMs-startMs)/60000);
212
+ }
213
+ }
192
214
  console.log(JSON.stringify({
193
215
  project:t.project||require('path').basename(process.cwd()),
194
216
  project_id:projectKey,team_id:t.team_id||'qualia-solutions',git_remote:gitRemote,
@@ -200,6 +222,7 @@ PAYLOAD=$(
200
222
  gap_cycles:(t.gap_cycles||{})[String(t.phase)]||0,build_count:t.build_count||0,
201
223
  deploy_count:t.deploy_count||0,deployed_url:t.deployed_url||'',
202
224
  session_started_at:t.session_started_at||'',last_pushed_at:t.last_pushed_at||'',
225
+ session_duration_minutes:sessionDurationMinutes,
203
226
  lifetime:t.lifetime||{},commits:commits,notes:notes,
204
227
  submitted_by:process.env.SUBMITTED_BY||'unknown',submitted_at:process.env.SUBMITTED_AT
205
228
  }));
@@ -214,11 +237,15 @@ if [ "$DRY_RUN" = "true" ]; then
214
237
  exit 0
215
238
  fi
216
239
 
217
- # Upload — 3 attempts with 1s/3s/9s backoff
240
+ # Upload — 3 attempts with 1s/3s/9s backoff.
241
+ # Idempotency-Key header carries a deterministic UUID per (client_report_id, submitted_at)
242
+ # so the ERP can dedupe at the request level in addition to the UPSERT key on the body.
243
+ # Documented in docs/erp-contract.md:42-49 with a 24h replay window.
218
244
  if [ "$ERP_ENABLED" = "true" ]; then
219
245
  for ATTEMPT in 1 2 3; do
220
246
  RESPONSE=$(curl -sS -X POST "$ERP_URL/api/v1/reports" \
221
247
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
248
+ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
222
249
  -d "$PAYLOAD" --max-time 10 -w "\n__HTTP__%{http_code}" 2>&1)
223
250
  HTTP_CODE=$(echo "$RESPONSE" | grep -o "__HTTP__[0-9]*" | sed 's/__HTTP__//')
224
251
  BODY=$(echo "$RESPONSE" | sed 's/__HTTP__[0-9]*//g')
@@ -235,7 +262,42 @@ if [ "$ERP_ENABLED" = "true" ]; then
235
262
  fi
236
263
  [ $ATTEMPT -lt 3 ] && { SLEEP=$((1 * 3 ** (ATTEMPT - 1))); node ~/.claude/bin/qualia-ui.js warn "Attempt $ATTEMPT failed (HTTP ${HTTP_CODE:-timeout}), retrying in ${SLEEP}s..."; sleep $SLEEP; }
237
264
  done
238
- [ "$ATTEMPT" = "3" ] && [ "$HTTP_CODE" != "200" ] && node ~/.claude/bin/qualia-ui.js warn "ERP upload failed after 3 attempts. $CLIENT_REPORT_ID is committed locally; will appear in ERP after retry."
265
+
266
+ # If all 3 in-process attempts failed, enqueue the report into the persistent
267
+ # retry queue (~/.claude/.erp-retry-queue.json). session-start.js drains it on
268
+ # the next Claude Code launch; `qualia-framework erp-flush` drains it on demand.
269
+ # This replaces the prior "will appear after retry" message which was a lie —
270
+ # no retry mechanism existed before v5.9.
271
+ if [ "$ATTEMPT" = "3" ] && [ "$HTTP_CODE" != "200" ]; then
272
+ LAST_ERR="HTTP ${HTTP_CODE:-timeout}"
273
+ if [ -n "$BODY" ]; then LAST_ERR="$LAST_ERR: $(echo "$BODY" | head -c 200)"; fi
274
+ PAYLOAD="$PAYLOAD" \
275
+ CLIENT_REPORT_ID="$CLIENT_REPORT_ID" \
276
+ IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" \
277
+ ERP_URL="$ERP_URL" \
278
+ LAST_ERR="$LAST_ERR" \
279
+ node -e "
280
+ try {
281
+ const {enqueue} = require(require('os').homedir() + '/.claude/bin/erp-retry.js');
282
+ enqueue({
283
+ client_report_id: process.env.CLIENT_REPORT_ID,
284
+ idempotency_key: process.env.IDEMPOTENCY_KEY,
285
+ url: process.env.ERP_URL + '/api/v1/reports',
286
+ payload: process.env.PAYLOAD,
287
+ last_error: process.env.LAST_ERR,
288
+ });
289
+ process.stdout.write('enqueued');
290
+ } catch (e) {
291
+ process.stderr.write('enqueue failed: ' + (e.message || e));
292
+ process.exit(1);
293
+ }
294
+ " 2>/dev/null && {
295
+ node ~/.claude/bin/qualia-ui.js warn "ERP upload failed after 3 attempts — $CLIENT_REPORT_ID enqueued for auto-retry on next session"
296
+ node ~/.claude/bin/qualia-ui.js info "Drain manually with: qualia-framework erp-flush"
297
+ } || {
298
+ node ~/.claude/bin/qualia-ui.js warn "ERP upload failed after 3 attempts AND queue enqueue failed. $CLIENT_REPORT_ID is committed locally — re-run /qualia-report later to retry."
299
+ }
300
+ fi
239
301
  fi
240
302
 
241
303
  [ "$ERP_ENABLED" != "true" ] && node ~/.claude/bin/qualia-ui.js info "ERP upload skipped (disabled). $CLIENT_REPORT_ID committed locally."
@@ -104,6 +104,22 @@ Append '## Adversarial Findings' to verification file. Empty section fine if not
104
104
 
105
105
  Findings merge into main report. Union PASS/FAIL: either pass found CRITICAL/HIGH → phase FAIL.
106
106
 
107
+ ### 2d. INSUFFICIENT EVIDENCE downgrade (mandatory)
108
+
109
+ The verifier marks criteria it could not check (budget exhaustion, missing context) as `INSUFFICIENT EVIDENCE`. The orchestrator treats those as silent PASS unless explicitly downgraded — that's the #1 false-pass vector. Grep the verification file before declaring PASS:
110
+
111
+ ```bash
112
+ IE_COUNT=$(grep -c "INSUFFICIENT EVIDENCE" .planning/phase-{N}-verification.md 2>/dev/null || echo 0)
113
+ if [ "$IE_COUNT" -gt 0 ]; then
114
+ node ~/.claude/bin/qualia-ui.js warn "${IE_COUNT} criteria marked INSUFFICIENT EVIDENCE — downgrading verdict to FAIL"
115
+ # Rewrite the verdict line in-place
116
+ sed -i 's/^result: PASS$/result: FAIL/' .planning/phase-{N}-verification.md
117
+ sed -i 's/^## Verdict$/## Verdict\n\n**Downgraded to FAIL:** '"${IE_COUNT}"' criteria left unchecked. Re-run with larger budget (`max(25, tasks*5)` already applied) or simplify the phase plan./' .planning/phase-{N}-verification.md
118
+ fi
119
+ ```
120
+
121
+ The same check runs after the adversarial pass if it executed.
122
+
107
123
  ### 3. Present Results
108
124
 
109
125
  Read verification report. Present:
@@ -374,9 +374,8 @@
374
374
  <h3>Quick Paths</h3>
375
375
  <p class="cmd-group-note">Lightweight alternatives when the full road is overkill.</p>
376
376
  <div class="commands">
377
- <div class="cmd"><span class="cmd-name">/qualia-quick</span><span class="cmd-desc">Fast path for small tasks &mdash; bug fixes, tweaks, hot fixes. Skips full phase planning.</span></div>
378
- <div class="cmd"><span class="cmd-name">/qualia-task</span><span class="cmd-desc">Build a single task &mdash; more structured than /qualia-quick, lighter than /qualia-build. Spawns a fresh builder agent for one focused task.</span></div>
379
- <div class="cmd"><span class="cmd-name">/qualia-design</span><span class="cmd-desc">One-shot design transformation &mdash; critiques, fixes, polishes, hardens, makes responsive. No reports, no choices, just makes it professional.</span></div>
377
+ <div class="cmd"><span class="cmd-name">/qualia-feature</span><span class="cmd-desc">Auto-scoped single-feature build. Inline for trivia (typo, config), fresh builder spawn for 1-5 file features. Refuses and routes to /qualia-plan for phase-sized work. Flags: --force-spawn, --force-inline.</span></div>
378
+ <div class="cmd"><span class="cmd-name">/qualia-polish</span><span class="cmd-desc">Design pass, scope-adaptive &mdash; component, route, full app, redesign, critique, quick. Add --loop for the autonomous screenshot &rarr; score &rarr; fix loop.</span></div>
380
379
  </div>
381
380
  </div>
382
381
 
package/tests/bin.test.sh CHANGED
@@ -719,18 +719,36 @@ else
719
719
  fail_case "research-synthesizer missing model frontmatter"
720
720
  fi
721
721
 
722
- # 64. Other agents do NOT have model frontmatter (conservative matrix)
723
- SAFE_AGENTS=("planner.md" "builder.md" "verifier.md" "plan-checker.md")
722
+ # 64. v5.9 model tiering: structured agents (verifier, plan-checker, roadmapper,
723
+ # qa-browser) use Sonnet. Real-reasoning agents (planner, builder, researcher,
724
+ # visual-evaluator) keep inherited Opus.
725
+ SONNET_AGENTS=("verifier.md" "plan-checker.md" "roadmapper.md" "qa-browser.md")
726
+ OPUS_AGENTS=("planner.md" "builder.md" "researcher.md" "visual-evaluator.md")
727
+
728
+ ALL_OK=1
729
+ for a in "${SONNET_AGENTS[@]}"; do
730
+ if ! grep -q '^model: sonnet$' "$TMP/.claude/agents/$a" 2>/dev/null; then
731
+ ALL_OK=0
732
+ echo " missing 'model: sonnet' in $a"
733
+ fi
734
+ done
735
+ if [ "$ALL_OK" = "1" ]; then
736
+ pass "structured agents (verifier/plan-checker/roadmapper/qa-browser) use sonnet (v5.9 tiering)"
737
+ else
738
+ fail_case "v5.9 sonnet-tier agent has wrong model frontmatter"
739
+ fi
740
+
724
741
  ALL_OK=1
725
- for a in "${SAFE_AGENTS[@]}"; do
742
+ for a in "${OPUS_AGENTS[@]}"; do
726
743
  if grep -q '^model: ' "$TMP/.claude/agents/$a" 2>/dev/null; then
727
744
  ALL_OK=0
745
+ echo " unexpected 'model:' in $a (should inherit Opus)"
728
746
  fi
729
747
  done
730
748
  if [ "$ALL_OK" = "1" ]; then
731
- pass "high-stakes agents (planner/builder/verifier/plan-checker) keep default model"
749
+ pass "reasoning agents (planner/builder/researcher/visual-evaluator) inherit Opus"
732
750
  else
733
- fail_case "high-stakes agent has unexpected model frontmatter"
751
+ fail_case "Opus-tier agent has unexpected model frontmatter"
734
752
  fi
735
753
 
736
754
  echo ""
@@ -0,0 +1,146 @@
1
+ #!/bin/bash
2
+ # Qualia Framework — surface-drift guard
3
+ # Greps every active surface for backtick-quoted /qualia-{name} command references.
4
+ # Asserts each name has a matching skills/qualia-{name}/SKILL.md.
5
+ #
6
+ # Why: v5.7 + v5.8 removed /qualia-quick, /qualia-task, /qualia-prd, /qualia-design,
7
+ # /qualia-polish-loop. Three user-facing files (rules/speed.md, templates/help.html,
8
+ # docs/onboarding.html) still pointed at the removed commands. New hires hit dead
9
+ # ends. This test fails when that happens again.
10
+ #
11
+ # Scoping rules:
12
+ # - Only matches references quoted in markdown backticks (`/qualia-foo`) or shown
13
+ # as a slash-command at line start. Bare path refs (/qualia-templates/,
14
+ # /qualia-ui.js) are excluded — they're directories or scripts, not commands.
15
+ # - Historical surfaces (docs/reviews/, docs/research/, CHANGELOG) are excluded —
16
+ # they intentionally describe past states.
17
+ # - Migration mentions on active surfaces (README/guide describing v5.7 removals)
18
+ # are excluded via a context prefix check.
19
+ #
20
+ # Run: bash tests/refs.test.sh
21
+
22
+ FRAMEWORK_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
23
+ SKILLS_DIR="$FRAMEWORK_ROOT/skills"
24
+
25
+ PASS=0
26
+ FAIL=0
27
+
28
+ pass() {
29
+ echo " ✓ $1"
30
+ PASS=$((PASS + 1))
31
+ }
32
+
33
+ fail_case() {
34
+ echo " ✗ $1"
35
+ echo " $2"
36
+ FAIL=$((FAIL + 1))
37
+ }
38
+
39
+ # Files we never scan — historical records, backups, vendored deps.
40
+ EXCLUDE_REGEX='/docs/reviews/|/docs/research/|/docs/playwright-loop-pilot-results\.md$|/CHANGELOG\.md$|\.bak\.|\.git/|/node_modules/|/\.continue-here\.md$'
41
+
42
+ # When a `/qualia-foo` ref appears AFTER one of these context tokens on the same line,
43
+ # it's a migration-explainer ("Replaces /qualia-quick" / "deprecated in v5.7"), not
44
+ # a live command reference. Treat it as exempt.
45
+ MIGRATION_CONTEXT_REGEX='Replaces|Removed|removed in|consolidated|deprecated|renamed|former|previously|was the|now the|now\s+`?/qualia|absorbed|superseded|legacy|migrated|after\s+`?/qualia.*-(quick|task|prd|design|polish-loop)'
46
+
47
+ ACTIVE_DIRS=(
48
+ "$FRAMEWORK_ROOT/rules"
49
+ "$FRAMEWORK_ROOT/skills"
50
+ "$FRAMEWORK_ROOT/agents"
51
+ "$FRAMEWORK_ROOT/hooks"
52
+ "$FRAMEWORK_ROOT/templates"
53
+ )
54
+
55
+ ACTIVE_FILES=(
56
+ "$FRAMEWORK_ROOT/README.md"
57
+ "$FRAMEWORK_ROOT/guide.md"
58
+ "$FRAMEWORK_ROOT/CLAUDE.md"
59
+ "$FRAMEWORK_ROOT/AGENTS.md"
60
+ "$FRAMEWORK_ROOT/docs/onboarding.html"
61
+ )
62
+
63
+ echo "refs.test.sh — surface-drift guard (active /qualia-* references must point at shipped skills)"
64
+ echo ""
65
+
66
+ SCAN_FILES=$(
67
+ {
68
+ for d in "${ACTIVE_DIRS[@]}"; do
69
+ [ -d "$d" ] && find "$d" -type f \( -name "*.md" -o -name "*.html" \)
70
+ done
71
+ for f in "${ACTIVE_FILES[@]}"; do
72
+ [ -f "$f" ] && echo "$f"
73
+ done
74
+ } | grep -Ev "$EXCLUDE_REGEX" | sort -u
75
+ )
76
+
77
+ # Extract backtick-quoted command refs only. Two patterns:
78
+ # 1. `/qualia-foo` — backticked, the canonical command-doc style
79
+ # 2. <dt>/qualia-foo</dt> — HTML help/onboarding pages
80
+ # We capture name + file:line so we can show context per failure.
81
+ declare -A SEEN_REFS
82
+ declare -A REF_LOCATIONS
83
+
84
+ while IFS= read -r file; do
85
+ # Pattern A: backtick-quoted commands. Allow trailing flag/word but only capture base name.
86
+ while IFS=: read -r path lineno line; do
87
+ [ -z "$line" ] && continue
88
+ # Skip migration-context lines.
89
+ if echo "$line" | grep -qE "$MIGRATION_CONTEXT_REGEX"; then
90
+ continue
91
+ fi
92
+ # Extract every backticked /qualia-foo on this line.
93
+ matches=$(echo "$line" | grep -oE '`/qualia(-[a-z]+){0,3}`' | sed 's/^`//; s/`$//')
94
+ for ref in $matches; do
95
+ SEEN_REFS["$ref"]=1
96
+ REF_LOCATIONS["$ref"]="${REF_LOCATIONS[$ref]:+${REF_LOCATIONS[$ref]}, }$(basename "$path"):$lineno"
97
+ done
98
+ done < <(grep -nE '`/qualia(-[a-z]+){0,3}`' "$file" 2>/dev/null)
99
+
100
+ # Pattern B: HTML <dt>/qualia-foo</dt>.
101
+ while IFS=: read -r path lineno line; do
102
+ [ -z "$line" ] && continue
103
+ if echo "$line" | grep -qE "$MIGRATION_CONTEXT_REGEX"; then
104
+ continue
105
+ fi
106
+ matches=$(echo "$line" | grep -oE '<dt>/qualia(-[a-z]+){0,3}( [^<]*)?</dt>' | sed -E 's|<dt>(/qualia(-[a-z]+){0,3}).*|\1|')
107
+ for ref in $matches; do
108
+ SEEN_REFS["$ref"]=1
109
+ REF_LOCATIONS["$ref"]="${REF_LOCATIONS[$ref]:+${REF_LOCATIONS[$ref]}, }$(basename "$path"):$lineno"
110
+ done
111
+ done < <(grep -nE '<dt>/qualia' "$file" 2>/dev/null)
112
+ done <<<"$SCAN_FILES"
113
+
114
+ if [ ${#SEEN_REFS[@]} -eq 0 ]; then
115
+ fail_case "scan" "no backticked /qualia-* references found across active surfaces (scanner broken?)"
116
+ echo ""
117
+ echo "Results: $PASS passed, $FAIL failed"
118
+ exit 1
119
+ fi
120
+
121
+ # Sort refs for deterministic output.
122
+ for ref in $(printf '%s\n' "${!SEEN_REFS[@]}" | sort); do
123
+ name="${ref#/}"
124
+ skill_dir="$SKILLS_DIR/$name"
125
+ locations="${REF_LOCATIONS[$ref]}"
126
+ if [ -d "$skill_dir" ] && [ -f "$skill_dir/SKILL.md" ]; then
127
+ pass "$ref → skills/$name/SKILL.md"
128
+ continue
129
+ fi
130
+ fail_case "$ref" "no skills/$name/SKILL.md — referenced by: $locations"
131
+ done
132
+
133
+ echo ""
134
+ echo "Results: $PASS passed, $FAIL failed"
135
+
136
+ if [ "$FAIL" -gt 0 ]; then
137
+ echo ""
138
+ echo "Surface drift detected. To fix one of:"
139
+ echo " 1. Restore the missing skill at skills/<name>/SKILL.md"
140
+ echo " 2. Update the offending file to point at the replacement command"
141
+ echo " 3. Reframe the mention as a migration note (this test skips lines containing"
142
+ echo " 'Replaces', 'consolidated', 'deprecated', 'now', 'former', 'superseded', etc.)"
143
+ exit 1
144
+ fi
145
+
146
+ exit 0