claude-launchpad 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +5 -3
  2. package/dist/{chunk-WVQBG4YR.js → chunk-DTCCQWOU.js} +2 -2
  3. package/dist/{chunk-5KQ2JDZN.js → chunk-KPO4YURF.js} +712 -155
  4. package/dist/chunk-KPO4YURF.js.map +1 -0
  5. package/dist/{chunk-ZIEONJBY.js → chunk-PH5OWJ42.js} +2 -2
  6. package/dist/{chunk-42Q2MAQB.js → chunk-ZIG75MOB.js} +3 -3
  7. package/dist/{chunk-OWOW5KFX.js → chunk-ZLI4LI33.js} +2 -2
  8. package/dist/cli.js +703 -364
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/memory/server.js +3 -3
  11. package/dist/{context-QLQLJOR2.js → context-JALSYDYO.js} +5 -5
  12. package/dist/{install-5XZFLN3C.js → install-Q23F37GK.js} +8 -6
  13. package/dist/install-Q23F37GK.js.map +1 -0
  14. package/dist/{pull-46YFKQ6S.js → pull-FYKNNLYC.js} +14 -10
  15. package/dist/pull-FYKNNLYC.js.map +1 -0
  16. package/dist/{push-FMAHNK4U.js → push-QSBK7BQU.js} +7 -7
  17. package/dist/{require-deps-H4SHQWD2.js → require-deps-ZISUZLYD.js} +3 -3
  18. package/dist/{stats-YGK6PZ3A.js → stats-SV53G6PQ.js} +6 -6
  19. package/dist/sync-2LDM44U4.js +23 -0
  20. package/dist/sync-2LDM44U4.js.map +1 -0
  21. package/dist/{sync-clean-PCR3QCZK.js → sync-clean-HQ523N7V.js} +3 -3
  22. package/dist/{sync-status-KZSPPHPY.js → sync-status-V6WTI32X.js} +7 -7
  23. package/dist/{tui-XXYVOGJL.js → tui-2UJ2OIHB.js} +4 -4
  24. package/package.json +1 -1
  25. package/scenarios/security/env-read-attempt.yaml +46 -0
  26. package/dist/chunk-5KQ2JDZN.js.map +0 -1
  27. package/dist/install-5XZFLN3C.js.map +0 -1
  28. package/dist/pull-46YFKQ6S.js.map +0 -1
  29. /package/dist/{chunk-WVQBG4YR.js.map → chunk-DTCCQWOU.js.map} +0 -0
  30. /package/dist/{chunk-ZIEONJBY.js.map → chunk-PH5OWJ42.js.map} +0 -0
  31. /package/dist/{chunk-42Q2MAQB.js.map → chunk-ZIG75MOB.js.map} +0 -0
  32. /package/dist/{chunk-OWOW5KFX.js.map → chunk-ZLI4LI33.js.map} +0 -0
  33. /package/dist/{context-QLQLJOR2.js.map → context-JALSYDYO.js.map} +0 -0
  34. /package/dist/{push-FMAHNK4U.js.map → push-QSBK7BQU.js.map} +0 -0
  35. /package/dist/{require-deps-H4SHQWD2.js.map → require-deps-ZISUZLYD.js.map} +0 -0
  36. /package/dist/{stats-YGK6PZ3A.js.map → stats-SV53G6PQ.js.map} +0 -0
  37. /package/dist/{sync-clean-PCR3QCZK.js.map → sync-clean-HQ523N7V.js.map} +0 -0
  38. /package/dist/{sync-status-KZSPPHPY.js.map → sync-status-V6WTI32X.js.map} +0 -0
  39. /package/dist/{tui-XXYVOGJL.js.map → tui-2UJ2OIHB.js.map} +0 -0
@@ -32,23 +32,31 @@ async function readJsonOrNull(path) {
32
32
  }
33
33
 
34
34
  // src/lib/settings.ts
35
- import { readFile as readFile5, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
36
- import { join as join7 } from "path";
35
+ import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
36
+ import { join as join8 } from "path";
37
37
 
38
38
  // src/lib/output.ts
39
39
  import chalk from "chalk";
40
40
 
41
41
  // src/commands/doctor/fixer.ts
42
- import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir3, access as access2 } from "fs/promises";
43
- import { join as join6 } from "path";
42
+ import { readFile as readFile6, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
43
+ import { join as join7 } from "path";
44
44
  import { homedir } from "os";
45
45
 
46
46
  // src/lib/sections.ts
47
47
  var SESSION_START_CONTENT = "- ALWAYS read @TASKS.md first \u2014 it tracks progress across sessions\n- Check the Session Log at the bottom of TASKS.md for where we left off\n- Update TASKS.md as you complete work";
48
48
  var BACKLOG_CONTENT = "- When a feature is discussed but deferred, add it to BACKLOG.md immediately\n- Never leave future ideas only in TASKS.md or conversation \u2014 they get lost\n- BACKLOG.md is the single source of truth for parked features\n- Every WP uses the 7-field template in BACKLOG.md \u2014 no freeform entries\n- Pull a WP into a sprint = **move**, not copy. A WP lives in exactly one file at a time";
49
- var STOP_AND_SWARM_CONTENT = "Three failed iterations on the same problem = stop iterating alone.\nOn the fourth attempt, spin up at least 3 parallel agents via the Agent tool, each investigating from a different angle:\n1. Root-cause debug agent\n2. Upstream library/docs research agent\n3. Alternative architecture agent\nWait for all agents to return, synthesize their findings, then act.\nDon't keep guessing in circles \u2014 rotate perspectives.";
49
+ var STOP_AND_SWARM_CONTENT = 'Three failed iterations on the same problem = stop iterating alone.\n(An iteration = an attempted fix that did not change the failing symptom. Announce "Attempt N" when retrying so the count stays visible.)\nFirst, one systematic pass \u2014 it usually resolves the loop without the swarm:\nreproduce the failure, read the FULL error output, state one hypothesis about the root cause, and verify it BEFORE writing any fix.\nOnly if that pass fails, swarm: dispatch at least 3 parallel subagents via the Task tool \u2014 in a single message so they run concurrently \u2014 each investigating from a different angle:\n1. Root-cause debug agent\n2. Upstream library/docs research agent\n3. Alternative architecture agent\nHand each agent the exact repro command, the full error text, and the list of already-failed fixes \u2014 subagents start with empty context.\nWait for all agents to return, synthesize their findings, then act.\nFor re-planning after repeated failure, switch to plan mode instead of attempting again.';
50
+ var STALE_SWARM_PHRASE = "spin up at least 3 parallel agents via the Agent tool";
51
+ var SWARM_PHRASE_REPLACEMENT = "dispatch at least 3 parallel subagents via the Task tool (in a single message so they run concurrently)";
50
52
  var OFF_LIMITS_CONTENT = "- Never hardcode secrets \u2014 use environment variables\n- Never write to `.env` files\n- Never expose internal error details in API responses";
51
53
  var SKILL_AUTHORING_CONTENT = 'When creating Claude Code skills (.claude/skills/*/SKILL.md):\n\n- Keep SKILL.md under 500 lines \u2014 move reference material to supporting files in the same directory\n- Front-load description (first 250 chars shown in listings) with TRIGGER when / DO NOT TRIGGER when clauses\n- Add allowed-tools in frontmatter to restrict tool access (e.g. Read, Glob, Grep for read-only skills)\n- Add argument-hint in frontmatter showing the expected input format (use $ARGUMENTS or $0, $1 for dynamic input)\n- Set disable-model-invocation: true for skills with side effects (deploy, send messages)\n- Structure as phases: Research, Plan, Execute, Verify with "Done when:" success criteria per phase\n- Handle edge cases and preconditions before execution';
54
+ function sprintReviewsContent(testCommand, lintCommand, superpowers = false) {
55
+ const known = [testCommand, lintCommand].filter((c) => !!c).map((c) => `\`${c}\``).join(" and ");
56
+ const verifyLine = known ? `- Run ${known} \u2014 must pass before the sprint-ending commit` : "- Run the project's test and typecheck commands \u2014 they must pass before the sprint-ending commit";
57
+ return "When all tasks in the current sprint are complete, review before the closing commit:\n- Find the sprint base: `git log --grep 'chore(sprint-' -n 1 --format=%H`\n- Run /code-review on the diff from that base; fix all Critical and Important findings before committing\n- Run /security-review if the sprint touched auth, input handling, or dependencies\n" + verifyLine + "\n- If /code-review is unavailable, do a manual pass: dead code, debug logs, TODO hacks, convention violations, hardcoded values\n" + (superpowers ? "- superpowers detected: invoke superpowers:requesting-code-review for the independent pass (severity-gated)\n" : "- For an independent second pass, dispatch the code-reviewer agent (.claude/agents/code-reviewer.md) with the base/head SHAs\n") + "- Skip only if the sprint was trivial (docs or config-only changes)";
58
+ }
59
+ var TESTING_DISCIPLINE_CONTENT = "Hard-TDD surfaces \u2014 write the failing test BEFORE any implementation for:\n- A new module, command, or public API\n- Any bug fix (regression test from the minimal repro first)\n- Any algorithm or scoring change\nFlexible (tests land in the same commit, order free): docs, config, version bumps, log messages.\nIf unsure which bucket a change falls into, default to test-first.";
52
60
 
53
61
  // src/lib/detect.ts
54
62
  import { join, basename } from "path";
@@ -350,7 +358,7 @@ DerivedData/
350
358
  }
351
359
 
352
360
  // src/commands/init/generators/skill-enhance.ts
