Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "sdlc-wizard",
"source": ".",
"description": "SDLC enforcement for AI agents — TDD, planning, self-review, CI shepherd",
"version": "1.68.0",
"version": "1.69.0",
"author": {
"name": "Stefan Ayala"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sdlc-wizard",
"version": "1.68.0",
"version": "1.69.0",
"description": "SDLC enforcement for AI agents — TDD, planning, self-review, CI shepherd",
"author": {
"name": "Stefan Ayala",
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ jobs:
- name: Run prompt-hook-fires-once tests (#224)
run: ./tests/test-prompt-hook-fires-once.sh

- name: Run baseline-fires-once-per-session tests (token bloat audit)
run: ./tests/test-baseline-fires-once.sh

- name: Run community scanner tests (#207)
run: ./tests/test-community-scanner.sh

Expand Down
34 changes: 34 additions & 0 deletions .reviews/baseline-fires-once-001/round-1-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
**Findings**

1. **P1**: Same-session BASELINE suppression is not concurrency-safe.
Evidence: [sdlc-prompt-check.sh](/Users/stefanayala/sdlc-wizard/hooks/sdlc-prompt-check.sh:131) checks for the sentinel before emit, but [sdlc-prompt-check.sh](/Users/stefanayala/sdlc-wizard/hooks/sdlc-prompt-check.sh:164) writes it only after the `cat` block. A 50-process same-session race produced `baseline_outputs=13`, `sentinels=1`.
Certify condition: make the first-writer decision atomic, with best-effort fallback preserved, and add a parallel same-`session_id` regression test expecting exactly one BASELINE.

2. **P1**: Valid `session_id` input is ignored when `jq` is unavailable or broken.
Evidence: [sdlc-prompt-check.sh](/Users/stefanayala/sdlc-wizard/hooks/sdlc-prompt-check.sh:47) only reads stdin when `jq` exists, and [sdlc-prompt-check.sh](/Users/stefanayala/sdlc-wizard/hooks/sdlc-prompt-check.sh:51) extracts `session_id` through `jq`. With a fake failing `jq` and valid JSON stdin: `first_baseline=1`, `second_baseline=1`, `sentinels=0`.
Certify condition: either make `session_id` extraction work without optional `jq`, or explicitly enforce/document `jq` as a runtime dependency and test the dependency behavior.

**Checklist Evidence**

(a) Sequential gate shape is correct: sentinel check at lines 122-132, BASELINE at 137-158, write after emit at 159-168. EFFORT is outside at 90-100; SETUP is outside at 103-112.

(b) SETUP missing test uses sibling tmpdir: [test-baseline-fires-once.sh](/Users/stefanayala/sdlc-wizard/tests/test-baseline-fires-once.sh:110).

(c) Sanitization uses `tr -cd 'A-Za-z0-9._-'` at line 128. Manual malicious id created only `baseline-shown-....badtouchtmpownedstefanayala` inside cache.

(d) Prune is scoped to cache dir and filename pattern: line 167.

(e) Best-effort cache failure verified: cache-dir-is-file manual run returned `rc=0`, `stderr_bytes=0`, `baseline_count=1`.

(f) Version bump grep verified all 7 sites: `package.json:3`, `plugin.json:3`, `marketplace.json:16`, `SDLC.md:1`, `SDLC.md:10`, `CLAUDE_CODE_SDLC_WIZARD.md:2979`, `CLAUDE_CODE_SDLC_WIZARD.md:4058`.

(g) CI path is correct: [.github/workflows/ci.yml](/Users/stefanayala/sdlc-wizard/.github/workflows/ci.yml:218) runs `./tests/test-baseline-fires-once.sh`.

(h) Tests run:
`./tests/test-baseline-fires-once.sh`: 8 passed, 0 failed.
`./tests/test-audit-session-load.sh`: 9 passed, 0 failed.
`./tests/test-hooks.sh`: 154 passed, 0 failed.

Score: **6/10**

**NOT CERTIFIED**
16 changes: 16 additions & 0 deletions .reviews/baseline-fires-once-001/round-2-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
No blocking findings.

Finding 1: FIXED. The BASELINE claim is now atomic via noclobber at [hooks/sdlc-prompt-check.sh](/Users/stefanayala/sdlc-wizard/hooks/sdlc-prompt-check.sh:158), with the correct fallback tree: winner emits, existing sentinel suppresses, cache failure emits. The 50-parallel regression at [tests/test-baseline-fires-once.sh](/Users/stefanayala/sdlc-wizard/tests/test-baseline-fires-once.sh:190) passed and counted exactly one `SDLC BASELINE:`.

Finding 2: FIXED. Stdin is read independently of `jq`, `session_id` is extracted with grep/sed at [hooks/sdlc-prompt-check.sh](/Users/stefanayala/sdlc-wizard/hooks/sdlc-prompt-check.sh:51), and `jq` is only used for prompt parsing. The no-`jq` regression at [tests/test-baseline-fires-once.sh](/Users/stefanayala/sdlc-wizard/tests/test-baseline-fires-once.sh:218) passed. I also manually checked an escaped `session_id` mention inside `prompt`; it used the real top-level session id and suppressed the second fire.

Prior passes still hold: EFFORT and SETUP remain outside the BASELINE gate, setup-missing uses a sibling tmpdir, `tr -cd 'A-Za-z0-9._-'` sanitization remains in place, prune is scoped to `baseline-shown-*`, cache-file fallback returned `rc=0`, `baseline=1`, `stderr_bytes=0`, version bumps are consistent at `1.69.0`, and CI runs `./tests/test-baseline-fires-once.sh`.

Verification run:
`./tests/test-baseline-fires-once.sh`: 10 passed
`./tests/test-audit-session-load.sh`: 9 passed
`./tests/test-hooks.sh`: 154 passed

Notes for next review: `.reviews/response.json` does not match this handoff; it references `roadmap-96-phase2-001` and a different F-01. I used the handoff’s `fixes_applied` plus the actual previous review findings for this recheck.

Score: 9/10, CERTIFIED
53 changes: 53 additions & 0 deletions .reviews/preflight-baseline-fires-once-001.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Preflight Self-Review: BASELINE block fires once per CC session

## What changed

`hooks/sdlc-prompt-check.sh`:
- Extract `session_id` from stdin JSON alongside existing `prompt` extraction.
- Gate the static SDLC BASELINE `cat << 'EOF'` block on a per-session sentinel `$SDLC_WIZARD_CACHE_DIR/baseline-shown-<safe_sid>`.
- After first emit, `mkdir -p` cache dir + `touch` sentinel; prune sentinels older than 7d via `find -mtime +7 -delete`.
- Sanitize session_id with `tr -cd 'A-Za-z0-9._-'` (defense-in-depth — CC session_ids are UUIDs, but never trust stdin).
- Fallback when no session_id (legacy CC stdin or direct shell test): emit BASELINE every fire (current behavior).

## What did NOT change

- `_find-sdlc-root.sh` walk-up logic
- Effort-bump signal detector + nudge (lines 38-99)
- SETUP-NOT-COMPLETE warning (lines 103-112)
- ROADMAP #224 SDLC_HOOK_FIRE_LOG instrumentation
- Plugin-vs-project dedupe heuristic
- Any other hook in `hooks/`

## Self-review checklist

- [x] `tests/test-baseline-fires-once.sh` — 8/8 PASS (8 cases: first-fire, suppression, different-session re-emit, no-session-id back-compat, SETUP persistence, EFFORT-bump persistence, cache-isolation, byte-shrink)
- [x] `tests/test-hooks.sh` — 154/154 PASS (no regression in existing hook test suite)
- [x] `tests/test-prompt-hook-fires-once.sh` — 6/6 PASS (ROADMAP #224 instrumentation regression, includes byte-identical assertion)
- [x] `tests/test-audit-session-load.sh` — 9/9 PASS (`skills/update/SKILL.md` still under 5K-token threshold)
- [x] `tests/test-cli.sh` — 78/78 PASS
- [x] `tests/test-plugin.sh` — 25/25 PASS
- [x] `tests/test-doc-consistency.sh` — 35/35 PASS
- [x] Cache writes are best-effort: unwritable cache → falls back to current behavior (BASELINE keeps emitting), never errors to user
- [x] session_id sanitized before use in filename
- [x] Comment block on the gating logic explains the *why* (12K token saving, skill duplication once auto-invoked) and the constraints (SETUP/EFFORT-bump must keep firing)
- [x] Version bumped 1.68.0 → 1.69.0 across 7 metadata sites
- [x] `CHANGELOG.md` v1.69.0 entry written with behavior matrix
- [x] `skills/update/SKILL.md` changelog list updated with 1.69.0 entry, older entries collapsed to keep under 5K threshold
- [x] `.github/workflows/ci.yml` wires new test into validate job

## Specific things to verify in review

1. **Race conditions:** if CC parallelism could fire two `UserPromptSubmit` hooks concurrently for the same session_id, both could check `[ -f $sentinel ]` as false, both emit, both touch. Worst case is BASELINE emits twice on a single rare race. Acceptable, but flag if there's a cleaner pattern.

2. **Stale-cache leakage across CC restarts:** sentinel persists on disk. If a user runs the same CC `session_id` again after a restart (does CC ever reuse session_ids? — CC sessions are UUIDs, almost certainly unique), they'd see no BASELINE on their first prompt. Mitigation: 7-day prune. Verify the worst case is acceptable.

3. **Filename injection / path traversal:** session_id from stdin sanitized via `tr -cd 'A-Za-z0-9._-'`. Confirm this is sufficient. CC session_ids are UUIDs (lowercase hex + dashes), so the strip is purely defense-in-depth.

4. **Byte-shrink test (test 8):** asserts second fire output is `<` (first / 5). Currently 823 → 0 chars. If anyone adds new conditional output that fires post-suppression, the test will catch it. Verify the assertion is the right shape.

5. **Back-compat:** `tests/test-hooks.sh` Test 5 (line ~91 `test_sdlc_hook_size`) calls `"$HOOKS_DIR/sdlc-prompt-check.sh"` with NO stdin. The hook reaches `[ ! -t 0 ]` check, finds tty (when run from terminal/test), skips stdin parse, `SESSION_ID` stays empty, BASELINE emits every fire. Test still passes. Confirm I haven't created a hidden coupling.

## Known limitations

- Sentinel doesn't survive `rm -rf $SDLC_WIZARD_CACHE_DIR`. User who clears cache mid-session sees BASELINE re-emit on next prompt — acceptable.
- 7-day prune is on-emit, not periodic. A long-idle session whose sentinel ages out then resumes would see one re-emit. Acceptable.
19 changes: 12 additions & 7 deletions .reviews/response.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
{
"review_id": "roadmap-96-phase2-001",
"round": 3,
"responding_to": ".reviews/latest-review.md",
"review_id": "baseline-fires-once-001",
"round": 2,
"responding_to": ".reviews/baseline-fires-once-001/round-1-review.md",
"responses": [
{
"finding": "F-01",
"severity": "P1",
"finding": "1",
"action": "FIXED",
"summary": "Round 2 nit closed. Added test_strip_paths_gate_uses_candidate_strip_dir which runs shepherd with --compare-baseline AND --strip-paths, mocks ground-truth.sh to log argv, and asserts both paths are tmpdir-rooted: baseline matches `sdlc-baseline-strip.*tests/e2e/fixtures/test-repo$`, candidate matches `sdlc-candidate-strip.*tests/e2e/fixtures/test-repo$`. Without my fix, the candidate path would be cwd-relative `tests/e2e/fixtures/test-repo` — that mutation would fail this test.",
"files": ["tests/test-local-shepherd.sh:test_strip_paths_gate_uses_candidate_strip_dir"]
"summary": "Atomic claim via subshell `set -C` (noclobber) + `: > sentinel` redirect. Across N parallel fires with the same session_id, exactly one wins the claim and emits; the rest see file-exists and suppress. New regression test (test_concurrency_same_session_emits_once) fires 50 parallel invocations and asserts exactly 1 BASELINE in aggregated stdout.",
"evidence": "hooks/sdlc-prompt-check.sh:131 (claim block), tests/test-baseline-fires-once.sh:166-189 (50-parallel test)"
},
{
"finding": "2",
"action": "FIXED",
"summary": "Decoupled session_id extraction from jq. Stdin is now read whenever stdin is non-tty (regardless of jq); session_id extracted via grep/sed. Prompt extraction still requires jq (multi-line + arbitrary escape content). New regression test (test_session_id_works_without_jq) builds a restricted PATH excluding jq, fires hook twice with valid session_id JSON stdin, asserts first emits / second suppresses.",
"evidence": "hooks/sdlc-prompt-check.sh:46-66 (decoupled extraction), tests/test-baseline-fires-once.sh:191-228 (no-jq regression test using type -P for binary-only resolution)"
}
]
}
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to the SDLC Wizard.

> **Note:** This changelog is for humans to read. Don't manually apply these changes - just run the wizard ("Check for SDLC wizard updates") and it handles everything automatically.

## [1.69.0] - 2026-05-04

### Token-bloat fix: BASELINE block fires once per CC session

Cuts ~12K tokens/session of duplicate context for users with >3 prompts. The `SDLC BASELINE` block in `hooks/sdlc-prompt-check.sh` (~250 tokens) was firing on every `UserPromptSubmit` — once Claude has the SDLC skill auto-invoked (covers TodoWrite/confidence/workflow phases), every subsequent re-emission is pure duplication. Now gated by a per-`session_id` sentinel under `$SDLC_WIZARD_CACHE_DIR/baseline-shown-<id>`, pruned at 7d.

### Behavior

- **First prompt of a CC session** → BASELINE emits as before (cold-start nudge survives).
- **Subsequent prompts (same session_id)** → BASELINE suppressed.
- **New CC session (different session_id)** → BASELINE re-emits.
- **No session_id in stdin** (legacy CC, direct shell tests) → BASELINE emits every fire (back-compat preserved).
- `SETUP NOT COMPLETE` warning + `EFFORT BUMP REQUIRED` nudge **continue to fire every prompt** — they're dynamic state warnings, not static reminders.

### Files

- `hooks/sdlc-prompt-check.sh` — extracts `session_id` from stdin JSON; gates the static BASELINE block via per-session sentinel; prunes >7d sentinels on emit.
- `tests/test-baseline-fires-once.sh` (new — 8 cases covering first-fire, suppression, different-session re-emit, no-session-id back-compat, SETUP-warning persistence, EFFORT-bump persistence, cross-cache-dir isolation, byte-shrink verification).
- `.github/workflows/ci.yml` — wires new test into `validate` job.
- `CHANGELOG.md`, `SDLC.md`, `skills/update/SKILL.md`, `package.json`, `.claude-plugin/plugin.json` + `marketplace.json`, `CLAUDE_CODE_SDLC_WIZARD.md` (1.68.0 → 1.69.0).

### Notes

Discovered during ROADMAP #236 functional-bloat audit. Identified `sdlc-prompt-check.sh` as the #1 amplifier (every-prompt × 22 lines × N prompts). Audit method: measure cost × frequency, judge value — not blind delete-and-see. Per-prompt BASELINE failed cost/value once skill is loaded; conditional warnings + effort-bump detector earned their keep and stayed untouched. Other hooks (`model-effort-check`, `precompact-seam-check`, `token-spike-check`) are silent at healthy state — not bloat.

## [1.68.0] - 2026-05-04

### Closed (paperwork-stale roadmap rows)
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE_CODE_SDLC_WIZARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -2976,7 +2976,7 @@ If deployment fails or post-deploy verification catches issues:

**SDLC.md:**
```markdown
<!-- SDLC Wizard Version: 1.68.0 -->
<!-- SDLC Wizard Version: 1.69.0 -->
<!-- Setup Date: [DATE] -->
<!-- Completed Steps: step-0.1, step-0.2, step-0.4, step-1, step-2, step-3, step-4, step-5, step-6, step-7, step-8, step-9 -->
<!-- Git Workflow: [PRs or Solo] -->
Expand Down Expand Up @@ -4055,7 +4055,7 @@ Walk through updates? (y/n)
Store wizard state in `SDLC.md` as metadata comments (invisible to readers, parseable by Claude):

```markdown
<!-- SDLC Wizard Version: 1.68.0 -->
<!-- SDLC Wizard Version: 1.69.0 -->
<!-- Setup Date: 2026-01-24 -->
<!-- Completed Steps: step-0.1, step-0.2, step-1, step-2, step-3, step-4, step-5, step-6, step-7, step-8, step-9 -->
<!-- Git Workflow: PRs -->
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Thank you for your interest in improving the SDLC Wizard!
./tests/test-local-shepherd.sh && \
./tests/test-repo-complexity.sh && \
./tests/test-prompt-hook-fires-once.sh && \
./tests/test-baseline-fires-once.sh && \
./tests/test-community-scanner.sh && \
./tests/test-community-fetch.sh && \
./tests/test-ground-truth.sh && \
Expand Down
4 changes: 2 additions & 2 deletions SDLC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- SDLC Wizard Version: 1.68.0 -->
<!-- SDLC Wizard Version: 1.69.0 -->
<!-- Setup Date: 2026-01-24 -->
<!-- Completed Steps: step-0.1, step-0.2, step-1, step-2, step-3, step-4, step-5, step-6, step-7, step-8, step-9 -->
# SDLC Configuration
Expand All @@ -7,7 +7,7 @@

| Property | Value |
|----------|-------|
| Wizard Version | 1.68.0 |
| Wizard Version | 1.69.0 |
| Last Updated | 2026-05-04 |
| Claude Code Baseline | v2.1.111+ (required for Opus 4.7 / `opus[1m]`) |
| Recommended Model | `opus[1m]` (Opus 4.7, 1M context) — run `/model opus[1m]` |
Expand Down
69 changes: 66 additions & 3 deletions hooks/sdlc-prompt-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,25 @@ fi
EFFORT_CACHE_DIR="${SDLC_WIZARD_CACHE_DIR:-$HOME/.cache/sdlc-wizard}"
EFFORT_SIGNALS="$EFFORT_CACHE_DIR/effort-signals.log"
PROMPT_TEXT=""
if [ ! -t 0 ] && command -v jq > /dev/null 2>&1; then
SESSION_ID=""
# Read stdin once regardless of jq availability — session_id extraction
# is jq-independent (Codex round 1 P1: BASELINE gate failed when jq was
# missing or broken). Prompt extraction still needs jq because prompt
# content can contain arbitrary multi-line text + escapes.
if [ ! -t 0 ]; then
STDIN_JSON=$(cat)
if [ -n "$STDIN_JSON" ]; then
PROMPT_TEXT=$(printf '%s' "$STDIN_JSON" | jq -r '.prompt // empty' 2>/dev/null) || PROMPT_TEXT=""
# session_id is a UUID-shaped string with no escapable content
# in CC's stdin contract — regex extraction is sufficient.
# `tr -cd` later strips anything filename-unsafe, so a malformed
# input cannot escape the cache dir.
SESSION_ID=$(printf '%s' "$STDIN_JSON" \
| grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 \
| sed 's/.*"\([^"]*\)"$/\1/')
if command -v jq > /dev/null 2>&1; then
PROMPT_TEXT=$(printf '%s' "$STDIN_JSON" | jq -r '.prompt // empty' 2>/dev/null) || PROMPT_TEXT=""
fi
fi
fi
if [ -n "$PROMPT_TEXT" ]; then
Expand Down Expand Up @@ -109,7 +124,49 @@ SETUP
exit 0
fi

cat << 'EOF'
# Token-bloat fix: BASELINE block fires once per CC session (~250 tok × 50
# prompts = ~12K wasted tokens before this gate). Once Claude has the SDLC
# skill auto-invoked (covers TodoWrite/confidence/workflow), this static
# block is duplicate context. Sentinel is per-session_id so a fresh CC
# session re-emits the cold-start nudge. Without session_id (legacy CC, or
# direct shell tests with no JSON stdin), behavior is unchanged — emits
# every fire. SETUP-not-complete + EFFORT-bump branches above are NOT
# gated; they're dynamic state warnings that must fire every prompt.
#
# Concurrency: claim is atomic via `set -C` (noclobber) — the redirect
# `: > "$path"` create-or-fails. Across N parallel fires with the same
# session_id, exactly one wins the claim and emits BASELINE; the rest
# see file-exists and suppress. (Codex round 1 P1: previous "check then
# write after emit" pattern allowed N parallel fires to all emit.)
SHOULD_EMIT_BASELINE=1
BASELINE_SENTINEL=""
if [ -n "$SESSION_ID" ]; then
BASELINE_CACHE_DIR="${SDLC_WIZARD_CACHE_DIR:-$HOME/.cache/sdlc-wizard}"
# Strip path-traversal chars from session_id before using in filename
# (defense-in-depth — CC session_ids are UUIDs, but never trust stdin).
SAFE_SID=$(printf '%s' "$SESSION_ID" | tr -cd 'A-Za-z0-9._-')
if [ -n "$SAFE_SID" ]; then
BASELINE_SENTINEL="$BASELINE_CACHE_DIR/baseline-shown-${SAFE_SID}"
mkdir -p "$BASELINE_CACHE_DIR" 2>/dev/null || true
# Atomic create-or-fail: subshell sets noclobber so `: > "$path"`
# fails (rc≠0) if the file already exists. The full conditional
# tree:
# - claim succeeds → emit (we won the race)
# - claim fails AND file exists → suppress (someone else won)
# - claim fails AND file doesn't exist → cache unwritable;
# fall back to emit so user never loses cold-start nudge.
if (set -C; : > "$BASELINE_SENTINEL") 2>/dev/null; then
SHOULD_EMIT_BASELINE=1
elif [ -f "$BASELINE_SENTINEL" ]; then
SHOULD_EMIT_BASELINE=0
else
SHOULD_EMIT_BASELINE=1
fi
fi
fi

if [ "$SHOULD_EMIT_BASELINE" -eq 1 ]; then
cat << 'EOF'
SDLC BASELINE:
1. TodoWrite FIRST (plan tasks before coding)
2. STATE CONFIDENCE: HIGH/MEDIUM/LOW
Expand All @@ -130,3 +187,9 @@ Workflow phases:

Quick refs: SDLC.md | TESTING.md | *_PLAN.md for feature
EOF
# Prune sentinels older than 7d so cache doesn't grow forever.
# Best-effort: errors silently swallowed.
if [ -n "$BASELINE_SENTINEL" ]; then
find "$BASELINE_CACHE_DIR" -name 'baseline-shown-*' -type f -mtime +7 -delete 2>/dev/null || true
fi
fi
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentic-sdlc-wizard",
"version": "1.68.0",
"version": "1.69.0",
"description": "SDLC enforcement for Claude Code — hooks, skills, and wizard setup in one command",
"bin": {
"sdlc-wizard": "cli/bin/sdlc-wizard.js"
Expand Down
Loading