Conversation
Canon Quality —
|
| Line | Rule | Occurrence | Message |
|---|---|---|---|
| 203 | dead-reference |
klappy://writings/four-questions-that-change-everything |
URI does not resolve |
writings/the-broken-wall-and-the-buried-talent.md — 1 finding(s)
| Line | Rule | Occurrence | Message |
|---|---|---|---|
| 332 | dead-reference |
klappy://draft-zeros/appendix-a-the-biblical-roots |
URI does not resolve |
writings/the-voice-came-first.md — 1 finding(s)
| Line | Rule | Occurrence | Message |
|---|---|---|---|
| 244 | dead-reference |
klappy://writings/four-questions-that-change-everything |
URI does not resolve |
Soft-block mode — this status is informational; the job will not fail. Hard-block ships in PR-3.2 after the observation cycle.
What to do for each finding:
- Fix the slug if the target now lives at a different
klappy://URI.- Remove the link if it is no longer needed.
- Allowlist with a reason if the rot is intentional (e.g. forward-ref to an upcoming article): place
<!-- audit-allow: dead-reference reason="..." -->on the line above the offending link. The directive is line-level and scopes to the next markdown link.
Spec: klappy://docs/oddkit/specs/oddkit-audit · Workflow: .github/workflows/canon-quality.yml · Run: #7
…X from hard block - Wrap the SSE/JSON-RPC parsing heredoc with an if-guard so a parse error routes through the same audit_failed=true path used for HTTP failures instead of letting bash -e abort the step (which previously skipped the PR comment and produced a red CI check with zero feedback in soft mode). - Enforcement gate now only fails on STATUS=FINDINGS in hard mode. Per docs/oddkit/specs/oddkit-audit.md, PARTIAL_INDEX is best-effort and must remain non-blocking (warning, retry on next push).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Hard-mode comment misleads on PARTIAL_INDEX status
- The renderer now mirrors the enforcement gate's predicate (will_fail = mode=='hard' and status=='FINDINGS'), picks the warning icon and a PARTIAL_INDEX-specific footer in hard mode so the comment matches the actual CI outcome.
- ✅ Fixed: Workflow input interpolated unsafely into shell
- Replaced direct ${{ github.event.inputs.scope_paths }} interpolation in the run script with an INPUT_SCOPE_PATHS env var referenced as a shell variable, treating the input as data and removing the quoting/injection hazard.
Preview (81c0aa2b13)
diff --git a/.github/workflows/canon-quality.yml b/.github/workflows/canon-quality.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/canon-quality.yml
@@ -1,0 +1,314 @@
+name: Canon Quality
+
+# Calls oddkit_audit against prod and reports/blocks based on findings.
+# Spec: klappy://docs/oddkit/specs/oddkit-audit (DRAFT v2.2)
+# Mode is controlled by repo variable AUDIT_ENFORCEMENT_MODE:
+# - 'soft' (default): comment with findings, never fail the job
+# - 'hard': fail the job when status != OK
+# Set the variable at https://github.com/klappy/klappy.dev/settings/variables/actions
+# PR-3.1 ships in soft. PR-3.2 will flip to hard after the observation cycle.
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'writings/**'
+ - 'canon/**'
+ - 'odd/**'
+ - 'docs/**'
+ - '.github/workflows/canon-quality.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'writings/**'
+ - 'canon/**'
+ - 'odd/**'
+ - 'docs/**'
+ - '.github/workflows/canon-quality.yml'
+ workflow_dispatch:
+ inputs:
+ scope_paths:
+ description: 'JSON array of scope paths (default ["writings/"])'
+ required: false
+ default: '["writings/"]'
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ ODDKIT_MCP_URL: https://oddkit.klappy.dev/mcp
+ ENFORCEMENT_MODE: ${{ vars.AUDIT_ENFORCEMENT_MODE || 'soft' }}
+ USER_AGENT: 'klappy.dev-canon-quality/1.0 (+github-actions; ${{ github.repository }}#${{ github.run_id }})'
+
+jobs:
+ audit:
+ name: Reference integrity audit
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Resolve scope
+ id: scope
+ env:
+ INPUT_SCOPE_PATHS: ${{ github.event.inputs.scope_paths }}
+ run: |
+ if [ -n "$INPUT_SCOPE_PATHS" ]; then
+ PATHS="$INPUT_SCOPE_PATHS"
+ else
+ PATHS='["writings/"]'
+ fi
+ echo "Scope paths: $PATHS"
+ echo "paths=$PATHS" >> "$GITHUB_OUTPUT"
+
+ - name: Call oddkit_audit (with one retry on transient failure)
+ id: audit
+ env:
+ SCOPE_PATHS: ${{ steps.scope.outputs.paths }}
+ run: |
+ set -uo pipefail
+
+ # Build JSON-RPC payload. The 'input' arg is a JSON string per the action's schema.
+ INNER=$(python3 -c "import json,os; print(json.dumps({'scope':{'paths':json.loads(os.environ['SCOPE_PATHS'])}}))")
+ PAYLOAD=$(python3 -c "
+ import json, sys
+ inner = sys.argv[1]
+ print(json.dumps({
+ 'jsonrpc': '2.0',
+ 'id': 1,
+ 'method': 'tools/call',
+ 'params': {'name': 'oddkit_audit', 'arguments': {'input': inner}}
+ }))
+ " "$INNER")
+ echo "$PAYLOAD" > /tmp/req.json
+
+ AUDIT_OK=false
+ HTTP_CODE=000
+ for attempt in 1 2; do
+ HTTP_CODE=$(curl -sS -o /tmp/resp.raw -w '%{http_code}' \
+ -X POST \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json, text/event-stream' \
+ -H "User-Agent: $USER_AGENT" \
+ --max-time 120 \
+ -d @/tmp/req.json \
+ "$ODDKIT_MCP_URL" || echo 000)
+ echo "Attempt $attempt -> HTTP $HTTP_CODE"
+ if [ "$HTTP_CODE" = "200" ]; then
+ AUDIT_OK=true
+ break
+ fi
+ if [ "$attempt" -lt 2 ]; then
+ echo "Transient failure; sleeping 8s before retry"
+ sleep 8
+ fi
+ done
+
+ if [ "$AUDIT_OK" != "true" ]; then
+ echo "Audit could not be completed after 2 attempts."
+ echo "Response (first 500 bytes):"
+ head -c 500 /tmp/resp.raw || true
+ echo ""
+ echo "audit_failed=true" >> "$GITHUB_OUTPUT"
+ echo "http_code=$HTTP_CODE" >> "$GITHUB_OUTPUT"
+ exit 0 # don't fail the step; the report step will surface this
+ fi
+
+ # Parse the SSE response. The MCP server returns event-stream with a final
+ # 'data: { ... }' line containing the JSON-RPC envelope.
+ # Guard with `if !` so any parse failure (sys.exit / unhandled exception)
+ # is routed through the same audit_failed=true path used for HTTP failures,
+ # rather than aborting the step under bash -e and skipping the comment steps.
+ if ! python3 - <<'PY'
+ import json, sys
+ with open('/tmp/resp.raw') as f:
+ raw = f.read()
+ data_lines = [l[6:] for l in raw.splitlines() if l.startswith('data: ')]
+ if not data_lines:
+ sys.stderr.write("No SSE 'data: ' lines in response\n")
+ sys.stderr.write(raw[:500])
+ sys.exit(1)
+ envelope = json.loads(data_lines[-1])
+ if 'error' in envelope:
+ sys.stderr.write(f"JSON-RPC error: {envelope['error']}\n")
+ sys.exit(1)
+ # Extract the action envelope from content[0].text
+ content = envelope.get('result', {}).get('content', [])
+ texts = [c.get('text','') for c in content if c.get('type') == 'text']
+ if not texts:
+ sys.stderr.write("No text content in tool-call result\n")
+ sys.exit(1)
+ inner = json.loads(texts[0])
+ with open('/tmp/audit-result.json','w') as f:
+ json.dump(inner, f, indent=2)
+ # Surface a tiny diagnostic to logs
+ r = inner.get('result', {})
+ print(f"status={r.get('status')} findings={r.get('summary',{}).get('total_findings',0)} files={r.get('summary',{}).get('files_scanned',0)}")
+ PY
+ then
+ echo "Failed to parse audit response."
+ echo "Response (first 500 bytes):"
+ head -c 500 /tmp/resp.raw || true
+ echo ""
+ echo "audit_failed=true" >> "$GITHUB_OUTPUT"
+ echo "http_code=parse_error" >> "$GITHUB_OUTPUT"
+ exit 0 # don't fail the step; the report step will surface this
+ fi
+
+ echo "audit_failed=false" >> "$GITHUB_OUTPUT"
+
+ - name: Upload audit response artifact
+ if: always() && steps.audit.outputs.audit_failed == 'false'
+ uses: actions/upload-artifact@v4
+ with:
+ name: audit-response
+ path: /tmp/audit-result.json
+ retention-days: 14
+ if-no-files-found: warn
+
+ - name: Render comment body
+ id: render
+ if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed != 'true'
+ env:
+ MODE: ${{ env.ENFORCEMENT_MODE }}
+ run: |
+ python3 - <<'PY'
+ import json, os
+
+ with open('/tmp/audit-result.json') as f:
+ d = json.load(f)
+ r = d.get('result', {})
+ status = r.get('status', '?')
+ summary = r.get('summary', {})
+ findings = r.get('findings', [])
+ suppressed = r.get('suppressed_findings', [])
+ scope = r.get('scope', {})
+ mode = os.environ.get('MODE', 'soft')
+ paths_label = ', '.join(scope.get('paths', []))
+
+ lines = []
+ if status == 'OK':
+ lines.append('### Canon Quality — `oddkit_audit` ✅')
+ lines.append('')
+ lines.append(f'No dead `klappy://` references or legacy link patterns found in `{paths_label}`. {summary.get("files_scanned", 0)} files scanned.')
+ else:
+ # Per the audit spec, PARTIAL_INDEX is non-blocking even in hard mode
+ # (best-effort findings, retry on next push). Only FINDINGS fails in hard.
+ will_fail = mode == 'hard' and status == 'FINDINGS'
+ icon = '❌' if will_fail else '⚠️'
+ lines.append(f'### Canon Quality — `oddkit_audit` {icon}')
+ lines.append('')
+ total = summary.get('total_findings', len(findings))
+ lines.append(f'**{total} finding(s)** in `{paths_label}` ({summary.get("files_scanned", 0)} files scanned). Mode: `{mode}`.')
+ lines.append('')
+
+ # Group by file, cap at 50 rendered to keep PR comment manageable
+ CAP = 50
+ by_file = {}
+ for f in findings[:CAP]:
+ loc = f.get('location', {})
+ by_file.setdefault(loc.get('path', '?'), []).append(
+ (loc.get('line'), f.get('rule_id'), f.get('occurrence'), f.get('message'))
+ )
+
+ for path, items in sorted(by_file.items()):
+ lines.append(f'<details><summary><code>{path}</code> — {len(items)} finding(s)</summary>')
+ lines.append('')
+ lines.append('| Line | Rule | Occurrence | Message |')
+ lines.append('|---:|---|---|---|')
+ for line, rule, occ, msg in items:
+ occ_safe = (occ or '').replace('|', '\\|')
+ msg_safe = (msg or '').replace('|', '\\|')
+ lines.append(f'| {line} | `{rule}` | `{occ_safe}` | {msg_safe} |')
+ lines.append('')
+ lines.append('</details>')
+
+ if len(findings) > CAP:
+ lines.append('')
+ lines.append(f'_… and {len(findings) - CAP} more finding(s). See the `audit-response` workflow artifact for the full list._')
+
+ if suppressed:
+ s_count = len(suppressed) if isinstance(suppressed, list) else suppressed
+ lines.append('')
+ lines.append(f'_{s_count} finding(s) suppressed via `<!-- audit-allow: ... -->` directives._')
+
+ lines.append('')
+ if mode == 'soft':
+ lines.append('> **Soft-block mode** — this status is informational. The job will not fail. Hard-block ships in PR-3.2 after the observation cycle.')
+ elif status == 'PARTIAL_INDEX':
+ lines.append('> **Hard-block mode** — `PARTIAL_INDEX` is non-blocking per the audit spec (best-effort findings, retry on next push). The job will not fail on this status.')
+ else:
+ lines.append('> **Hard-block mode** — this PR will fail until findings are resolved. Fix the dead references or add a line-level allowlist directive (`<!-- audit-allow: dead-reference reason="..." -->`) above the offending link.')
+
+ lines.append('')
+ lines.append('<sub>Spec: `klappy://docs/oddkit/specs/oddkit-audit` · Workflow: `.github/workflows/canon-quality.yml` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>')
+
+ with open('/tmp/comment.md', 'w') as f:
+ f.write('\n'.join(lines))
+ PY
+
+ - name: Sticky comment — audit results
+ if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed != 'true'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: canon-quality-audit
+ path: /tmp/comment.md
+
+ - name: Sticky comment — audit infrastructure failure
+ if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed == 'true'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: canon-quality-audit
+ message: |
+ ### Canon Quality — `oddkit_audit` ⚠️ (infrastructure)
+
+ The audit could not be completed (HTTP `${{ steps.audit.outputs.http_code }}` from `${{ env.ODDKIT_MCP_URL }}` after retry). This run produces no signal about reference integrity — re-run the workflow if the failure was transient.
+
+ The job does not fail on infrastructure issues; persistent failures should be tracked separately.
+
+ <sub>Workflow: `.github/workflows/canon-quality.yml` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>
+
+ - name: Workflow step summary
+ if: always()
+ run: |
+ {
+ echo "## Canon Quality — \`oddkit_audit\`"
+ echo ""
+ echo "- **Mode**: \`$ENFORCEMENT_MODE\`"
+ echo "- **Endpoint**: \`$ODDKIT_MCP_URL\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ if [ "${{ steps.audit.outputs.audit_failed }}" = "true" ]; then
+ echo "- **Result**: infrastructure failure (HTTP \`${{ steps.audit.outputs.http_code }}\`)" >> "$GITHUB_STEP_SUMMARY"
+ elif [ -f /tmp/audit-result.json ]; then
+ python3 - <<'PY'
+ import json, os
+ with open('/tmp/audit-result.json') as f:
+ d = json.load(f)
+ r = d.get('result', {})
+ s = r.get('summary', {})
+ out_path = os.environ['GITHUB_STEP_SUMMARY']
+ with open(out_path, 'a') as f:
+ f.write(f"- **Status**: `{r.get('status')}`\n")
+ f.write(f"- **Total findings**: {s.get('total_findings', 0)}\n")
+ f.write(f"- **By severity**: `{s.get('by_severity', {})}`\n")
+ f.write(f"- **Files scanned**: {s.get('files_scanned', 0)}\n")
+ f.write(f"- **Scope**: `{r.get('scope', {})}`\n")
+ if s.get('truncated'):
+ f.write(f"- **Truncated**: yes (response was capped)\n")
+ PY
+ else
+ echo "- **Result**: no audit result file produced" >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Enforcement gate
+ if: steps.audit.outputs.audit_failed != 'true'
+ run: |
+ STATUS=$(python3 -c "import json; print(json.load(open('/tmp/audit-result.json'))['result']['status'])")
+ echo "Enforcement mode: $ENFORCEMENT_MODE | Audit status: $STATUS"
+ # Per the audit spec, PARTIAL_INDEX is non-blocking (best-effort findings,
+ # retry on next push). Only FINDINGS should fail the job in hard mode.
+ if [ "$ENFORCEMENT_MODE" = "hard" ] && [ "$STATUS" = "FINDINGS" ]; then
+ echo "::error::Hard-block mode: oddkit_audit returned status=$STATUS. Failing job — fix the dead references or add explicit allowlist directives."
+ exit 1
+ fi
+ echo "Not failing the job (mode=$ENFORCEMENT_MODE, status=$STATUS)."You can send follow-ups to the cloud agent here.
… scope input - Render comment now treats PARTIAL_INDEX as non-blocking even in hard mode, matching the enforcement gate which only fails on FINDINGS. Adds a PARTIAL_INDEX-specific footer in hard mode and selects the warning icon when the job will not fail. - Resolve scope step passes github.event.inputs.scope_paths via env (INPUT_SCOPE_PATHS) instead of interpolating into the shell body, eliminating the quoting hazard and shell injection vector under workflow_dispatch.
…dening Reinstates two defensive improvements (originally landed in commits 2d9d09a and 81c0aa2, then removed when Option γ was applied on top of dc13e76): 1. SSE parse guard - wrap the parsing python heredoc in `if ! ... ; then ... fi` so a parse-side failure (sys.exit / unhandled exception) routes through the same audit_failed=true path as HTTP failures rather than aborting the step under bash -e and silently skipping the comment-posting steps. 2. INPUT_SCOPE_PATHS env hardening - move `github.event.inputs.scope_paths` out of inline ${{ }} interpolation into a quoted env var, defending against script injection through workflow_dispatch input. Intentionally not re-applied: the PARTIAL_INDEX-aware code paths from those commits. Spec v2.2 explicitly defers PARTIAL_INDEX (the worker does not emit it), so per Use Only What Hurts, the dormant code paths are bloat.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Retry loop aborts on curl connection failures
- Appended
|| trueto the curl command-substitution so curl transport failures (DNS, connection refused, TLS, --max-time) leave HTTP_CODE="000" and let the retry loop and audit_failed=true path run instead of bash -e aborting the step.
- Appended
Preview (cbc41c383e)
diff --git a/.github/workflows/canon-quality.yml b/.github/workflows/canon-quality.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/canon-quality.yml
@@ -1,0 +1,316 @@
+name: Canon Quality
+
+# Calls oddkit_audit against prod and reports/blocks based on findings.
+# Spec: klappy://docs/oddkit/specs/oddkit-audit (DRAFT v2.2)
+# Mode is controlled by repo variable AUDIT_ENFORCEMENT_MODE:
+# - 'soft' (default): comment with findings, never fail the job
+# - 'hard': fail the job when status != OK
+# Set the variable at https://github.com/klappy/klappy.dev/settings/variables/actions
+# PR-3.1 ships in soft. PR-3.2 will flip to hard after the observation cycle.
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'writings/**'
+ - 'canon/**'
+ - 'odd/**'
+ - 'docs/**'
+ - '.github/workflows/canon-quality.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'writings/**'
+ - 'canon/**'
+ - 'odd/**'
+ - 'docs/**'
+ - '.github/workflows/canon-quality.yml'
+ workflow_dispatch:
+ inputs:
+ scope_paths:
+ description: 'JSON array of scope paths (default ["writings/"])'
+ required: false
+ default: '["writings/"]'
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ ODDKIT_MCP_URL: https://oddkit.klappy.dev/mcp
+ ENFORCEMENT_MODE: ${{ vars.AUDIT_ENFORCEMENT_MODE || 'soft' }}
+ USER_AGENT: 'klappy.dev-canon-quality/1.0 (+github-actions; ${{ github.repository }}#${{ github.run_id }})'
+
+jobs:
+ audit:
+ name: Reference integrity audit
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Resolve scope
+ id: scope
+ env:
+ INPUT_SCOPE_PATHS: ${{ github.event.inputs.scope_paths }}
+ run: |
+ if [ -n "$INPUT_SCOPE_PATHS" ]; then
+ PATHS="$INPUT_SCOPE_PATHS"
+ else
+ PATHS='["writings/"]'
+ fi
+ echo "Scope paths: $PATHS"
+ echo "paths=$PATHS" >> "$GITHUB_OUTPUT"
+
+ - name: Call oddkit_audit (with one retry on transient failure)
+ id: audit
+ env:
+ SCOPE_PATHS: ${{ steps.scope.outputs.paths }}
+ run: |
+ set -uo pipefail
+
+ # Build JSON-RPC payload. The 'input' arg is a JSON string per the action's schema.
+ INNER=$(python3 -c "import json,os; print(json.dumps({'scope':{'paths':json.loads(os.environ['SCOPE_PATHS'])}}))")
+ PAYLOAD=$(python3 -c "
+ import json, sys
+ inner = sys.argv[1]
+ print(json.dumps({
+ 'jsonrpc': '2.0',
+ 'id': 1,
+ 'method': 'tools/call',
+ 'params': {'name': 'oddkit_audit', 'arguments': {'input': inner}}
+ }))
+ " "$INNER")
+ echo "$PAYLOAD" > /tmp/req.json
+
+ AUDIT_OK=false
+ HTTP_CODE=000
+ for attempt in 1 2; do
+ # `|| true` guards against curl exit codes (DNS, connection refused,
+ # TLS errors, --max-time timeout) tripping bash -e via the command
+ # substitution and aborting the retry loop. `-w '%{http_code}'` still
+ # emits "000" on transport failure, which the loop already handles.
+ HTTP_CODE=$(curl -sS -o /tmp/resp.raw -w '%{http_code}' \
+ -X POST \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json, text/event-stream' \
+ -H "User-Agent: $USER_AGENT" \
+ --max-time 120 \
+ -d @/tmp/req.json \
+ "$ODDKIT_MCP_URL" || true)
+ echo "Attempt $attempt -> HTTP $HTTP_CODE"
+ if [ "$HTTP_CODE" = "200" ]; then
+ AUDIT_OK=true
+ break
+ fi
+ if [ "$attempt" -lt 2 ]; then
+ echo "Transient failure; sleeping 8s before retry"
+ sleep 8
+ fi
+ done
+
+ if [ "$AUDIT_OK" != "true" ]; then
+ echo "Audit could not be completed after 2 attempts."
+ echo "Response (first 500 bytes):"
+ head -c 500 /tmp/resp.raw || true
+ echo ""
+ echo "audit_failed=true" >> "$GITHUB_OUTPUT"
+ echo "http_code=$HTTP_CODE" >> "$GITHUB_OUTPUT"
+ exit 0 # don't fail the step; the report step will surface this
+ fi
+
+ # Parse the SSE response. The MCP server returns event-stream with a final
+ # 'data: { ... }' line containing the JSON-RPC envelope.
+ # Guard with `if !` so any parse failure (sys.exit / unhandled exception)
+ # is routed through the same audit_failed=true path used for HTTP failures,
+ # rather than aborting the step under bash -e and skipping the comment steps.
+ if ! python3 - <<'PY'
+ import json, sys
+ with open('/tmp/resp.raw') as f:
+ raw = f.read()
+ data_lines = [l[6:] for l in raw.splitlines() if l.startswith('data: ')]
+ if not data_lines:
+ sys.stderr.write("No SSE 'data: ' lines in response\n")
+ sys.stderr.write(raw[:500])
+ sys.exit(1)
+ envelope = json.loads(data_lines[-1])
+ if 'error' in envelope:
+ sys.stderr.write(f"JSON-RPC error: {envelope['error']}\n")
+ sys.exit(1)
+ # Extract the action envelope from content[0].text
+ content = envelope.get('result', {}).get('content', [])
+ texts = [c.get('text','') for c in content if c.get('type') == 'text']
+ if not texts:
+ sys.stderr.write("No text content in tool-call result\n")
+ sys.exit(1)
+ inner = json.loads(texts[0])
+ with open('/tmp/audit-result.json','w') as f:
+ json.dump(inner, f, indent=2)
+ # Surface a tiny diagnostic to logs
+ r = inner.get('result', {})
+ print(f"status={r.get('status')} findings={r.get('summary',{}).get('total_findings',0)} files={r.get('summary',{}).get('files_scanned',0)}")
+ PY
+ then
+ echo "Failed to parse audit response."
+ echo "Response (first 500 bytes):"
+ head -c 500 /tmp/resp.raw || true
+ echo ""
+ echo "audit_failed=true" >> "$GITHUB_OUTPUT"
+ echo "http_code=parse_error" >> "$GITHUB_OUTPUT"
+ exit 0 # don't fail the step; the report step will surface this
+ fi
+
+ echo "audit_failed=false" >> "$GITHUB_OUTPUT"
+
+ - name: Upload audit response artifact
+ if: always() && steps.audit.outputs.audit_failed == 'false'
+ uses: actions/upload-artifact@v4
+ with:
+ name: audit-response
+ path: /tmp/audit-result.json
+ retention-days: 14
+ if-no-files-found: warn
+
+ - name: Render comment body
+ id: render
+ if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed != 'true'
+ env:
+ MODE: ${{ env.ENFORCEMENT_MODE }}
+ run: |
+ python3 - <<'PY'
+ import json, os
+
+ with open('/tmp/audit-result.json') as f:
+ d = json.load(f)
+ r = d.get('result', {})
+ status = r.get('status', '?')
+ summary = r.get('summary', {})
+ findings = r.get('findings', [])
+ suppressed = r.get('suppressed_findings', [])
+ scope = r.get('scope', {})
+ mode = os.environ.get('MODE', 'soft')
+ paths_label = ', '.join(scope.get('paths', []))
+
+ lines = []
+ if status == 'OK':
+ lines.append('### Canon Quality — `oddkit_audit` ✅')
+ lines.append('')
+ lines.append(f'No dead `klappy://` references or legacy link patterns found in `{paths_label}`. {summary.get("files_scanned", 0)} files scanned.')
+ else:
+ icon = '⚠️' if mode == 'soft' else '❌'
+ lines.append(f'### Canon Quality — `oddkit_audit` {icon}')
+ lines.append('')
+ total = summary.get('total_findings', len(findings))
+ lines.append(f'**{total} finding(s)** in `{paths_label}` ({summary.get("files_scanned", 0)} files scanned). Mode: `{mode}`.')
+ lines.append('')
+
+ # Group by file, cap at 50 rendered to keep PR comment manageable
+ CAP = 50
+ by_file = {}
+ for f in findings[:CAP]:
+ loc = f.get('location', {})
+ by_file.setdefault(loc.get('path', '?'), []).append(
+ (loc.get('line'), f.get('rule_id'), f.get('occurrence'), f.get('message'))
+ )
+
+ for path, items in sorted(by_file.items()):
+ lines.append(f'<details><summary><code>{path}</code> — {len(items)} finding(s)</summary>')
+ lines.append('')
+ lines.append('| Line | Rule | Occurrence | Message |')
+ lines.append('|---:|---|---|---|')
+ for line, rule, occ, msg in items:
+ occ_safe = (occ or '').replace('|', '\\|')
+ msg_safe = (msg or '').replace('|', '\\|')
+ lines.append(f'| {line} | `{rule}` | `{occ_safe}` | {msg_safe} |')
+ lines.append('')
+ lines.append('</details>')
+
+ if len(findings) > CAP:
+ lines.append('')
+ lines.append(f'_… and {len(findings) - CAP} more finding(s). See the `audit-response` workflow artifact for the full list._')
+
+ if suppressed:
+ s_count = len(suppressed) if isinstance(suppressed, list) else suppressed
+ lines.append('')
+ lines.append(f'_{s_count} finding(s) suppressed via `<!-- audit-allow: ... -->` directives._')
+
+ lines.append('')
+ if mode == 'soft':
+ lines.append('> **Soft-block mode** — this status is informational; the job will not fail. Hard-block ships in PR-3.2 after the observation cycle.')
+ lines.append('>')
+ lines.append('> **What to do for each finding:**')
+ lines.append('> - **Fix the slug** if the target now lives at a different `klappy://` URI.')
+ lines.append('> - **Remove the link** if it is no longer needed.')
+ lines.append('> - **Allowlist with a reason** if the rot is intentional (e.g. forward-ref to an upcoming article): place `<!-- audit-allow: dead-reference reason="..." -->` on the line above the offending link. The directive is line-level and scopes to the next markdown link.')
+ else:
+ lines.append('> **Hard-block mode** — this PR will fail until findings are resolved. Fix the dead references, remove the links, or add a line-level allowlist directive (`<!-- audit-allow: dead-reference reason="..." -->`) above the offending link.')
+
+ lines.append('')
+ lines.append('<sub>Spec: `klappy://docs/oddkit/specs/oddkit-audit` · Workflow: `.github/workflows/canon-quality.yml` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>')
+
+ with open('/tmp/comment.md', 'w') as f:
+ f.write('\n'.join(lines))
+ PY
+
+ - name: Sticky comment — audit results
+ if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed != 'true'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: canon-quality-audit
+ path: /tmp/comment.md
+
+ - name: Sticky comment — audit infrastructure failure
+ if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed == 'true'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: canon-quality-audit
+ message: |
+ ### Canon Quality — `oddkit_audit` ⚠️ (infrastructure)
+
+ The audit could not be completed (HTTP `${{ steps.audit.outputs.http_code }}` from `${{ env.ODDKIT_MCP_URL }}` after retry). This run produces no signal about reference integrity — re-run the workflow if the failure was transient.
+
+ The job does not fail on infrastructure issues; persistent failures should be tracked separately.
+
+ <sub>Workflow: `.github/workflows/canon-quality.yml` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>
+
+ - name: Workflow step summary
+ if: always()
+ run: |
+ {
+ echo "## Canon Quality — \`oddkit_audit\`"
+ echo ""
+ echo "- **Mode**: \`$ENFORCEMENT_MODE\`"
+ echo "- **Endpoint**: \`$ODDKIT_MCP_URL\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ if [ "${{ steps.audit.outputs.audit_failed }}" = "true" ]; then
+ echo "- **Result**: infrastructure failure (HTTP \`${{ steps.audit.outputs.http_code }}\`)" >> "$GITHUB_STEP_SUMMARY"
+ elif [ -f /tmp/audit-result.json ]; then
+ python3 - <<'PY'
+ import json, os
+ with open('/tmp/audit-result.json') as f:
+ d = json.load(f)
+ r = d.get('result', {})
+ s = r.get('summary', {})
+ out_path = os.environ['GITHUB_STEP_SUMMARY']
+ with open(out_path, 'a') as f:
+ f.write(f"- **Status**: `{r.get('status')}`\n")
+ f.write(f"- **Total findings**: {s.get('total_findings', 0)}\n")
+ f.write(f"- **By severity**: `{s.get('by_severity', {})}`\n")
+ f.write(f"- **Files scanned**: {s.get('files_scanned', 0)}\n")
+ f.write(f"- **Scope**: `{r.get('scope', {})}`\n")
+ if s.get('truncated'):
+ f.write(f"- **Truncated**: yes (response was capped)\n")
+ PY
+ else
+ echo "- **Result**: no audit result file produced" >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Enforcement gate
+ if: steps.audit.outputs.audit_failed != 'true'
+ run: |
+ STATUS=$(python3 -c "import json; print(json.load(open('/tmp/audit-result.json'))['result']['status'])")
+ echo "Enforcement mode: $ENFORCEMENT_MODE | Audit status: $STATUS"
+ if [ "$ENFORCEMENT_MODE" = "hard" ] && [ "$STATUS" != "OK" ]; then
+ echo "::error::Hard-block mode: oddkit_audit returned status=$STATUS. Failing job — fix the dead references or add explicit allowlist directives."
+ exit 1
+ fi
+ echo "Not failing the job (mode=$ENFORCEMENT_MODE, status=$STATUS)."You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 3af60ce. Configure here.

Phase 3 PR-3.1 of the link-rot elimination campaign — wires
oddkit_audit(live in prod since v0.26.0) into klappy.dev's CI as a soft-block quality gate. This is the first GitHub Actions workflow on the repo.## What this PR addsA single new file:.github/workflows/canon-quality.yml(~290 lines, well-commented).Triggers- Pull requests againstmainthat touchwritings/,canon/,odd/,docs/, or the workflow itself- Pushes tomainwith the same path filter- Manualworkflow_dispatchwith optionalscope_pathsinputBehavior1. POST atools/callforoddkit_audittohttps://oddkit.klappy.dev/mcp(default scope["writings/"]per spec v2.2)2. Retry once on transient HTTP failure (handles the DNS/cold-start cases observed during PR #146 verification)3. Parse the SSE response, extract the action envelope, save as a workflow artifact (audit-response, 14-day retention)4. Render a sticky PR comment viamarocchino/sticky-pull-request-comment@v2(header: canon-quality-audit): - Onstatus: OK— green checkmark, files-scanned count - Onstatus: FINDINGS— table grouped by file (max 50 rendered, link to artifact for the rest), columns: line, rule, occurrence, message - On infrastructure failure — explicit "could not be checked" comment so the absence of signal is visible5. Write a workflow run summary with the same metrics6. Enforcement gate: read repo variableAUDIT_ENFORCEMENT_MODE(defaults tosoft) -soft— never fails the job; the comment is the only signal -hard— fails the job whenstatus != OKMode flip mechanismPR-3.1 ships insoft(no repo variable set → default). After the observation cycle (3–5 PRs through the gate), PR-3.2 will setvars.AUDIT_ENFORCEMENT_MODEtohardat https://github.com/klappy/klappy.dev/settings/variables/actions — single repo-config change, no workflow file edit needed.## Self-test on this PRThis PR's path filter includes.github/workflows/canon-quality.yml, so the workflow runs on this PR itself. Expected first-run signal: 3 dead-references inwritings/(the same 3 surfaced by post-deploy verification of v0.26.0 — actual link-rot in canon, real Phase 3 input). The job will not fail because mode issoftby default.## Refs- Spec:klappy://docs/oddkit/specs/oddkit-audit(DRAFT v2.2 — landing in companion canon PR #148)- Promote that made this possible: klappy/oddkit#146 (oddkit v0.26.0)- Session ledger:klappy://odd/ledger/2026-04-27-link-rot-phase-2-shipped(also in klappy.dev#148)- Resume handoff:klappy://odd/handoffs/2026-04-27-link-rot-phase-2-promote-resume(also in klappy.dev#148)- Campaign sequencing:klappy://docs/planning/link-rot-elimination-campaign## After merge1. Observation cycle — let 3–5 PRs run through the soft gate. Watch finding rate, sticky-comment readability, and CI duration.2. Fix surfaced rot — the 3 known dead references inwritings/are the obvious first sweep; line-level allowlist (<!-- audit-allow: dead-reference reason="..." -->) is available for genuinely-deferred targets like upcoming-but-unpublished articles.3. PR-3.2 — setvars.AUDIT_ENFORCEMENT_MODEtohard(no workflow file change required) once observation is satisfactory.## Notes- The workflow usespython3 - <<PYheredocs for JSON manipulation rather than installingjq— keeps the runner setup time near zero.- All comments and step-summaries are read-only; the workflow haspull-requests: writeonly for the sticky comment.- Thesoftdefault is the safe-by-construction state — even if this PR has a typo that makesAUDIT_ENFORCEMENT_MODE=hardaccidentally read incorrectly, the worst case is reporting-only, not surprise PR blocks.Note
Medium Risk
Moderate risk because it introduces new CI behavior that calls an external production endpoint and can block merges when
AUDIT_ENFORCEMENT_MODE=hard, though it defaults to non-blocking soft mode and treats infra failures as non-fatal.Overview
Adds a new
Canon QualityGitHub Actions workflow (.github/workflows/canon-quality.yml) that triggers on PRs/pushes affecting canon content (and via manual dispatch) to runoddkit_auditagainsthttps://oddkit.klappy.dev/mcp.The workflow posts a sticky PR comment summarizing audit findings (grouped/capped with an artifact for full output), writes a run step summary, retries once on transient HTTP issues, and gates failures only when
AUDIT_ENFORCEMENT_MODEis set tohard(defaultsoft; infra failures don’t fail the job).Reviewed by Cursor Bugbot for commit cbc41c3. Bugbot is set up for automated code reviews on this repo. Configure here.
Cursor Bugbot disposition (current HEAD
cbc41c38)Per
klappy://canon/constraints/release-validation-gateRule 1, every Bugbot finding requires a disposition. Bugbot has 4 review comments visible at HEAD; three are stale (Bugbot reviewed against an earlier commit and the fix already landed in a subsequent commit on this branch); one is dormant (real but unreachable per spec v2.2 deferral).2d9d09a381c0aa2bINPUT_SCOPE_PATHSenv var indirection (lines 53-56 at HEAD). Bugbot has not re-reviewed against the fix.3af60ceccbc41c38|| trueafter the curl invocation (line 97 at HEAD). The fix-forward commitcbc41c38was made in direct response to this finding and is the current HEAD.dc13e76db1ea3951if ! python3 - <<'PY' ... fiwrapping (lines 125-156 at HEAD). Routes parse failures through the sameaudit_failed=truepath as HTTP failures.dc13e76dSTATUS != "OK"which would includePARTIAL_INDEX. However, PARTIAL_INDEX is deferred per audit spec v2.2 (worker does not emit it). Gate is dormant code that cannot trigger until/unless PARTIAL_INDEX is un-deferred. Tracked at #153 with revisit trigger "any spec amendment that un-defers PARTIAL_INDEX from Output schema."Note on the stale finding pattern: this PR went through 4 commit cycles (mine → Cursor Agent fixes → operator commits → more Cursor Agent fixes) and Bugbot's review state didn't always re-evaluate against the latest HEAD. The findings are not lies — they were valid at the commits they reviewed — but they are no longer reachable in the current code. A future canon observation: when a PR has many fix-forward commits, expect Bugbot stale-state and verify each finding against HEAD before fixing-forward again.
✅ All findings dispositioned per Rule 1; cleared to merge.