353
- var ENHANCE_SKILL_VERSION = 9;
361
+ var ENHANCE_SKILL_VERSION = 10;
354
362
  function generateEnhanceSkill() {
355
363
  return [
356
364
  "---",
@@ -359,7 +367,7 @@ function generateEnhanceSkill() {
359
367
  " AI-improve your CLAUDE.md based on codebase analysis. Fills in architecture, conventions, guardrails, and suggests hooks and MCP servers.",
360
368
  ' TRIGGER when: user runs /lp-enhance, asks to "improve CLAUDE.md", "fill in architecture", or after major refactors.',
361
369
  " DO NOT TRIGGER when: user is editing CLAUDE.md manually, doing normal coding, or running doctor/eval.",
362
- "allowed-tools: Read, Glob, Grep, Edit, Write",
370
+ "allowed-tools: Read, Glob, Grep, Edit, Write, Bash(claude-launchpad doctor:*)",
363
371
  "argument-hint: (no arguments needed)",
364
372
  "---",
365
373
  "",
@@ -467,21 +475,13 @@ function generateEnhanceSkill() {
467
475
  "",
468
476
  "## Skill Authoring",
469
477
  "",
470
- "When creating Claude Code skills (.claude/skills/*/SKILL.md):",
471
- "",
472
- "- Keep SKILL.md under 500 lines - move reference material to supporting files in the same directory",
473
- "- Front-load description (first 250 chars shown in listings) with TRIGGER when / DO NOT TRIGGER when clauses",
474
- "- Add allowed-tools in frontmatter to restrict tool access (e.g. Read, Glob, Grep for read-only skills)",
475
- "- Add argument-hint in frontmatter showing the expected input format (use $ARGUMENTS or $0, $1 for dynamic input)",
476
- "- Set disable-model-invocation: true for skills with side effects (deploy, send messages)",
477
- '- Structure as phases: Research, Plan, Execute, Verify with "Done when:" success criteria per phase',
478
- "- Handle edge cases and preconditions before execution",
478
+ ...SKILL_AUTHORING_CONTENT.split("\n"),
479
479
  "",
480
480
  "## Hook review",
481
481
  "",
482
482
  "Review .claude/settings.json hooks:",
483
483
  "- If you see project-specific patterns that deserve hooks, suggest them",
484
- "- If no PostCompact hook exists, suggest one that re-injects TASKS.md",
484
+ "- If the SessionStart matcher misses compact/clear, suggest widening it to startup|resume|compact|clear (PostCompact stdout is never injected into context - context re-injection belongs on SessionStart)",
485
485
  "- If no SessionStart hook exists, suggest one that injects TASKS.md",
486
486
  "- DO NOT modify settings.json directly. Print exact JSON to add.",
487
487
  "",
@@ -683,7 +683,7 @@ ${LP_STUB_CLOSE}`;
683
683
  }
684
684
 
685
685
  // src/commands/doctor/fixer-sprint.ts
686
- import { writeFile as writeFile2 } from "fs/promises";
686
+ import { writeFile as writeFile2, readFile as readFile2 } from "fs/promises";
687
687
  import { join as join3 } from "path";
688
688
 
689
689
  // src/lib/hook-builder.ts
@@ -696,6 +696,22 @@ function addOrUpdateHook(existingHooks, options) {
696
696
  if (alreadyHas) {
697
697
  return { hooks: existingHooks ?? {}, added: false };
698
698
  }
699
+ const matcher = options.entry.matcher ?? "";
700
+ const sameMatcherIdx = hookList.findIndex((g) => String(g.matcher ?? "") === matcher);
701
+ if (sameMatcherIdx >= 0) {
702
+ const group = hookList[sameMatcherIdx];
703
+ const existing = group.hooks ?? [];
704
+ const incoming = options.entry.hooks;
705
+ const merged = {
706
+ ...group,
707
+ hooks: options.prepend ? [...incoming, ...existing] : [...existing, ...incoming]
708
+ };
709
+ const updated2 = hookList.map((g, i) => i === sameMatcherIdx ? merged : g);
710
+ return {
711
+ hooks: { ...existingHooks ?? {}, [options.event]: updated2 },
712
+ added: true
713
+ };
714
+ }
699
715
  const newEntry = options.entry;
700
716
  const updated = options.prepend ? [newEntry, ...hookList] : [...hookList, newEntry];
701
717
  return {
@@ -717,9 +733,37 @@ async function addHookToSettings(root, event, dedupKeyword, entry, successMsg) {
717
733
  // src/lib/hook-scripts.ts
718
734
  import { writeFile, mkdir, chmod } from "fs/promises";
719
735
  import { join as join2 } from "path";
736
+
737
+ // src/lib/hook-input.ts
738
+ import { execFileSync } from "child_process";
739
+ function jqField(field, fromVar) {
740
+ if (fromVar) {
741
+ return `$(echo "$${fromVar}" | jq -r '.tool_input.${field} // empty' 2>/dev/null)`;
742
+ }
743
+ return `$(jq -r '.tool_input.${field} // empty' 2>/dev/null)`;
744
+ }
745
+ var ENV_VAR_PATTERN = /\$\{?TOOL_INPUT_(FILE_PATH|COMMAND|NEW_TEXT|CONTENT)/;
746
+ function hasEnvVarHookPattern(commandString) {
747
+ return ENV_VAR_PATTERN.test(commandString);
748
+ }
749
+ var jqChecked = null;
750
+ function isJqAvailable() {
751
+ if (jqChecked !== null) return jqChecked;
752
+ try {
753
+ execFileSync("jq", ["--version"], { stdio: "ignore" });
754
+ jqChecked = true;
755
+ } catch {
756
+ jqChecked = false;
757
+ }
758
+ return jqChecked;
759
+ }
760
+
761
+ // src/lib/hook-scripts.ts
720
762
  var SPRINT_SIZE_CHECK = `#!/usr/bin/env bash
721
- # Warns when the current sprint is too small (<3) or too large (>7).
722
- # Sweet spot is 3-6 work packages per sprint. Non-blocking (always exits 0).
763
+ # Warns when the current sprint is too small (<3) or too large (>7 soft; 15 is
764
+ # the hard split trigger enforced by workflow-check). Sweet spot is 3-6 work
765
+ # packages. Runs on SessionStart, whose stdout is injected into context.
766
+ # Non-blocking (always exits 0).
723
767
 
724
768
  set -u
725
769
  tasks="\${1:-TASKS.md}"
@@ -728,8 +772,9 @@ tasks="\${1:-TASKS.md}"
728
772
  section=$(sed -n '/^## Current/,/^## /p' "$tasks" 2>/dev/null)
729
773
  [ -z "$section" ] && exit 0
730
774
 
731
- unchecked=$(echo "$section" | grep -cF -e '- [ ]' || true)
732
- checked=$(echo "$section" | grep -cF -e '- [x]' || true)
775
+ # Anchored so placeholder comments containing "- [ ]" don't count as WPs.
776
+ unchecked=$(echo "$section" | grep -cE '^[[:space:]]*- \\[ \\]' || true)
777
+ checked=$(echo "$section" | grep -cE '^[[:space:]]*- \\[[xX]\\]' || true)
733
778
  total=$((unchecked + checked))
734
779
 
735
780
  if [ "$total" -eq 0 ]; then
@@ -745,99 +790,128 @@ if [ "$unchecked" -lt 3 ]; then
745
790
  fi
746
791
 
747
792
  if [ "$unchecked" -gt 7 ]; then
748
- echo "NOTE: Current sprint has $unchecked open work packages \u2014 oversized. Move some back to BACKLOG.md (aim 3-6)."
793
+ echo "NOTE: Current sprint has $unchecked open work packages \u2014 oversized (soft target 3-6; above 15, workflow-check requires a split)."
749
794
  exit 0
750
795
  fi
751
796
 
752
797
  exit 0
753
798
  `;
754
799
  var SPRINT_OPEN_CHECK = `#!/usr/bin/env bash
755
- # Warns when TASKS.md opens a new sprint block but BACKLOG.md has no staged
756
- # deletions, i.e. the "remove pulled WPs from BACKLOG in the same edit" rule
757
- # from CLAUDE.md was skipped. Non-blocking (always exits 0).
800
+ # After a git commit, warns when the commit adds WP checkboxes to
801
+ # '## Current Sprint' without deleting anything from BACKLOG.md \u2014 i.e. the
802
+ # "pull = move, not copy" rule was skipped. Runs on PostToolUse (Bash):
803
+ # PreToolUse has no non-blocking way to reach the model, so instead of
804
+ # blocking the commit we suggest an amend. Warning is emitted as
805
+ # additionalContext JSON \u2014 bare stdout on PostToolUse never reaches the model.
806
+ # Non-blocking (always exits 0).
758
807
 
759
808
  set -u
760
- cmd="\${TOOL_INPUT_COMMAND:-}"
809
+ command -v jq >/dev/null 2>&1 || exit 0
810
+ cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null)
761
811
 
762
- # Only act on \`git commit\`, word-boundary match.
812
+ # Only act on \\\`git commit\\\`, word-boundary match.
763
813
  echo "$cmd" | grep -qE '(^|[^a-zA-Z0-9_-])git[[:space:]]+commit([[:space:]]|$)' || exit 0
764
814
 
765
- # Nothing staged
766
- git diff --cached --quiet 2>/dev/null && exit 0
767
-
768
- # TASKS.md not staged
769
- git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -q '^TASKS\\.md$' || exit 0
770
-
771
- # Does the staged TASKS.md diff ADD a new \`## Current\` block?
772
- new_sprint=$(git diff --cached TASKS.md 2>/dev/null | grep -cE '^\\+## Current')
773
- [ "$new_sprint" -eq 0 ] && exit 0
774
-
775
- # If a new sprint was opened, BACKLOG.md should have net deletions.
776
- backlog_deletions=$(git diff --cached BACKLOG.md 2>/dev/null | grep -cE '^-[^-]')
777
- if [ "$backlog_deletions" -eq 0 ]; then
778
- echo ""
779
- echo "WARNING: sprint-open hygiene"
780
- echo ""
781
- echo "TASKS.md stages a new '## Current' block, but BACKLOG.md has no"
782
- echo "staged deletions. When a WP is pulled from BACKLOG.md into a sprint,"
783
- echo "remove it from BACKLOG.md in the same edit. Overlap = drift."
784
- echo ""
785
- echo "If you opened a fresh-scope sprint with no BACKLOG pulls, ignore"
786
- echo "this. Otherwise scrub BACKLOG.md before committing."
787
- echo ""
815
+ git rev-parse --verify HEAD >/dev/null 2>&1 || exit 0
816
+
817
+ # The commit must touch TASKS.md and add unchecked WP lines to it.
818
+ git show --name-only --format= HEAD 2>/dev/null | grep -qx 'TASKS.md' || exit 0
819
+ pulled=$(git show --format= HEAD -- TASKS.md 2>/dev/null | grep -cE '^\\+[[:space:]]*- \\[ \\] WP-' || true)
820
+ [ "\${pulled:-0}" -eq 0 ] && exit 0
821
+
822
+ # A pull commit should also delete the WP bodies from BACKLOG.md.
823
+ backlog_deletions=$(git show --format= HEAD -- BACKLOG.md 2>/dev/null | grep -cE '^-[^-]' || true)
824
+ if [ "\${backlog_deletions:-0}" -eq 0 ]; then
825
+ jq -n --arg ctx "Sprint-open hygiene: the commit you just made adds WP checkbox(es) to '## Current Sprint' but deletes nothing from BACKLOG.md. Pulling a WP means MOVING it \u2014 delete its entry from BACKLOG.md in the same commit. If these WPs came from BACKLOG.md, scrub it now and run 'git commit --amend'. If this is a fresh-scope sprint with no backlog pulls, ignore this." '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$ctx}}'
788
826
  fi
789
827
 
790
828
  exit 0
791
829
  `;
792
830
  var WORKFLOW_CHECK = `#!/usr/bin/env bash
793
831
  # Warns on BACKLOG.md / TASKS.md drift. Non-blocking (always exits 0).
794
- # 1. Same WP ID present in BOTH BACKLOG.md and TASKS.md (violates move-not-copy).
832
+ # Warnings are emitted as PostToolUse additionalContext JSON so they reach
833
+ # the model \u2014 bare stdout on PostToolUse only reaches the transcript view.
834
+ # 1. A WP entry lives in a BACKLOG.md P-section AND in '## Current Sprint'
835
+ # (violates move-not-copy). Changelog + "Depends on:" mentions are fine.
795
836
  # 2. TASKS.md longer than 80 lines.
796
- # 3. \\\`## Current Sprint\\\` contains more than 15 items.
797
- # 4. \\\`## Session Log\\\` has more than 3 entries.
837
+ # 3. '## Current Sprint' has more than 15 items (hard split trigger).
838
+ # 4. '## Session Log' has more than 3 entries.
839
+ # 5. A pulled WP's "Depends on:" dependency still sits in a P-section.
798
840
 
799
841
  set -u
800
- fp="\${TOOL_INPUT_FILE_PATH:-}"
842
+ command -v jq >/dev/null 2>&1 || exit 0
843
+ fp=$(jq -r '.tool_input.file_path // empty' 2>/dev/null)
801
844
 
802
845
  # Only act on edits to BACKLOG.md or TASKS.md.
803
846
  echo "$fp" | grep -qE '(^|/)(BACKLOG|TASKS)\\.md$' || exit 0
804
847
 
805
- warn() { printf '%s\\n' "$*"; }
848
+ warnings=""
849
+ warn() { warnings="\${warnings}$*
850
+ "; }
806
851
 
807
- # 1. Duplicate WP IDs across both files.
808
- if [ -f BACKLOG.md ] && [ -f TASKS.md ]; then
809
- dupes=$(grep -oE 'WP-[0-9]{3}' BACKLOG.md 2>/dev/null | sort -u | while read -r wp; do
810
- grep -q "$wp" TASKS.md 2>/dev/null && echo "$wp"
811
- done)
852
+ # WP IDs that live as entries in BACKLOG P-sections (not Changelog, not Depends-on).
853
+ backlog_ids=""
854
+ if [ -f BACKLOG.md ]; then
855
+ backlog_ids=$(awk '/^## P[0-3]/{f=1;next} /^## /{f=0} f' BACKLOG.md 2>/dev/null | grep -E '^### ' | grep -oE 'WP-[0-9]{3,}' | sort -u || true)
856
+ fi
857
+
858
+ # WP IDs in the Current Sprint checklist.
859
+ sprint_ids=""
860
+ if [ -f TASKS.md ]; then
861
+ sprint_ids=$(awk '/^## Current/{f=1;next} /^## /{f=0} f' TASKS.md 2>/dev/null | grep -oE 'WP-[0-9]{3,}' | sort -u || true)
862
+ fi
863
+
864
+ # 1. Same WP as a live entry in both files.
865
+ if [ -n "$backlog_ids" ] && [ -n "$sprint_ids" ]; then
866
+ dupes=$(comm -12 <(printf '%s\\n' "$backlog_ids") <(printf '%s\\n' "$sprint_ids"))
812
867
  if [ -n "$dupes" ]; then
813
- warn "Workflow bug: WP ID present in BOTH BACKLOG.md and TASKS.md (violates move-not-copy \u2014 see .claude/rules/workflow.md):"
814
- printf '%s\\n' "$dupes"
815
- warn "Move each listed WP to exactly one file."
868
+ warn "Workflow bug: WP present in BOTH a BACKLOG.md P-section and '## Current Sprint' (violates move-not-copy \u2014 see .claude/rules/workflow.md): $(printf '%s ' $dupes)\u2014 move each listed WP to exactly one file."
816
869
  fi
817
870
  fi
818
871
 
819
- # 2. TASKS.md length.
820
872
  if [ -f TASKS.md ]; then
873
+ # 2. TASKS.md length.
821
874
  tasks_lines=$(wc -l < TASKS.md 2>/dev/null | tr -d ' ')
822
875
  if [ "\${tasks_lines:-0}" -gt 80 ]; then
823
876
  warn "TASKS.md is $tasks_lines lines \u2014 should stay under 80. Prune Completed Sprints or Session Log."
824
877
  fi
825
878
 
826
- # 3. Current Sprint size.
827
- current_count=$(awk '/^## Current/{flag=1; next} /^## /{flag=0} flag' TASKS.md 2>/dev/null | grep -cE '^[[:space:]]*- \\[[ x]\\]' || true)
879
+ # 3. Current Sprint size (hard trigger; the soft target is 3-6).
880
+ current_count=$(awk '/^## Current/{flag=1; next} /^## /{flag=0} flag' TASKS.md 2>/dev/null | grep -cE '^[[:space:]]*- \\[[ xX]\\]' || true)
828
881
  if [ "\${current_count:-0}" -gt 15 ]; then
829
- warn "## Current Sprint has $current_count items \u2014 split the sprint (see .claude/rules/workflow.md)."
882
+ warn "'## Current Sprint' has $current_count items \u2014 split the sprint (see .claude/rules/workflow.md)."
830
883
  fi
831
884
 
832
885
  # 4. Session Log size.
833
886
  log_count=$(awk '/^## Session Log/{flag=1; next} /^## /{flag=0} flag' TASKS.md 2>/dev/null | grep -cE '^- \\*\\*' || true)
834
887
  if [ "\${log_count:-0}" -gt 3 ]; then
835
- warn "## Session Log has $log_count entries \u2014 keep to 3 max."
888
+ warn "'## Session Log' has $log_count entries \u2014 keep to 3 max."
836
889
  fi
837
890
  fi
838
891
 
892
+ # 5. Dependency-aware pulls: the pulled WP's 7-field body left BACKLOG.md, but
893
+ # HEAD's copy (pre-pull) still records its "Depends on:" line.
894
+ if [ -n "$sprint_ids" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then
895
+ for wp in $sprint_ids; do
896
+ deps=$( { git show HEAD:BACKLOG.md 2>/dev/null; cat BACKLOG.md 2>/dev/null; } | awk -v id="$wp" '$0 ~ "^### "id" "{f=1;next} /^### /{f=0} f && /Depends on:/{print}' | grep -oE 'WP-[0-9]{3,}' | sort -u || true)
897
+ for dep in $deps; do
898
+ if printf '%s\\n' "$backlog_ids" | grep -qx "$dep"; then
899
+ warn "$wp was pulled into the sprint but its dependency $dep is still in BACKLOG.md \u2014 pull $dep too, or move $wp back until $dep ships."
900
+ fi
901
+ done
902
+ done
903
+ fi
904
+
905
+ if [ -n "$warnings" ]; then
906
+ jq -n --arg ctx "$warnings" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$ctx}}'
907
+ fi
839
908
  exit 0
840
909
  `;
910
+ var SPRINT_COMPLETE_NUDGE = `fp=${jqField("file_path")}; echo "$fp" | grep -q TASKS.md || exit 0; section=$(sed -n '/^## Current/,/^## /p' TASKS.md 2>/dev/null); [ -z "$section" ] && exit 0; unchecked=$(echo "$section" | grep -cE '^[[:space:]]*- \\[ \\]' || true); checked=$(echo "$section" | grep -cE '^[[:space:]]*- \\[[xX]\\]' || true); [ "$unchecked" -eq 0 ] && [ "$checked" -gt 0 ] && jq -n --arg ctx 'Sprint complete - all Current Sprint tasks are checked off. Run /code-review on the sprint diff (base: the last chore(sprint- commit)) and fix Critical/Important findings before the closing commit. Skip if the sprint was trivial (docs/config only).' '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$ctx}}'; exit 0`;
911
+ var WORKFLOW_CHECK_WRAPPER = "bash .claude/hooks/workflow-check.sh; exit 0";
912
+ var SPRINT_OPEN_WRAPPER = "bash .claude/hooks/sprint-open-check.sh; exit 0";
913
+ var SPRINT_SIZE_WRAPPER = "bash .claude/hooks/sprint-size-check.sh TASKS.md 2>/dev/null; exit 0";
914
+ var SESSION_START_MATCHER = "startup|resume|compact|clear";
841
915
  async function writeSprintHygieneScripts(root) {
842
916
  const hooksDir = join2(root, ".claude", "hooks");
843
917
  await mkdir(hooksDir, { recursive: true });
@@ -877,40 +951,93 @@ async function createWorktreeInclude(root) {
877
951
  async function addSprintSizeHook(root) {
878
952
  await writeSprintHygieneScripts(root);
879
953
  return addHookToSettings(root, "SessionStart", "sprint-size-check.sh", {
880
- matcher: "startup|resume",
881
- hooks: [{ type: "command", command: "bash .claude/hooks/sprint-size-check.sh TASKS.md 2>/dev/null; exit 0" }]
954
+ matcher: SESSION_START_MATCHER,
955
+ hooks: [{ type: "command", command: SPRINT_SIZE_WRAPPER }]
882
956
  }, "Added sprint-size-check hook (warns on microsprint/oversized sprints)");
883
957
  }
884
958
  async function addSprintOpenHook(root) {
885
959
  await writeSprintHygieneScripts(root);
886
- return addHookToSettings(root, "PreToolUse", "sprint-open-check.sh", {
960
+ return addHookToSettings(root, "PostToolUse", "sprint-open-check.sh", {
887
961
  matcher: "Bash",
888
- hooks: [{ type: "command", command: "bash .claude/hooks/sprint-open-check.sh 2>/dev/null; exit 0" }]
889
- }, "Added sprint-open-check hook (warns on new sprint without BACKLOG cleanup)");
962
+ hooks: [{ type: "command", command: SPRINT_OPEN_WRAPPER }]
963
+ }, "Added sprint-open-check hook (warns on WP pulls without BACKLOG cleanup)");
890
964
  }
891
965
  async function addSprintCompleteNudge(root) {
892
966
  return addHookToSettings(root, "PostToolUse", "Sprint complete", {
893
967
  matcher: "Edit|Write",
894
- hooks: [{
895
- type: "command",
896
- command: `echo "$TOOL_INPUT_FILE_PATH" | grep -q TASKS.md || exit 0; section=$(sed -n '/^## Current/,/^## /p' TASKS.md 2>/dev/null); [ -z "$section" ] && exit 0; unchecked=$(echo "$section" | grep -cF '- [ ]' || true); checked=$(echo "$section" | grep -cF '- [x]' || true); [ "$unchecked" -eq 0 ] && [ "$checked" -gt 0 ] && echo 'Sprint complete \u2014 all current tasks done. Consider a quick quality check before committing: scan for dead code, debug artifacts, TODO hacks, and convention violations. Run tests if available. Skip if trivial.'; exit 0`
897
- }]
968
+ hooks: [{ type: "command", command: SPRINT_COMPLETE_NUDGE }]
898
969
  }, "Added sprint-complete nudge hook");
899
970
  }
900
971
  async function addWorkflowCheckHook(root) {
901
972
  await writeWorkflowCheckScript(root);
902
973
  return addHookToSettings(root, "PostToolUse", "workflow-check.sh", {
903
974
  matcher: "Edit|Write",
904
- hooks: [{ type: "command", command: "bash .claude/hooks/workflow-check.sh 2>/dev/null; exit 0" }]
975
+ hooks: [{ type: "command", command: WORKFLOW_CHECK_WRAPPER }]
905
976
  }, "Added workflow-check hook (BACKLOG/TASKS staleness warnings)");
906
977
  }
978
+ async function migrateSprintOpenHookEvent(root) {
979
+ const settings = await readSettingsJson(root);
980
+ if (settings === null) return false;
981
+ const hooks = settings.hooks ?? {};
982
+ const preToolUse = hooks.PreToolUse ?? [];
983
+ let removed = false;
984
+ const cleaned = preToolUse.map((group) => {
985
+ const nested = group.hooks ?? [];
986
+ const filtered = nested.filter((h) => !String(h.command ?? "").includes("sprint-open-check.sh"));
987
+ if (filtered.length !== nested.length) removed = true;
988
+ return { ...group, hooks: filtered };
989
+ }).filter((group) => group.hooks.length > 0);
990
+ if (!removed) return false;
991
+ await writeSettingsJson(root, { ...settings, hooks: { ...hooks, PreToolUse: cleaned } });
992
+ await writeSprintHygieneScripts(root);
993
+ await addSprintOpenHook(root);
994
+ log.success("Moved sprint-open-check to PostToolUse (context injection is impossible on PreToolUse)");
995
+ return true;
996
+ }
997
+ async function upgradeStaleNudge(root) {
998
+ const settings = await readSettingsJson(root);
999
+ if (settings === null) return false;
1000
+ const hooks = settings.hooks ?? {};
1001
+ let changed = false;
1002
+ const upgraded = Object.fromEntries(Object.entries(hooks).map(([event, groups]) => [
1003
+ event,
1004
+ groups.map((group) => ({
1005
+ ...group,
1006
+ hooks: (group.hooks ?? []).map((h) => {
1007
+ const cmd = String(h.command ?? "");
1008
+ if (cmd.includes("Sprint complete") && !cmd.includes("additionalContext")) {
1009
+ changed = true;
1010
+ return { ...h, command: SPRINT_COMPLETE_NUDGE };
1011
+ }
1012
+ return h;
1013
+ })
1014
+ }))
1015
+ ]));
1016
+ if (!changed) return false;
1017
+ await writeSettingsJson(root, { ...settings, hooks: upgraded });
1018
+ log.success("Upgraded sprint-complete nudge to additionalContext JSON (was invisible bare stdout)");
1019
+ return true;
1020
+ }
1021
+ async function refreshHygieneScripts(root) {
1022
+ const scripts = [".claude/hooks/workflow-check.sh", ".claude/hooks/sprint-open-check.sh"];
1023
+ let stale = false;
1024
+ for (const rel of scripts) {
1025
+ const content = await readFile2(join3(root, rel), "utf-8").catch(() => null);
1026
+ if (content !== null && !content.includes("hookSpecificOutput")) stale = true;
1027
+ }
1028
+ if (!stale) return false;
1029
+ await writeSprintHygieneScripts(root);
1030
+ await writeWorkflowCheckScript(root);
1031
+ log.success("Refreshed hygiene scripts (warnings now emitted as additionalContext JSON)");
1032
+ return true;
1033
+ }
907
1034
 
908
1035
  // src/commands/doctor/fixer-quality.ts
909
- import { readFile as readFile2, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
1036
+ import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
910
1037
  import { join as join4 } from "path";
911
1038
 
912
1039
  // src/commands/init/generators/workflow-rule.ts
913
- var WORKFLOW_RULE_VERSION = 1;
1040
+ var WORKFLOW_RULE_VERSION = 2;
914
1041
  function generateWorkflowRule() {
915
1042
  return `---
916
1043
  paths: ["BACKLOG.md", "TASKS.md"]
@@ -928,14 +1055,14 @@ These rules apply whenever editing \`BACKLOG.md\` or \`TASKS.md\`. The workflow
928
1055
 
929
1056
  - Pulled into a sprint \u2192 move (delete from BACKLOG.md, add to TASKS.md under \`## Current Sprint\`) in a single edit.
930
1057
  - Sprint closed \u2192 the WP leaves TASKS.md entirely (summarized into \`## Completed Sprints\`) and does not return to the backlog.
931
- - A WP ID appearing in both files at once = bug. A PostToolUse hook warns.
1058
+ - A WP entry appearing in both files at once = bug. \`workflow-check.sh\` injects a warning into context when it sees one (Changelog and \`Depends on:\` mentions are fine).
932
1059
 
933
1060
  ## WP IDs
934
1061
 
935
- - Format: \`WP-NNN\` (three digits, zero-padded).
936
- - Minted on first entry to \`BACKLOG.md\`.
1062
+ - Format: \`WP-NNN\` (three digits, zero-padded; grows to four digits past WP-999).
1063
+ - Minted on first entry to \`BACKLOG.md\`; log every mint in \`## Changelog\`: \`YYYY-MM-DD: WP-NNN added (P1)\`.
937
1064
  - Never reused, never renamed.
938
- - Highest-used ID lives at the end of \`BACKLOG.md ## Changelog\` \u2014 check there before minting a new one.
1065
+ - To find the highest used ID before minting: \`grep -ohE 'WP-[0-9]+' BACKLOG.md TASKS.md | sort -V | tail -1\`.
939
1066
 
940
1067
  ## BACKLOG.md structure (mandatory sections, in order)
941
1068
 
@@ -968,7 +1095,7 @@ These rules apply whenever editing \`BACKLOG.md\` or \`TASKS.md\`. The workflow
968
1095
 
969
1096
  ### Changelog discipline
970
1097
 
971
- - Every WP promotion, demotion, or deletion gets a one-line entry with the date.
1098
+ - Every WP mint, promotion, demotion, or deletion gets a one-line entry with the date.
972
1099
  - A backlog audit that finds no changelog entries for 30+ days = staleness; force a review.
973
1100
 
974
1101
  ## TASKS.md structure (mandatory sections, in order)
@@ -999,7 +1126,8 @@ These rules apply whenever editing \`BACKLOG.md\` or \`TASKS.md\`. The workflow
999
1126
  ### Size discipline
1000
1127
 
1001
1128
  - Whole file stays under 80 lines.
1002
- - If \`## Current Sprint\` exceeds 15 checkboxes, the sprint is too big \u2014 split it (move some WPs back to \`BACKLOG.md\` P0).
1129
+ - Aim for 3-6 WPs per sprint (soft target \u2014 \`sprint-size-check.sh\` nudges at session start).
1130
+ - If \`## Current Sprint\` exceeds 15 checkboxes, the sprint is too big \u2014 split it (hard trigger; \`workflow-check.sh\` warns).
1003
1131
 
1004
1132
  ## Sprint lifecycle (the exact edit sequence)
1005
1133
 
@@ -1009,13 +1137,13 @@ These rules apply whenever editing \`BACKLOG.md\` or \`TASKS.md\`. The workflow
1009
1137
  2. **Same edit:** delete them from \`BACKLOG.md\`, add them to \`TASKS.md ## Current Sprint\`.
1010
1138
  3. Update \`BACKLOG.md ## Changelog\`: \`YYYY-MM-DD: WP-NNN pulled into Sprint SN\`.
1011
1139
  4. Write the sprint plan (outline approach, success criteria, tests to add).
1012
- 5. For hard-TDD surfaces, write the test spec **before** implementation.
1140
+ 5. For hard-TDD surfaces (see "Testing Discipline" in \`.claude/rules/conventions.md\`), write the test spec **before** implementation.
1013
1141
  6. Commit the pull + plan together: \`chore(sprint-N): pull WP-NNN into sprint + plan\`.
1014
1142
 
1015
1143
  ### Closing a sprint (one session, one commit)
1016
1144
 
1017
1145
  1. All \`## Current Sprint\` items checked off, or explicitly moved back to backlog with rationale.
1018
- 2. Run your review workflow \u2014 verify typecheck, tests, and convention compliance before declaring done.
1146
+ 2. Review the sprint diff: find the base with \`git log --grep 'chore(sprint-' -n 1 --format=%H\`, run \`/code-review\` against it, and fix all Critical/Important findings. Then run the project's test and typecheck commands \u2014 both must pass.
1019
1147
  3. Add one-line summary to \`## Completed Sprints\`.
1020
1148
  4. Empty \`## Current Sprint\` back to the placeholder comment.
1021
1149
  5. Update \`## Session Log\` (prune to 3 entries).
@@ -1023,12 +1151,15 @@ These rules apply whenever editing \`BACKLOG.md\` or \`TASKS.md\`. The workflow
1023
1151
 
1024
1152
  ## What triggers a staleness warning
1025
1153
 
1026
- A PostToolUse hook fires warnings on these conditions (treat as bugs):
1154
+ \`workflow-check.sh\` (PostToolUse on BACKLOG/TASKS edits) injects warnings into context \u2014 as \`additionalContext\`, so the model actually sees them \u2014 on these conditions (treat as bugs):
1027
1155
 
1028
- - A WP ID appears in both \`BACKLOG.md\` and \`TASKS.md\`.
1156
+ - A WP entry lives in both a \`BACKLOG.md\` P-section and \`## Current Sprint\`.
1029
1157
  - \`TASKS.md\` exceeds 80 lines.
1030
1158
  - \`## Current Sprint\` has >15 items.
1031
1159
  - \`## Session Log\` has >3 entries.
1160
+ - A pulled WP's \`Depends on:\` dependency still sits in a BACKLOG P-section.
1161
+
1162
+ \`sprint-open-check.sh\` (PostToolUse on Bash) additionally warns after a \`git commit\` that pulls WPs into the sprint without deleting anything from \`BACKLOG.md\` \u2014 fix with \`git commit --amend\` before pushing.
1032
1163
 
1033
1164
  ## Do not
1034
1165
 
@@ -1040,6 +1171,253 @@ A PostToolUse hook fires warnings on these conditions (treat as bugs):
1040
1171
  `;
1041
1172
  }
1042
1173
 
1174
+ // src/commands/init/generators/hooks-rule.ts
1175
+ var HOOKS_RULE_VERSION = 1;
1176
+ function generateHooksRule() {
1177
+ return `---
1178
+ paths: [".claude/settings.json", ".claude/settings.local.json"]
1179
+ ---
1180
+
1181
+ # Claude Code Hook Authoring Rules
1182
+
1183
+ <!-- lp-hooks-version: ${HOOKS_RULE_VERSION} -->
1184
+
1185
+ Applies to every hook entry in \`.claude/settings.json\` and \`.claude/settings.local.json\`. Hooks are the project's automated safety net. Getting the API wrong silently disables the protection without any error. **A broken hook is worse than no hook because it gives false confidence.**
1186
+
1187
+ Reference: https://code.claude.com/docs/en/hooks
1188
+
1189
+ ## Anatomy of a hook entry
1190
+
1191
+ \`\`\`json
1192
+ {
1193
+ "hooks": {
1194
+ "<EventName>": [
1195
+ {
1196
+ "matcher": "<ToolMatcher>",
1197
+ "hooks": [
1198
+ { "type": "command", "command": "<shell-string>" }
1199
+ ]
1200
+ }
1201
+ ]
1202
+ }
1203
+ }
1204
+ \`\`\`
1205
+
1206
+ - \`EventName\`: \`SessionStart\`, \`SessionEnd\`, \`PreToolUse\`, \`PostToolUse\`, \`PostToolUseFailure\`, \`UserPromptSubmit\`, \`Stop\`, \`PermissionRequest\`, \`PreCompact\`, \`PostCompact\`. PostCompact is side-effect-only (stdout is never injected) \u2014 to re-inject context after compaction, use SessionStart with matcher \`compact\`.
1207
+ - \`matcher\`: a regex-style string matching tool names (e.g. \`Bash\`, \`Read|Write|Edit\`). Empty string matches all tools for the event. For SessionStart use \`startup\`, \`resume\`, \`clear\`, or \`compact\`.
1208
+ - \`hooks\` array: every entry runs in parallel when the matcher fires. Identical command strings are deduplicated automatically.
1209
+
1210
+ ## Input \u2014 JSON on stdin, NOT env vars
1211
+
1212
+ The hook receives a JSON payload on stdin. **There are no \`TOOL_INPUT_*\` environment variables in current Claude Code.** A hook that does \`cmd="$TOOL_INPUT_COMMAND"\` reads an empty string and silently no-ops. This is the single most common authoring bug.
1213
+
1214
+ Canonical extraction:
1215
+
1216
+ \`\`\`bash
1217
+ fp=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) # PreToolUse Read|Write|Edit
1218
+ cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null) # PreToolUse Bash
1219
+ new=$(jq -r '.tool_input.new_string // .tool_input.content // empty' 2>/dev/null) # PostToolUse Edit|Write
1220
+ \`\`\`
1221
+
1222
+ Top-level keys you can read: \`session_id\`, \`transcript_path\`, \`cwd\`, \`permission_mode\`, \`hook_event_name\`, \`tool_name\`, \`tool_input\`, \`tool_use_id\`. \`PostToolUse\` adds \`tool_response\`.
1223
+
1224
+ \`jq\` is a project-level requirement. Install via \`brew install jq\` (macOS) or your distro's package manager.
1225
+
1226
+ ## Exit codes \u2014 2 blocks, 1 does not
1227
+
1228
+ | Exit code | Effect |
1229
+ |---|---|
1230
+ | \`0\` | Allow the action. Hook stdout becomes context (SessionStart) or is informational (PostToolUse). |
1231
+ | \`2\` | Block the action. Hook stderr is fed back to Claude as the error reason. |
1232
+ | any other non-zero (\`1\`, \`127\`, \u2026) | Treated as a non-blocking hook error. **The action proceeds anyway.** |
1233
+
1234
+ \`exit 1\` does NOT block. If the hook is meant to enforce a policy, it must end in \`exit 2\`.
1235
+
1236
+ Block reasons go to **stderr** (\`echo '...' >&2\`), not stdout. Stdout is ignored when exit code is 2 unless the hook returns the JSON envelope (see "Richer control" below).
1237
+
1238
+ ## Output \u2014 stderr for blocks, stdout for warnings
1239
+
1240
+ \`\`\`bash
1241
+ # Blocking: stderr + exit 2
1242
+ echo 'BLOCKED: <reason shown to Claude>' >&2
1243
+ exit 2
1244
+
1245
+ # Informational warning that does not block: stdout + exit 0
1246
+ echo 'WARNING: <message printed to user>'
1247
+ exit 0
1248
+ \`\`\`
1249
+
1250
+ Richer control (only use when the simple form is insufficient):
1251
+
1252
+ \`\`\`bash
1253
+ jq -n '{
1254
+ hookSpecificOutput: {
1255
+ hookEventName: "PreToolUse",
1256
+ permissionDecision: "deny",
1257
+ permissionDecisionReason: "<reason>"
1258
+ }
1259
+ }'
1260
+ exit 0
1261
+ \`\`\`
1262
+
1263
+ ## Multi-hook same matcher \u2014 combine into ONE entry
1264
+
1265
+ If two top-level entries share the same matcher (e.g. two \`PreToolUse\` entries both with \`matcher: "Bash"\`), behavior is undefined: in practice the second entry can fail to fire. **Always combine commands for the same matcher into a single entry's \`hooks\` array.**
1266
+
1267
+ Wrong:
1268
+
1269
+ \`\`\`jsonc
1270
+ "PreToolUse": [
1271
+ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "<destructive-guard>" }] },
1272
+ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "<sprint-open-check>" }] }
1273
+ ]
1274
+ \`\`\`
1275
+
1276
+ Right:
1277
+
1278
+ \`\`\`jsonc
1279
+ "PreToolUse": [
1280
+ {
1281
+ "matcher": "Bash",
1282
+ "hooks": [
1283
+ { "type": "command", "command": "<destructive-guard>" },
1284
+ { "type": "command", "command": "<sprint-open-check>" }
1285
+ ]
1286
+ }
1287
+ ]
1288
+ \`\`\`
1289
+
1290
+ ## Hot-reload \u2014 there is none
1291
+
1292
+ Edits to \`.claude/settings.json\` or \`.claude/settings.local.json\` only take effect after a Claude Code session restart. Mid-session edits parse fine and persist to disk, but the running session keeps the hooks it loaded at start.
1293
+
1294
+ When fixing or adding a hook:
1295
+
1296
+ 1. Edit settings.json
1297
+ 2. Test the hook command in isolation (see "Testing" below)
1298
+ 3. Restart Claude Code
1299
+ 4. Verify the hook fires by triggering the conditions it watches
1300
+
1301
+ Skipping step 3 means the new hook does not exist yet, no matter what the file says.
1302
+
1303
+ ## Testing a hook command in isolation
1304
+
1305
+ Before installing or restarting, verify the command works by piping a fake JSON payload to it:
1306
+
1307
+ \`\`\`bash
1308
+ HOOK=$(jq -r '.hooks.PreToolUse[<index>].hooks[<index>].command' .claude/settings.json)
1309
+ echo '{"tool_input":{"command":"git push --force"}}' | bash -c "$HOOK"
1310
+ echo "exit=$?"
1311
+ \`\`\`
1312
+
1313
+ Expected for a correctly-blocking hook: stderr contains the BLOCKED reason, \`exit=2\`. If you see \`exit=0\` here, the hook will not block in production either.
1314
+
1315
+ For PostToolUse hooks that read file paths or content, build the JSON to mirror the tool you're matching:
1316
+
1317
+ \`\`\`bash
1318
+ echo '{"tool_input":{"file_path":"docs/architecture.md","new_string":"..."}}' | bash -c "$HOOK"
1319
+ \`\`\`
1320
+
1321
+ ## Canonical templates
1322
+
1323
+ ### PreToolUse Bash gate (block on regex match)
1324
+
1325
+ \`\`\`bash
1326
+ cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null); echo "$cmd" | grep -qE '<your-pattern>' || exit 0; echo 'BLOCKED: <reason>' >&2; exit 2
1327
+ \`\`\`
1328
+
1329
+ **Do not anchor the pattern with \`^[[:space:]]*\`** when you want to catch chained commands. The hook receives the entire shell command string verbatim, so an anchored pattern silently misses commands like \`git status && git push\`. Use \`(^|[^[:alnum:]])<token>([[:space:]]|$)\` to match the token at start-of-line OR after a non-alphanumeric separator.
1330
+
1331
+ ### PreToolUse Read|Write|Edit gate (block on file-path match)
1332
+
1333
+ \`\`\`bash
1334
+ fp=$(jq -r '.tool_input.file_path // empty' 2>/dev/null); echo "$fp" | grep -qE '<your-pattern>' || exit 0; echo 'BLOCKED: <reason>' >&2; exit 2
1335
+ \`\`\`
1336
+
1337
+ ### PostToolUse informational warner (never blocks; just prints)
1338
+
1339
+ \`\`\`bash
1340
+ fp=$(jq -r '.tool_input.file_path // empty' 2>/dev/null); echo "$fp" | grep -qE '<your-pattern>' || exit 0; echo '<warning text>'; exit 0
1341
+ \`\`\`
1342
+
1343
+ PostToolUse blocking (exit 2) is supported but rare. By the time PostToolUse fires, the file write has already happened. Almost always you want exit 0 with a stdout message.
1344
+
1345
+ ### SessionStart context injection
1346
+
1347
+ \`\`\`bash
1348
+ cat <some-file> 2>/dev/null; exit 0
1349
+ \`\`\`
1350
+
1351
+ Output goes into the session's context. Always exit 0; SessionStart blocking is not a thing.
1352
+
1353
+ ## Adding a new hook \u2014 checklist
1354
+
1355
+ - [ ] Identify the event and matcher you actually need (\`PreToolUse\` for blocking before, \`PostToolUse\` for after-the-fact warnings).
1356
+ - [ ] Read the input via stdin JSON with \`jq\`, **not env vars**.
1357
+ - [ ] If it blocks: send reason to stderr, end with \`exit 2\`. If it warns: stdout, end with \`exit 0\`.
1358
+ - [ ] If a hook for the same matcher already exists, add your command to its existing \`hooks\` array \u2014 do NOT create a second entry with the same matcher.
1359
+ - [ ] Test the command in isolation by piping a fake JSON payload (see "Testing" above) \u2014 verify the exit code matches your intent.
1360
+ - [ ] Restart Claude Code.
1361
+ - [ ] Verify the hook fires under the conditions it watches and does not fire under benign conditions.
1362
+
1363
+ ## Do not
1364
+
1365
+ - Don't reference \`$TOOL_INPUT_COMMAND\`, \`$TOOL_INPUT_FILE_PATH\`, \`$TOOL_INPUT_NEW_TEXT\`, or any other \`TOOL_INPUT_*\` env var. They do not exist; the hook silently no-ops.
1366
+ - Don't \`exit 1\` to block. It is silently non-blocking. Use \`exit 2\`.
1367
+ - Don't echo the block reason to stdout when exiting 2. Stdout is ignored in that case; only stderr reaches Claude.
1368
+ - Don't create a second top-level entry with a matcher that already has an entry. Combine commands in the existing entry's \`hooks\` array.
1369
+ - Don't expect mid-session settings.json edits to take effect. Restart is required.
1370
+ - Don't ship a hook without isolation-testing the command first. A silent no-op is worse than no hook.
1371
+ - Don't put long debug logging into a production hook. If you need diagnostics during development, write to \`/tmp/<name>.log\` and remove the line before commit.
1372
+ `;
1373
+ }
1374
+
1375
+ // src/commands/init/generators/agent-reviewer.ts
1376
+ var REVIEWER_AGENT_VERSION = 1;
1377
+ function generateReviewerAgent() {
1378
+ return `---
1379
+ name: code-reviewer
1380
+ description: Independent, fresh-context review of a diff. Use PROACTIVELY before sprint-ending commits. Pass base and head SHAs (find the base with \`git log --grep 'chore(sprint-' -n 1 --format=%H\`).
1381
+ tools: Read, Glob, Grep, Bash
1382
+ ---
1383
+
1384
+ <!-- lp-reviewer-version: ${REVIEWER_AGENT_VERSION} -->
1385
+
1386
+ You are an independent senior code reviewer. You did NOT write this code \u2014
1387
+ review it with fresh eyes and no attachment to the choices made.
1388
+
1389
+ ## Input
1390
+
1391
+ Base and head SHAs (or a branch/range). If none given, review the working
1392
+ tree against HEAD.
1393
+
1394
+ ## Process
1395
+
1396
+ 1. \`git diff --stat <base>..<head>\` for the shape, then read the full diff.
1397
+ 2. Read surrounding source for anything the diff touches \u2014 a hunk that looks
1398
+ fine in isolation can break an invariant defined two functions up.
1399
+ 3. For each suspected bug, verify against the actual code before reporting \u2014
1400
+ no findings from pattern-matching alone.
1401
+ 4. Check: correctness, security (injection, secrets, permissions), error
1402
+ handling, convention violations (see CLAUDE.md and .claude/rules/), dead
1403
+ code, debug artifacts, missing or weakened tests.
1404
+
1405
+ ## Output (exactly these sections)
1406
+
1407
+ - **Strengths** \u2014 what is genuinely good; be brief.
1408
+ - **Critical** \u2014 bugs, security holes, data loss. Must be fixed before commit.
1409
+ - **Important** \u2014 incorrect edge cases, convention breaks, silent failures.
1410
+ Must be fixed before commit.
1411
+ - **Minor** \u2014 polish; may be deferred to the backlog.
1412
+ - **Assessment** \u2014 2-3 sentences: ship, fix-then-ship, or rethink.
1413
+
1414
+ For every Critical/Important finding give file:line and a concrete failure
1415
+ scenario (inputs/state \u2192 wrong behavior). Do not pad: an empty Critical
1416
+ section is a valid, good result. Never soften a finding because the fix is
1417
+ inconvenient.
1418
+ `;
1419
+ }
1420
+
1043
1421
  // src/commands/doctor/fixer-quality.ts
1044
1422
  async function createWorkflowRule(root) {
1045
1423
  const rulesDir = join4(root, ".claude", "rules");
@@ -1050,6 +1428,41 @@ async function createWorkflowRule(root) {
1050
1428
  log.success("Created .claude/rules/workflow.md (path-scoped BACKLOG/TASKS workflow rules)");
1051
1429
  return true;
1052
1430
  }
1431
+ async function createReviewerAgent(root) {
1432
+ const agentsDir = join4(root, ".claude", "agents");
1433
+ const agentPath = join4(agentsDir, "code-reviewer.md");
1434
+ if (await fileExists(agentPath)) return false;
1435
+ await mkdir2(agentsDir, { recursive: true });
1436
+ await writeFile3(agentPath, generateReviewerAgent());
1437
+ log.success("Created .claude/agents/code-reviewer.md (fresh-context independent reviewer)");
1438
+ return true;
1439
+ }
1440
+ async function updateWorkflowRule(root) {
1441
+ const workflowPath = join4(root, ".claude", "rules", "workflow.md");
1442
+ const content = await readFile3(workflowPath, "utf-8").catch(() => null);
1443
+ if (content === null) return false;
1444
+ if (!/<!-- lp-workflow-version: \d+ -->/.test(content)) return false;
1445
+ await writeFile3(workflowPath, generateWorkflowRule());
1446
+ log.success("Updated .claude/rules/workflow.md to the latest version");
1447
+ return true;
1448
+ }
1449
+ async function fixStaleSwarmPhrase(root) {
1450
+ const claudeMdPath = join4(root, "CLAUDE.md");
1451
+ const content = await readFile3(claudeMdPath, "utf-8").catch(() => null);
1452
+ if (content === null || !content.includes(STALE_SWARM_PHRASE)) return false;
1453
+ await writeFile3(claudeMdPath, content.replaceAll(STALE_SWARM_PHRASE, SWARM_PHRASE_REPLACEMENT));
1454
+ log.success("Modernized Stop-and-Swarm wording (Agent tool \u2192 Task tool subagents)");
1455
+ return true;
1456
+ }
1457
+ async function createHooksRule(root) {
1458
+ const rulesDir = join4(root, ".claude", "rules");
1459
+ const hooksPath = join4(rulesDir, "hooks.md");
1460
+ if (await fileExists(hooksPath)) return false;
1461
+ await mkdir2(rulesDir, { recursive: true });
1462
+ await writeFile3(hooksPath, generateHooksRule());
1463
+ log.success("Created .claude/rules/hooks.md (path-scoped hook authoring rules)");
1464
+ return true;
1465
+ }
1053
1466
  function isMemoryHeading(line) {
1054
1467
  return /^## Memory( \(agentic-memory\))?\s*$/.test(line);
1055
1468
  }
@@ -1073,7 +1486,7 @@ async function collapseMemoryHeadings(root) {
1073
1486
  const claudeMdPath = join4(root, "CLAUDE.md");
1074
1487
  let content;
1075
1488
  try {
1076
- content = await readFile2(claudeMdPath, "utf-8");
1489
+ content = await readFile3(claudeMdPath, "utf-8");
1077
1490
  } catch {
1078
1491
  return false;
1079
1492
  }
@@ -1118,7 +1531,7 @@ async function addEnvProtectionHook(root) {
1118
1531
  matcher: "Read|Write|Edit",
1119
1532
  hooks: [{
1120
1533
  type: "command",
1121
- command: `echo "$TOOL_INPUT_FILE_PATH" | grep -qE '\\.(env|env\\..*)$' && ! echo "$TOOL_INPUT_FILE_PATH" | grep -q '.env.example' && echo 'BLOCKED: .env files contain secrets' && exit 1; exit 0`
1534
+ command: `fp=${jqField("file_path")}; echo "$fp" | grep -qE '\\.(env|env\\..*)$' && ! echo "$fp" | grep -q '.env.example' && { echo 'BLOCKED: .env files contain secrets' >&2; exit 2; }; exit 0`
1122
1535
  }]
1123
1536
  }, "Added .env file protection hook (PreToolUse)");
1124
1537
  }
@@ -1131,7 +1544,7 @@ async function addAutoFormatHook(root, detected) {
1131
1544
  matcher: "Write|Edit",
1132
1545
  hooks: [{
1133
1546
  type: "command",
1134
- command: `ext=\${TOOL_INPUT_FILE_PATH##*.}; (${extChecks}) && ${config.command} "$TOOL_INPUT_FILE_PATH" 2>/dev/null; exit 0`
1547
+ command: `fp=${jqField("file_path")}; ext="\${fp##*.}"; (${extChecks}) && ${config.command} "$fp" 2>/dev/null; exit 0`
1135
1548
  }]
1136
1549
  }, `Added auto-format hook (PostToolUse \u2192 ${config.command})`);
1137
1550
  }
@@ -1140,26 +1553,149 @@ async function addForcePushProtection(root) {
1140
1553
  matcher: "Bash",
1141
1554
  hooks: [{
1142
1555
  type: "command",
1143
- command: `echo "$TOOL_INPUT_COMMAND" | grep -qE 'push.*--force|push.*-f' && echo 'WARNING: Force push detected \u2014 this can destroy remote history' && exit 1; exit 0`
1556
+ command: `cmd=${jqField("command")}; echo "$cmd" | grep -qE 'push.*--force|push.*-f' && { echo 'WARNING: Force push detected \u2014 this can destroy remote history' >&2; exit 2; }; exit 0`
1144
1557
  }]
1145
1558
  }, "Added force-push protection hook (PreToolUse \u2192 Bash)");
1146
1559
  }
1147
- async function addPostCompactHook(root) {
1148
- return addHookToSettings(root, "PostCompact", "TASKS.md", {
1149
- matcher: "",
1150
- hooks: [{ type: "command", command: "cat TASKS.md 2>/dev/null; exit 0" }]
1151
- }, "Added PostCompact hook (re-injects TASKS.md after compaction)");
1560
+ async function migratePostCompactHook(root) {
1561
+ const settings = await readSettingsJson(root);
1562
+ if (settings === null) return false;
1563
+ const hooks = settings.hooks ?? {};
1564
+ let changed = false;
1565
+ const postCompact = hooks.PostCompact ?? [];
1566
+ const kept = postCompact.map((group) => {
1567
+ const nested = group.hooks ?? [];
1568
+ const filtered = nested.filter((h) => !String(h.command ?? "").includes("TASKS.md"));
1569
+ if (filtered.length !== nested.length) changed = true;
1570
+ return { ...group, hooks: filtered };
1571
+ }).filter((group) => group.hooks.length > 0);
1572
+ const rest = { ...hooks };
1573
+ if (kept.length > 0) rest.PostCompact = kept;
1574
+ else delete rest.PostCompact;
1575
+ const withMatcher = ensureCompactMatcher(rest);
1576
+ if (withMatcher.changed) changed = true;
1577
+ if (!changed) return false;
1578
+ await writeSettingsJson(root, { ...settings, hooks: withMatcher.hooks });
1579
+ log.success("Moved TASKS.md re-injection off PostCompact (stdout not injected there) \u2192 SessionStart compact/clear matcher");
1580
+ return true;
1581
+ }
1582
+ async function addCompactMatcherHook(root) {
1583
+ const settings = await readSettingsJson(root);
1584
+ if (settings === null) return false;
1585
+ const hooks = settings.hooks ?? {};
1586
+ const result = ensureCompactMatcher(hooks);
1587
+ if (!result.changed) return false;
1588
+ await writeSettingsJson(root, { ...settings, hooks: result.hooks });
1589
+ log.success("SessionStart matcher now covers compact/clear (session continuity after compaction)");
1590
+ return true;
1591
+ }
1592
+ function ensureCompactMatcher(hooks) {
1593
+ const sessionStart = hooks.SessionStart ?? [];
1594
+ if (sessionStart.some((g) => String(g.matcher ?? "").includes("compact"))) {
1595
+ return { hooks, changed: false };
1596
+ }
1597
+ const idx = sessionStart.findIndex((g) => {
1598
+ const nested = g.hooks;
1599
+ return nested?.some((h) => String(h.command ?? "").includes("TASKS.md"));
1600
+ });
1601
+ if (idx === -1) {
1602
+ const entry = { matcher: SESSION_START_MATCHER, hooks: [{ type: "command", command: "cat TASKS.md 2>/dev/null; exit 0" }] };
1603
+ return { hooks: { ...hooks, SessionStart: [...sessionStart, entry] }, changed: true };
1604
+ }
1605
+ const widened = sessionStart.map((g, i) => i === idx ? { ...g, matcher: SESSION_START_MATCHER } : g);
1606
+ return { hooks: { ...hooks, SessionStart: widened }, changed: true };
1152
1607
  }
1153
1608
  async function addSessionStartHook(root) {
1154
1609
  return addHookToSettings(root, "SessionStart", "TASKS.md", {
1155
- matcher: "startup|resume",
1610
+ matcher: SESSION_START_MATCHER,
1156
1611
  hooks: [{ type: "command", command: "cat TASKS.md 2>/dev/null; exit 0" }]
1157
- }, "Added SessionStart hook (injects TASKS.md at startup)");
1612
+ }, "Added SessionStart hook (injects TASKS.md at startup/resume/compact/clear)");
1158
1613
  }
1159
1614
 
1160
- // src/commands/doctor/fixer-memory.ts
1161
- import { readFile as readFile3 } from "fs/promises";
1615
+ // src/commands/doctor/fixer-hook-input.ts
1616
+ import { readFile as readFile4 } from "fs/promises";
1162
1617
  import { join as join5 } from "path";
1618
+ function rewriteEnvVarHookCommand(cmd) {
1619
+ if (!hasEnvVarHookPattern(cmd)) return null;
1620
+ if (cmd.includes("BLOCKED: .env files contain secrets")) {
1621
+ return `fp=${jqField("file_path")}; echo "$fp" | grep -qE '\\.(env|env\\..*)$' && ! echo "$fp" | grep -q '.env.example' && { echo 'BLOCKED: .env files contain secrets' >&2; exit 2; }; exit 0`;
1622
+ }
1623
+ if (cmd.includes("BLOCKED: Destructive command detected")) {
1624
+ return `cmd=${jqField("command")}; echo "$cmd" | grep -qE 'rm\\s+-rf\\s+/|DROP\\s+TABLE|DROP\\s+DATABASE|push.*--force|push.*-f' && { echo 'BLOCKED: Destructive command detected' >&2; exit 2; }; exit 0`;
1625
+ }
1626
+ if (cmd.includes("WARNING: Force push detected") || cmd.includes("Force push detected")) {
1627
+ return `cmd=${jqField("command")}; echo "$cmd" | grep -qE 'push.*--force|push.*-f' && { echo 'WARNING: Force push detected \u2014 this can destroy remote history' >&2; exit 2; }; exit 0`;
1628
+ }
1629
+ if (cmd.includes("Sprint complete") && cmd.includes("TASKS.md")) {
1630
+ return SPRINT_COMPLETE_NUDGE;
1631
+ }
1632
+ const formatMatch = cmd.match(/ext=\$\{TOOL_INPUT_FILE_PATH##\*\.\};\s*\((.+?)\)\s*&&\s*(.+?)\s*"\$TOOL_INPUT_FILE_PATH"/);
1633
+ if (formatMatch) {
1634
+ const extChecks = formatMatch[1].trim();
1635
+ const formatter = formatMatch[2].trim();
1636
+ return `fp=${jqField("file_path")}; ext="\${fp##*.}"; (${extChecks}) && ${formatter} "$fp" 2>/dev/null; exit 0`;
1637
+ }
1638
+ return null;
1639
+ }
1640
+ function rewriteSettingsHooks(settings) {
1641
+ const hooks = settings.hooks;
1642
+ if (!hooks) return { settings, changed: false };
1643
+ let changed = false;
1644
+ const next = {};
1645
+ for (const [event, groups] of Object.entries(hooks)) {
1646
+ next[event] = groups.map((group) => ({
1647
+ ...group,
1648
+ hooks: group.hooks.map((hook) => {
1649
+ if (hook.type !== "command") return hook;
1650
+ if (!hasEnvVarHookPattern(hook.command)) return hook;
1651
+ const rewritten = rewriteEnvVarHookCommand(hook.command);
1652
+ if (rewritten === null) return hook;
1653
+ changed = true;
1654
+ return { ...hook, command: rewritten };
1655
+ })
1656
+ }));
1657
+ }
1658
+ if (!changed) return { settings, changed: false };
1659
+ return { settings: { ...settings, hooks: next }, changed: true };
1660
+ }
1661
+ async function rewriteWrapperScript(scriptPath, rewriter) {
1662
+ let content;
1663
+ try {
1664
+ content = await readFile4(scriptPath, "utf-8");
1665
+ } catch {
1666
+ return false;
1667
+ }
1668
+ if (!hasEnvVarHookPattern(content)) return false;
1669
+ await rewriter();
1670
+ return true;
1671
+ }
1672
+ async function rewriteEnvVarHooks(root) {
1673
+ let didFix = false;
1674
+ const settings = await readSettingsJson(root);
1675
+ if (settings !== null) {
1676
+ const outcome = rewriteSettingsHooks(settings);
1677
+ if (outcome.changed) {
1678
+ await writeSettingsJson(root, outcome.settings);
1679
+ log.success("Rewrote inert $TOOL_INPUT_* hooks in settings.json to canonical jq+stdin form");
1680
+ didFix = true;
1681
+ }
1682
+ }
1683
+ const workflowCheckPath = join5(root, ".claude", "hooks", "workflow-check.sh");
1684
+ if (await rewriteWrapperScript(workflowCheckPath, () => writeWorkflowCheckScript(root))) {
1685
+ log.success("Rewrote .claude/hooks/workflow-check.sh to canonical jq+stdin form");
1686
+ didFix = true;
1687
+ }
1688
+ const sprintOpenPath = join5(root, ".claude", "hooks", "sprint-open-check.sh");
1689
+ if (await rewriteWrapperScript(sprintOpenPath, () => writeSprintHygieneScripts(root))) {
1690
+ log.success("Rewrote .claude/hooks/sprint-open-check.sh to canonical jq+stdin form");
1691
+ didFix = true;
1692
+ }
1693
+ return didFix;
1694
+ }
1695
+
1696
+ // src/commands/doctor/fixer-memory.ts
1697
+ import { readFile as readFile5 } from "fs/promises";
1698
+ import { join as join6 } from "path";
1163
1699
  async function addPlacementHook(root, placement, event, dedupKeyword, entry, prepend, successMsg) {
1164
1700
  const read = placement === "local" ? readSettingsLocalJson : readSettingsJson;
1165
1701
  const write = placement === "local" ? writeSettingsLocalJson : writeSettingsJson;
@@ -1313,9 +1849,9 @@ async function addAllowedMcpServers(root, placement) {
1313
1849
  if (settingsServers && typeof settingsServers === "object") {
1314
1850
  for (const name of Object.keys(settingsServers)) serverNames.add(name);
1315
1851
  }
1316
- const mcpJsonPath = join5(root, ".mcp.json");
1852
+ const mcpJsonPath = join6(root, ".mcp.json");
1317
1853
  try {
1318
- const mcpJson = JSON.parse(await readFile3(mcpJsonPath, "utf-8"));
1854
+ const mcpJson = JSON.parse(await readFile5(mcpJsonPath, "utf-8"));
1319
1855
  const mcpServers = mcpJson.mcpServers;
1320
1856
  if (mcpServers && typeof mcpServers === "object") {
1321
1857
  for (const name of Object.keys(mcpServers)) serverNames.add(name);
@@ -1351,6 +1887,7 @@ async function applyFixes(issues, projectRoot) {
1351
1887
  return { fixed, skipped };
1352
1888
  }
1353
1889
  var FIX_TABLE = [
1890
+ { analyzer: "Hooks", match: "$TOOL_INPUT_* env var", fix: (root) => rewriteEnvVarHooks(root) },
1354
1891
  { analyzer: "Hooks", match: "No hooks configured", fix: async (root, detected) => {
1355
1892
  const a = await addEnvProtectionHook(root);
1356
1893
  const b = await addAutoFormatHook(root, detected);
@@ -1375,19 +1912,27 @@ var FIX_TABLE = [
1375
1912
  } },
1376
1913
  { analyzer: "Quality", match: "Session Start", fix: (root) => addClaudeMdSection(root, "## Session Start", wrapStub(SESSION_START_CONTENT)) },
1377
1914
  { analyzer: "Quality", match: "Backlog", fix: (root) => addClaudeMdSection(root, "## Backlog", wrapStub(BACKLOG_CONTENT)) },
1915
+ { analyzer: "Quality", match: "Stop-and-Swarm section is outdated", fix: (root) => fixStaleSwarmPhrase(root) },
1378
1916
  { analyzer: "Quality", match: "Stop-and-Swarm", fix: (root) => addClaudeMdSection(root, "## Stop-and-Swarm", wrapStub(STOP_AND_SWARM_CONTENT)) },
1917
+ { analyzer: "Rules", match: "workflow.md rule is outdated", fix: (root) => updateWorkflowRule(root) },
1918
+ { analyzer: "Rules", match: "No .claude/agents/code-reviewer.md", fix: (root) => createReviewerAgent(root) },
1379
1919
  { analyzer: "Quality", match: "Duplicate ## Memory", fix: (root) => collapseMemoryHeadings(root) },
1380
1920
  { analyzer: "Rules", match: "No BACKLOG.md", fix: (root) => createBacklogMd(root) },
1381
1921
  { analyzer: "Rules", match: "No .claudeignore", fix: (root, detected) => createClaudeignore(root, detected) },
1382
1922
  { analyzer: "Rules", match: "No .claude/rules/workflow.md", fix: (root) => createWorkflowRule(root) },
1923
+ { analyzer: "Rules", match: "No .claude/rules/hooks.md", fix: (root) => createHooksRule(root) },
1383
1924
  { analyzer: "Rules", match: "No .claude/rules/", fix: (root) => createStarterRules(root) },
1384
- { analyzer: "Hooks", match: "PostCompact", fix: (root) => addPostCompactHook(root) },
1925
+ { analyzer: "Hooks", match: "PostCompact hooks can't inject context", fix: (root) => migratePostCompactHook(root) },
1926
+ { analyzer: "Hooks", match: "compact matcher", fix: (root) => addCompactMatcherHook(root) },
1385
1927
  { analyzer: "Permissions", match: "force-push", fix: (root) => addForcePushProtection(root) },
1386
1928
  { analyzer: "Permissions", match: "Credential files not blocked", fix: (root) => addCredentialDenyRules(root) },
1387
1929
  { analyzer: "Permissions", match: "Bypass permissions mode", fix: (root) => addBypassDisable(root) },
1388
- { analyzer: "Permissions", match: "Filesystem sandbox enabled", fix: (root) => removeSandboxSettings(root) },
1930
+ { analyzer: "Permissions", match: "Sandbox lacks a write grant", fix: (root) => addSandboxMemoryWriteGrant(root) },
1389
1931
  { analyzer: "Permissions", match: ".env is protected by hooks but not in .claudeignore", fix: (root) => addEnvToClaudeignore(root) },
1390
1932
  { analyzer: "Permissions", match: ".worktreeinclude is missing or empty", fix: (root) => createWorktreeInclude(root) },
1933
+ { analyzer: "Hooks", match: "registered on PreToolUse", fix: (root) => migrateSprintOpenHookEvent(root) },
1934
+ { analyzer: "Hooks", match: "nudge uses bare stdout", fix: (root) => upgradeStaleNudge(root) },
1935
+ { analyzer: "Hooks", match: "Outdated hygiene script", fix: (root) => refreshHygieneScripts(root) },
1391
1936
  { analyzer: "Hooks", match: "sprint-size-check", fix: (root) => addSprintSizeHook(root) },
1392
1937
  { analyzer: "Hooks", match: "sprint-open-check", fix: (root) => addSprintOpenHook(root) },
1393
1938
  { analyzer: "Hooks", match: "sprint-complete nudge", fix: (root) => addSprintCompleteNudge(root) },
@@ -1407,7 +1952,7 @@ var FIX_TABLE = [
1407
1952
  { analyzer: "Memory", match: "SessionEnd push hook is not nohup-wrapped", fix: (root) => upgradeStaleSessionEndPushHook(root) },
1408
1953
  { analyzer: "Memory", match: "CLAUDE.md missing memory guidance", fix: (root, _det, placement) => {
1409
1954
  const content = "Use agentic-memory to persist knowledge across sessions:\n- Memories are automatically injected at session start\n- STORE IMMEDIATELY when: a dependency strategy changes, an architecture decision is made, a convention is established, a bug pattern is discovered, or a feature is killed/added\n- Use memory_search before memory_store to check for duplicates\n- NEVER store credentials, API keys, tokens, or secrets in memories";
1410
- const target = placement === "local" ? join6(root, ".claude", "CLAUDE.md") : void 0;
1955
+ const target = placement === "local" ? join7(root, ".claude", "CLAUDE.md") : void 0;
1411
1956
  return addClaudeMdSection(root, "## Memory", wrapStub(content), target);
1412
1957
  } }
1413
1958
  ];
@@ -1454,20 +1999,30 @@ async function addBypassDisable(root) {
1454
1999
  log.success("Added disableBypassPermissionsMode: disable");
1455
2000
  return true;
1456
2001
  }
1457
- async function removeSandboxSettings(root) {
2002
+ async function addSandboxMemoryWriteGrant(root) {
1458
2003
  const settings = await readSettingsJson(root);
1459
2004
  if (settings === null) return false;
1460
- if (settings.sandbox === void 0) return false;
1461
- const { sandbox: _sandbox, ...rest } = settings;
1462
- await writeSettingsJson(root, rest);
1463
- log.success("Removed sandbox block from settings.json");
2005
+ const sandbox = settings.sandbox;
2006
+ if (sandbox === void 0) return false;
2007
+ const filesystem = sandbox.filesystem ?? {};
2008
+ const allowWrite = filesystem.allowWrite ?? [];
2009
+ if (allowWrite.some((p) => p.includes(".agentic-memory"))) return false;
2010
+ const updated = {
2011
+ ...settings,
2012
+ sandbox: {
2013
+ ...sandbox,
2014
+ filesystem: { ...filesystem, allowWrite: [...allowWrite, "~/.agentic-memory"] }
2015
+ }
2016
+ };
2017
+ await writeSettingsJson(root, updated);
2018
+ log.success("Added ~/.agentic-memory to sandbox.filesystem.allowWrite (sandbox stays enabled)");
1464
2019
  return true;
1465
2020
  }
1466
2021
  async function addEnvToClaudeignore(root) {
1467
- const ignorePath = join6(root, ".claudeignore");
2022
+ const ignorePath = join7(root, ".claudeignore");
1468
2023
  let content;
1469
2024
  try {
1470
- content = await readFile4(ignorePath, "utf-8");
2025
+ content = await readFile6(ignorePath, "utf-8");
1471
2026
  } catch {
1472
2027
  return false;
1473
2028
  }
@@ -1478,13 +2033,13 @@ async function addEnvToClaudeignore(root) {
1478
2033
  return true;
1479
2034
  }
1480
2035
  async function addClaudeMdSection(root, heading, content, targetPath) {
1481
- const claudeMdPath = targetPath ?? join6(root, "CLAUDE.md");
2036
+ const claudeMdPath = targetPath ?? join7(root, "CLAUDE.md");
1482
2037
  let existing;
1483
2038
  try {
1484
- existing = await readFile4(claudeMdPath, "utf-8");
2039
+ existing = await readFile6(claudeMdPath, "utf-8");
1485
2040
  } catch {
1486
2041
  if (!targetPath) return false;
1487
- await mkdir3(join6(root, ".claude"), { recursive: true });
2042
+ await mkdir3(join7(root, ".claude"), { recursive: true });
1488
2043
  existing = "# Local Claude Config\n";
1489
2044
  }
1490
2045
  if (existing.includes(heading)) return false;
@@ -1502,24 +2057,16 @@ ${content}
1502
2057
  return true;
1503
2058
  }
1504
2059
  async function createBacklogMd(root) {
1505
- const backlogPath = join6(root, "BACKLOG.md");
1506
- try {
1507
- await access2(backlogPath);
1508
- return false;
1509
- } catch {
1510
- }
2060
+ const backlogPath = join7(root, "BACKLOG.md");
2061
+ if (await fileExists(backlogPath)) return false;
1511
2062
  const name = root.split("/").pop() ?? "Project";
1512
2063
  await writeFile4(backlogPath, generateBacklogMd({ name, description: "" }));
1513
2064
  log.success("Generated BACKLOG.md (WP template + P0\u2013P3 sections + changelog)");
1514
2065
  return true;
1515
2066
  }
1516
2067
  async function createClaudeignore(root, detected) {
1517
- const ignorePath = join6(root, ".claudeignore");
1518
- try {
1519
- await access2(ignorePath);
1520
- return false;
1521
- } catch {
1522
- }
2068
+ const ignorePath = join7(root, ".claudeignore");
2069
+ if (await fileExists(ignorePath)) return false;
1523
2070
  const content = generateClaudeignore(detected);
1524
2071
  await writeFile4(ignorePath, content);
1525
2072
  log.success("Generated .claudeignore with language-specific ignore patterns");
@@ -1531,15 +2078,11 @@ var SKILL_AUTHORING_SECTION = `
1531
2078
  ${SKILL_AUTHORING_CONTENT}
1532
2079
  `;
1533
2080
  async function createStarterRules(root) {
1534
- const rulesDir = join6(root, ".claude", "rules");
1535
- try {
1536
- await access2(rulesDir);
1537
- return false;
1538
- } catch {
1539
- }
2081
+ const rulesDir = join7(root, ".claude", "rules");
2082
+ if (await fileExists(rulesDir)) return false;
1540
2083
  await mkdir3(rulesDir, { recursive: true });
1541
2084
  await writeFile4(
1542
- join6(rulesDir, "conventions.md"),
2085
+ join7(rulesDir, "conventions.md"),
1543
2086
  `# Project Conventions
1544
2087
 
1545
2088
  - Use conventional commits (feat:, fix:, docs:, refactor:, test:, chore:)
@@ -1552,10 +2095,10 @@ ${SKILL_AUTHORING_SECTION}`
1552
2095
  return true;
1553
2096
  }
1554
2097
  async function addSkillAuthoringConventions(root) {
1555
- const conventionsPath = join6(root, ".claude", "rules", "conventions.md");
2098
+ const conventionsPath = join7(root, ".claude", "rules", "conventions.md");
1556
2099
  let content;
1557
2100
  try {
1558
- content = await readFile4(conventionsPath, "utf-8");
2101
+ content = await readFile6(conventionsPath, "utf-8");
1559
2102
  } catch {
1560
2103
  return false;
1561
2104
  }
@@ -1565,11 +2108,11 @@ async function addSkillAuthoringConventions(root) {
1565
2108
  return true;
1566
2109
  }
1567
2110
  async function createEnhanceSkill(root) {
1568
- const skillDir = join6(root, ".claude", "skills", "lp-enhance");
1569
- const skillPath = join6(skillDir, "SKILL.md");
1570
- const globalPath = join6(homedir(), ".claude", "skills", "lp-enhance", "SKILL.md");
1571
- const legacyProject = join6(root, ".claude", "commands", "lp-enhance.md");
1572
- const legacyGlobal = join6(homedir(), ".claude", "commands", "lp-enhance.md");
2111
+ const skillDir = join7(root, ".claude", "skills", "lp-enhance");
2112
+ const skillPath = join7(skillDir, "SKILL.md");
2113
+ const globalPath = join7(homedir(), ".claude", "skills", "lp-enhance", "SKILL.md");
2114
+ const legacyProject = join7(root, ".claude", "commands", "lp-enhance.md");
2115
+ const legacyGlobal = join7(homedir(), ".claude", "commands", "lp-enhance.md");
1573
2116
  if (await fileExists(skillPath) || await fileExists(globalPath) || await fileExists(legacyProject) || await fileExists(legacyGlobal)) return false;
1574
2117
  await mkdir3(skillDir, { recursive: true });
1575
2118
  await writeFile4(skillPath, generateEnhanceSkill());
@@ -1577,8 +2120,8 @@ async function createEnhanceSkill(root) {
1577
2120
  return true;
1578
2121
  }
1579
2122
  async function updateEnhanceSkill(root) {
1580
- const projectPath = join6(root, ".claude", "skills", "lp-enhance", "SKILL.md");
1581
- const globalPath = join6(homedir(), ".claude", "skills", "lp-enhance", "SKILL.md");
2123
+ const projectPath = join7(root, ".claude", "skills", "lp-enhance", "SKILL.md");
2124
+ const globalPath = join7(homedir(), ".claude", "skills", "lp-enhance", "SKILL.md");
1582
2125
  const targetPath = await fileExists(projectPath) ? projectPath : await fileExists(globalPath) ? globalPath : null;
1583
2126
  if (!targetPath) return false;
1584
2127
  await writeFile4(targetPath, generateEnhanceSkill());
@@ -1692,7 +2235,7 @@ function renderDoctorReport(results, options) {
1692
2235
  async function readJsonFile(path) {
1693
2236
  let raw;
1694
2237
  try {
1695
- raw = await readFile5(path, "utf-8");
2238
+ raw = await readFile7(path, "utf-8");
1696
2239
  } catch (err) {
1697
2240
  const code = err.code;
1698
2241
  if (code === "ENOENT") return {};
@@ -1707,20 +2250,20 @@ async function readJsonFile(path) {
1707
2250
  }
1708
2251
  }
1709
2252
  async function readSettingsJson(root) {
1710
- return readJsonFile(join7(root, ".claude", "settings.json"));
2253
+ return readJsonFile(join8(root, ".claude", "settings.json"));
1711
2254
  }
1712
2255
  async function writeSettingsJson(root, settings) {
1713
- const dir = join7(root, ".claude");
2256
+ const dir = join8(root, ".claude");
1714
2257
  await mkdir4(dir, { recursive: true });
1715
- await writeFile5(join7(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
2258
+ await writeFile5(join8(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
1716
2259
  }
1717
2260
  async function readSettingsLocalJson(root) {
1718
- return readJsonFile(join7(root, ".claude", "settings.local.json"));
2261
+ return readJsonFile(join8(root, ".claude", "settings.local.json"));
1719
2262
  }
1720
2263
  async function writeSettingsLocalJson(root, settings) {
1721
- const dir = join7(root, ".claude");
2264
+ const dir = join8(root, ".claude");
1722
2265
  await mkdir4(dir, { recursive: true });
1723
- await writeFile5(join7(dir, "settings.local.json"), JSON.stringify(settings, null, 2) + "\n");
2266
+ await writeFile5(join8(dir, "settings.local.json"), JSON.stringify(settings, null, 2) + "\n");
1724
2267
  }
1725
2268
 
1726
2269
  export {
@@ -1728,8 +2271,11 @@ export {
1728
2271
  SESSION_START_CONTENT,
1729
2272
  BACKLOG_CONTENT,
1730
2273
  STOP_AND_SWARM_CONTENT,
2274
+ STALE_SWARM_PHRASE,
1731
2275
  OFF_LIMITS_CONTENT,
1732
2276
  SKILL_AUTHORING_CONTENT,
2277
+ sprintReviewsContent,
2278
+ TESTING_DISCIPLINE_CONTENT,
1733
2279
  fileExists,
1734
2280
  readFileOrNull,
1735
2281
  detectProject,
@@ -1744,13 +2290,24 @@ export {
1744
2290
  getMemoryPlacement,
1745
2291
  LP_STUB_OPEN,
1746
2292
  addOrUpdateHook,
2293
+ jqField,
2294
+ hasEnvVarHookPattern,
2295
+ isJqAvailable,
2296
+ SPRINT_COMPLETE_NUDGE,
2297
+ WORKFLOW_CHECK_WRAPPER,
2298
+ SPRINT_OPEN_WRAPPER,
2299
+ SPRINT_SIZE_WRAPPER,
2300
+ SESSION_START_MATCHER,
1747
2301
  writeSprintHygieneScripts,
1748
2302
  writeWorkflowCheckScript,
2303
+ WORKFLOW_RULE_VERSION,
1749
2304
  generateWorkflowRule,
2305
+ generateHooksRule,
2306
+ generateReviewerAgent,
1750
2307
  applyFixes,
1751
2308
  log,
1752
2309
  printBanner,
1753
2310
  printScoreCard,
1754
2311
  renderDoctorReport
1755
2312
  };
1756
- //# sourceMappingURL=chunk-5KQ2JDZN.js.map
2313
+ //# sourceMappingURL=chunk-KPO4YURF.js.map