@paths.design/caws-cli 11.1.6 → 11.1.8

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 (190) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +55 -58
  3. package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
  4. package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
  5. package/dist/init/hook-packs/manifest-claude-code.js +317 -6
  6. package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
  7. package/dist/init/hook-packs/types.js +1 -1
  8. package/dist/init/hook-packs/types.js.map +1 -1
  9. package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
  10. package/dist/shell/binding/resolve-binding.js +105 -1
  11. package/dist/shell/binding/resolve-binding.js.map +1 -1
  12. package/dist/shell/binding/types.d.ts +47 -3
  13. package/dist/shell/binding/types.d.ts.map +1 -1
  14. package/dist/shell/command-metadata.d.ts +93 -0
  15. package/dist/shell/command-metadata.d.ts.map +1 -0
  16. package/dist/shell/command-metadata.js +687 -0
  17. package/dist/shell/command-metadata.js.map +1 -0
  18. package/dist/shell/commands/agents.d.ts +48 -0
  19. package/dist/shell/commands/agents.d.ts.map +1 -0
  20. package/dist/shell/commands/agents.js +577 -0
  21. package/dist/shell/commands/agents.js.map +1 -0
  22. package/dist/shell/commands/claim.d.ts +16 -0
  23. package/dist/shell/commands/claim.d.ts.map +1 -1
  24. package/dist/shell/commands/claim.js +88 -30
  25. package/dist/shell/commands/claim.js.map +1 -1
  26. package/dist/shell/commands/events.d.ts +106 -0
  27. package/dist/shell/commands/events.d.ts.map +1 -0
  28. package/dist/shell/commands/events.js +510 -0
  29. package/dist/shell/commands/events.js.map +1 -0
  30. package/dist/shell/commands/gates.d.ts +2 -2
  31. package/dist/shell/commands/gates.d.ts.map +1 -1
  32. package/dist/shell/commands/gates.js +106 -25
  33. package/dist/shell/commands/gates.js.map +1 -1
  34. package/dist/shell/commands/init.d.ts.map +1 -1
  35. package/dist/shell/commands/init.js +26 -0
  36. package/dist/shell/commands/init.js.map +1 -1
  37. package/dist/shell/commands/prepush.d.ts +26 -0
  38. package/dist/shell/commands/prepush.d.ts.map +1 -0
  39. package/dist/shell/commands/prepush.js +373 -0
  40. package/dist/shell/commands/prepush.js.map +1 -0
  41. package/dist/shell/commands/scope.d.ts.map +1 -1
  42. package/dist/shell/commands/scope.js +31 -1
  43. package/dist/shell/commands/scope.js.map +1 -1
  44. package/dist/shell/commands/specs.d.ts +44 -3
  45. package/dist/shell/commands/specs.d.ts.map +1 -1
  46. package/dist/shell/commands/specs.js +411 -15
  47. package/dist/shell/commands/specs.js.map +1 -1
  48. package/dist/shell/commands/status.d.ts +12 -0
  49. package/dist/shell/commands/status.d.ts.map +1 -1
  50. package/dist/shell/commands/status.js +236 -21
  51. package/dist/shell/commands/status.js.map +1 -1
  52. package/dist/shell/commands/worktree.d.ts +9 -0
  53. package/dist/shell/commands/worktree.d.ts.map +1 -1
  54. package/dist/shell/commands/worktree.js +353 -1
  55. package/dist/shell/commands/worktree.js.map +1 -1
  56. package/dist/shell/gates/disposition.d.ts.map +1 -1
  57. package/dist/shell/gates/disposition.js +43 -2
  58. package/dist/shell/gates/disposition.js.map +1 -1
  59. package/dist/shell/index.d.ts +14 -6
  60. package/dist/shell/index.d.ts.map +1 -1
  61. package/dist/shell/index.js +32 -1
  62. package/dist/shell/index.js.map +1 -1
  63. package/dist/shell/legacy-command-map.js +832 -0
  64. package/dist/shell/push-range/classify-range.d.ts +99 -0
  65. package/dist/shell/push-range/classify-range.d.ts.map +1 -0
  66. package/dist/shell/push-range/classify-range.js +155 -0
  67. package/dist/shell/push-range/classify-range.js.map +1 -0
  68. package/dist/shell/push-range/scope-match.d.ts +13 -0
  69. package/dist/shell/push-range/scope-match.d.ts.map +1 -0
  70. package/dist/shell/push-range/scope-match.js +53 -0
  71. package/dist/shell/push-range/scope-match.js.map +1 -0
  72. package/dist/shell/register.d.ts.map +1 -1
  73. package/dist/shell/register.js +350 -165
  74. package/dist/shell/register.js.map +1 -1
  75. package/dist/shell/registered-command-groups.js +48 -0
  76. package/dist/shell/render/status.d.ts +7 -1
  77. package/dist/shell/render/status.d.ts.map +1 -1
  78. package/dist/shell/render/status.js +72 -0
  79. package/dist/shell/render/status.js.map +1 -1
  80. package/dist/shell/rules.d.ts +19 -0
  81. package/dist/shell/rules.d.ts.map +1 -1
  82. package/dist/shell/rules.js +27 -0
  83. package/dist/shell/rules.js.map +1 -1
  84. package/dist/shell/session/resolve-session.d.ts +29 -1
  85. package/dist/shell/session/resolve-session.d.ts.map +1 -1
  86. package/dist/shell/session/resolve-session.js +817 -11
  87. package/dist/shell/session/resolve-session.js.map +1 -1
  88. package/dist/shell/session/types.d.ts +127 -1
  89. package/dist/shell/session/types.d.ts.map +1 -1
  90. package/dist/shell/session/types.js +10 -4
  91. package/dist/shell/session/types.js.map +1 -1
  92. package/dist/store/agents-store.d.ts.map +1 -1
  93. package/dist/store/agents-store.js +9 -0
  94. package/dist/store/agents-store.js.map +1 -1
  95. package/dist/store/apply-patch.d.ts.map +1 -1
  96. package/dist/store/apply-patch.js +15 -0
  97. package/dist/store/apply-patch.js.map +1 -1
  98. package/dist/store/doctor-snapshot.d.ts.map +1 -1
  99. package/dist/store/doctor-snapshot.js +169 -3
  100. package/dist/store/doctor-snapshot.js.map +1 -1
  101. package/dist/store/events-migration.d.ts +207 -0
  102. package/dist/store/events-migration.d.ts.map +1 -0
  103. package/dist/store/events-migration.js +358 -0
  104. package/dist/store/events-migration.js.map +1 -0
  105. package/dist/store/events-store.d.ts +47 -1
  106. package/dist/store/events-store.d.ts.map +1 -1
  107. package/dist/store/events-store.js +278 -0
  108. package/dist/store/events-store.js.map +1 -1
  109. package/dist/store/git-autocommit.d.ts +46 -0
  110. package/dist/store/git-autocommit.d.ts.map +1 -0
  111. package/dist/store/git-autocommit.js +198 -0
  112. package/dist/store/git-autocommit.js.map +1 -0
  113. package/dist/store/git-sparse-checkout.d.ts +25 -0
  114. package/dist/store/git-sparse-checkout.d.ts.map +1 -0
  115. package/dist/store/git-sparse-checkout.js +101 -0
  116. package/dist/store/git-sparse-checkout.js.map +1 -0
  117. package/dist/store/index.d.ts +6 -1
  118. package/dist/store/index.d.ts.map +1 -1
  119. package/dist/store/index.js +16 -1
  120. package/dist/store/index.js.map +1 -1
  121. package/dist/store/leases-store.d.ts +89 -0
  122. package/dist/store/leases-store.d.ts.map +1 -0
  123. package/dist/store/leases-store.js +427 -0
  124. package/dist/store/leases-store.js.map +1 -0
  125. package/dist/store/lifecycle-transaction.d.ts.map +1 -1
  126. package/dist/store/lifecycle-transaction.js +34 -1
  127. package/dist/store/lifecycle-transaction.js.map +1 -1
  128. package/dist/store/rules.d.ts +74 -1
  129. package/dist/store/rules.d.ts.map +1 -1
  130. package/dist/store/rules.js +76 -0
  131. package/dist/store/rules.js.map +1 -1
  132. package/dist/store/specs-migration.d.ts +128 -0
  133. package/dist/store/specs-migration.d.ts.map +1 -0
  134. package/dist/store/specs-migration.js +481 -0
  135. package/dist/store/specs-migration.js.map +1 -0
  136. package/dist/store/specs-store.d.ts.map +1 -1
  137. package/dist/store/specs-store.js +14 -2
  138. package/dist/store/specs-store.js.map +1 -1
  139. package/dist/store/specs-writer.d.ts +130 -3
  140. package/dist/store/specs-writer.d.ts.map +1 -1
  141. package/dist/store/specs-writer.js +941 -102
  142. package/dist/store/specs-writer.js.map +1 -1
  143. package/dist/store/types.d.ts +31 -1
  144. package/dist/store/types.d.ts.map +1 -1
  145. package/dist/store/waivers-store.d.ts.map +1 -1
  146. package/dist/store/waivers-store.js +8 -1
  147. package/dist/store/waivers-store.js.map +1 -1
  148. package/dist/store/worktrees-migration.d.ts +141 -0
  149. package/dist/store/worktrees-migration.d.ts.map +1 -0
  150. package/dist/store/worktrees-migration.js +356 -0
  151. package/dist/store/worktrees-migration.js.map +1 -0
  152. package/dist/store/worktrees-writer.d.ts +28 -0
  153. package/dist/store/worktrees-writer.d.ts.map +1 -1
  154. package/dist/store/worktrees-writer.js +147 -13
  155. package/dist/store/worktrees-writer.js.map +1 -1
  156. package/package.json +5 -2
  157. package/templates/hook-packs/claude-code/CLAUDE.md +11 -5
  158. package/templates/hook-packs/claude-code/agent-heartbeat.sh +131 -0
  159. package/templates/hook-packs/claude-code/agent-register.sh +62 -0
  160. package/templates/hook-packs/claude-code/agent-stop.sh +51 -0
  161. package/templates/hook-packs/claude-code/audit.sh +1 -1
  162. package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
  163. package/templates/hook-packs/claude-code/classify_command.py +1 -1
  164. package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
  165. package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
  166. package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +19 -2
  167. package/templates/hook-packs/claude-code/dispatch/session_start.sh +6 -2
  168. package/templates/hook-packs/claude-code/dispatch/stop.sh +7 -2
  169. package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
  170. package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
  171. package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
  172. package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
  173. package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
  174. package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
  175. package/templates/hook-packs/claude-code/naming-check.sh +128 -0
  176. package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
  177. package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
  178. package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
  179. package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
  180. package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
  181. package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
  182. package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
  183. package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
  184. package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
  185. package/templates/hook-packs/claude-code/session-caws-status.sh +7 -1
  186. package/templates/hook-packs/claude-code/session-log.sh +1 -1
  187. package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
  188. package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
  189. package/templates/hook-packs/claude-code/worktree-guard.sh +130 -4
  190. package/templates/hook-packs/claude-code/worktree-write-guard.sh +133 -18
@@ -0,0 +1,147 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 11
5
+ # caws_min_major: 11
6
+ # lineage_refs: 29
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ #
9
+ # CAWS Shortcut-Language Progressive Check (QG-HOOKS-EXTRACT-001)
10
+ #
11
+ # PostToolUse hook firing on Write/Edit. Flags "shortcut" / incomplete-work
12
+ # language in committed-bound source — the edit-time analogue of the
13
+ # quality-gates `todo_detection` gate
14
+ # (packages/quality-gates/todo-analyzer.mjs + check-placeholders.mjs). It
15
+ # re-implements the practical intent in self-contained bash: catch the agent
16
+ # leaving a TODO/FIXME/placeholder/"not implemented" stub in a NON-test source
17
+ # file.
18
+ #
19
+ # Unlike the other three advisory hooks, this one escalates via the existing
20
+ # progressive-strike mechanism (guard-strikes.sh):
21
+ # strike 1 -> warn (allow)
22
+ # strike 2 -> ask (permission prompt)
23
+ # strike 3 -> block
24
+ # Rationale: TODO/placeholder language in committed code is the CLAUDE.md
25
+ # "No fake implementations" rule; repeated offenses in a session warrant
26
+ # escalation, matching how scope-guard treats repeated scope violations.
27
+ #
28
+ # Test files (*.test.* / *.spec.*) are NOT strike-eligible: TODO/placeholder
29
+ # language in tests is routine (describing pending cases, fixture stubs).
30
+ #
31
+ # Patterns (case-insensitive) — only the high-signal subset of the
32
+ # todo-analyzer engine, kept to single-file grep for hook-time speed:
33
+ # \bTODO\b \bFIXME\b \bXXX\b \bHACK\b \bTBD\b
34
+ # "not implemented" "implement later" "coming soon" "placeholder"
35
+ # stub-return shapes: return null;? // TODO ; throw new Error("not implemented")
36
+ #
37
+ # env: none (strike count fixed at 3 via guard-strikes.sh).
38
+
39
+ set -uo pipefail
40
+
41
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42
+ # shellcheck source=lib/parse-input.sh
43
+ source "$SCRIPT_DIR/lib/parse-input.sh" 2>/dev/null || exit 0
44
+ parse_hook_input || exit 0
45
+ # shellcheck source=guard-strikes.sh
46
+ source "$SCRIPT_DIR/guard-strikes.sh" 2>/dev/null || exit 0
47
+
48
+ FILE_PATH="$HOOK_FILE_PATH"
49
+ TOOL_NAME="$HOOK_TOOL_NAME"
50
+
51
+ case "$TOOL_NAME" in
52
+ Write | Edit) ;;
53
+ *) exit 0 ;;
54
+ esac
55
+
56
+ [[ -z "$FILE_PATH" ]] && exit 0
57
+
58
+ # Skip generated / vendored / build output.
59
+ case "$FILE_PATH" in
60
+ */node_modules/* | */dist/* | */build/* | */coverage/* | */.next/* | */out/* | */vendor/*)
61
+ exit 0
62
+ ;;
63
+ esac
64
+
65
+ # Skip non-source artifacts: markdown/docs and lockfiles routinely contain
66
+ # the word "placeholder"/"TODO" as prose. The hook targets code.
67
+ case "$(basename "$FILE_PATH")" in
68
+ *.md | *.markdown | *.txt | *.lock | *-lock.json | package-lock.json | *.min.js | *.bundle.js | *.map)
69
+ exit 0
70
+ ;;
71
+ esac
72
+
73
+ # Test files are exempt from strikes (TODO/placeholder is routine in specs).
74
+ case "$FILE_PATH" in
75
+ *.test.* | *.spec.* | */tests/* | */__tests__/* | */test/* | */fixtures/*)
76
+ exit 0
77
+ ;;
78
+ esac
79
+
80
+ # The content to scan: prefer the tool payload (works on untracked files and
81
+ # is exactly what the agent just wrote). Write -> .content; Edit -> .new_string.
82
+ CONTENT=""
83
+ if [[ -n "${HOOK_TOOL_INPUT_JSON:-}" ]] && command -v jq >/dev/null 2>&1; then
84
+ CONTENT=$(printf '%s' "$HOOK_TOOL_INPUT_JSON" | jq -r '.content // .new_string // empty' 2>/dev/null || printf '')
85
+ fi
86
+ # Fallback: read the file from disk if the payload had no content field.
87
+ if [[ -z "$CONTENT" ]] && [[ -f "$FILE_PATH" ]]; then
88
+ CONTENT=$(cat "$FILE_PATH" 2>/dev/null || printf '')
89
+ fi
90
+ [[ -z "$CONTENT" ]] && exit 0
91
+
92
+ # Match the high-signal patterns. grep -nE on the content via a here-string;
93
+ # -i case-insensitive. Word-boundary keywords first, then phrases, then
94
+ # stub-return shapes.
95
+ MATCH=""
96
+ PATTERN_DESC=""
97
+
98
+ # 1. Keyword markers (word-boundary).
99
+ if hit=$(printf '%s' "$CONTENT" | grep -nE '\b(TODO|FIXME|XXX|HACK|TBD)\b' 2>/dev/null | head -1); then
100
+ if [[ -n "$hit" ]]; then
101
+ MATCH="$hit"
102
+ PATTERN_DESC="incomplete-work marker (TODO/FIXME/XXX/HACK/TBD)"
103
+ fi
104
+ fi
105
+
106
+ # 2. Placeholder / not-implemented phrases.
107
+ if [[ -z "$MATCH" ]]; then
108
+ if hit=$(printf '%s' "$CONTENT" | grep -niE 'not implemented|implement later|coming soon|placeholder' 2>/dev/null | head -1); then
109
+ if [[ -n "$hit" ]]; then
110
+ MATCH="$hit"
111
+ PATTERN_DESC="placeholder / not-implemented language"
112
+ fi
113
+ fi
114
+ fi
115
+
116
+ # 3. Stub-return shapes (throw new Error("not implemented") is caught above;
117
+ # catch the bare "return null; // TODO" combo and an explicit stub throw).
118
+ if [[ -z "$MATCH" ]]; then
119
+ if hit=$(printf '%s' "$CONTENT" | grep -niE 'throw new Error\(["'"'"']not implemented' 2>/dev/null | head -1); then
120
+ if [[ -n "$hit" ]]; then
121
+ MATCH="$hit"
122
+ PATTERN_DESC="explicit not-implemented stub throw"
123
+ fi
124
+ fi
125
+ fi
126
+
127
+ [[ -z "$MATCH" ]] && exit 0
128
+
129
+ # Trim the matched line for the message (strip leading whitespace, cap length).
130
+ LINE_TEXT=$(printf '%s' "$MATCH" | sed 's/^[0-9]*://; s/^[[:space:]]*//' | cut -c1-120)
131
+
132
+ BASE="Shortcut-language advisory in ${FILE_PATH}: ${PATTERN_DESC} — \"${LINE_TEXT}\". CAWS doctrine (\"No fake implementations\") asks for complete code in committed source, not TODO/placeholder stubs."
133
+ MSG1="${BASE} (strike 1 of 3 — advisory.)"
134
+ MSG2="${BASE} (strike 2 of 3 — please resolve before continuing.)"
135
+ MSG3="${BASE} (strike 3 — blocked. Replace the placeholder/stub with a real implementation, or move the work to a tracked spec.)"
136
+
137
+ guard_enforce_progressive_strikes \
138
+ "${HOOK_SESSION_ID:-unknown}" \
139
+ "shortcut_language" \
140
+ "${HOOK_CWD:-}" \
141
+ "$MSG1" "$MSG2" "$MSG3"
142
+
143
+ # guard_enforce_progressive_strikes emits the decision JSON. For strikes 1/2
144
+ # it is an allow/ask (exit 0). For strike 3 it emits a block decision; exit 0
145
+ # is correct for PostToolUse (the tool already ran) — the block decision in
146
+ # the JSON is what Claude Code honors.
147
+ exit 0
@@ -1,9 +1,9 @@
1
1
  #!/bin/bash
2
2
  # CAWS-MANAGED-HOOK
3
3
  # hook_pack: claude-code
4
- # hook_pack_version: 2
4
+ # hook_pack_version: 11
5
5
  # caws_min_major: 11
6
- # lineage_refs: 4,6,11
6
+ # lineage_refs: 4,6,11,19
7
7
  # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
8
  #
9
9
  # CAWS Worktree Safety Guard for Claude Code (v11-shape).
@@ -50,11 +50,137 @@ if echo "$COMMAND" | grep -qE 'caws\s+(worktree\s+create|parallel\s+setup).*--sc
50
50
  fi
51
51
 
52
52
  if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*git\s+sparse-checkout'; then
53
- echo "BLOCKED: git sparse-checkout is not allowed in this project." >&2
54
- echo "Use full worktrees without sparse-checkout." >&2
53
+ # WORKTREE-SPEC-CANONICAL-ACCESS-GUARD-001 A3: blanket refusal stays.
54
+ # Agent-issued git sparse-checkout commands are refused regardless of
55
+ # subcommand (disable / set / init / reapply / list / add). Recovery
56
+ # of the canonical-spec-materialization invariant in a linked CAWS
57
+ # worktree is a CAWS worktree-repair concern routed through the CLI,
58
+ # not an agent-Bash git operation.
59
+ echo "BLOCKED: agent-issued git sparse-checkout is refused in CAWS projects." >&2
60
+ echo "" >&2
61
+ echo "Sparse-checkout in a CAWS linked worktree carries the mechanical guard" >&2
62
+ echo "against the v10.2 split-brain authority class: .caws/specs/ is excluded" >&2
63
+ echo "from the worktree by design, so canonical spec authority cannot be" >&2
64
+ echo "materialized inside the worktree as a divergent private copy. Disabling" >&2
65
+ echo "sparse-checkout (or any sparse-checkout reconfiguration via agent Bash)" >&2
66
+ echo "would re-open that class. Linked worktrees must not use worktree-local" >&2
67
+ echo ".caws/specs/ files as authority; CAWS resolves spec reads through the" >&2
68
+ echo "canonical control plane regardless of cwd." >&2
69
+ echo "" >&2
70
+ echo "To read a spec from any cwd (including this worktree), use:" >&2
71
+ echo " caws specs show <id>" >&2
72
+ echo "" >&2
73
+ echo "To check scope from any cwd, use:" >&2
74
+ echo " caws scope show <path>" >&2
75
+ echo " caws scope check <path>" >&2
76
+ echo "" >&2
77
+ echo "To restore the sparse-checkout invariant on a linked worktree (e.g.," >&2
78
+ echo "after a human-authorized sparse-checkout reconfiguration left the tree" >&2
79
+ echo "with materialized .caws/specs/ files), run from the canonical checkout:" >&2
80
+ echo " caws worktree repair-sparse <name>" >&2
81
+ echo "" >&2
82
+ echo "The repair command is non-destructive: it refuses dirty .caws/specs/" >&2
83
+ echo "rather than stashing, cleaning, or deleting work." >&2
55
84
  exit 2
56
85
  fi
57
86
 
87
+ # ─── CANONICAL-CHECKOUT-WORKTREE-GUARD-001 (Entry 19) ────────────────
88
+ # Block mutating git commands from the canonical checkout while at
89
+ # least one active CAWS worktree exists. Hook-layer enforcement only:
90
+ # authority remains in worktrees.json + specs. The guard's refusal
91
+ # predicate is conjunctive: canonical + worktrees-active + mutating
92
+ # command. Any one false MUST allow.
93
+ #
94
+ # Leases (.caws/leases/*.json) are NOT consulted by this decision —
95
+ # stale-lease-is-evidence-never-authority. The block decision uses
96
+ # worktrees.json's active entries only.
97
+ canonical_guard_emit_block() {
98
+ local action="$1"
99
+ local first_active="$2"
100
+ echo "BLOCKED: $action from the canonical checkout while CAWS worktrees are active." >&2
101
+ echo "Active worktree(s) detected (e.g. '$first_active' in .caws/worktrees.json)." >&2
102
+ echo "Switch into your worktree before mutating: cd .caws/worktrees/$first_active" >&2
103
+ echo "Or destroy any worktree that is genuinely abandoned: caws worktree destroy <name>" >&2
104
+ }
105
+
106
+ # Determine whether the session's cwd is the canonical checkout.
107
+ # git_dir == git_common_dir indicates canonical; a linked worktree has
108
+ # git_dir under git_common_dir/worktrees/<name>/.
109
+ CANONICAL_GUARD_CHECK_CWD="${HOOK_CWD:-$PROJECT_DIR}"
110
+ if command -v git >/dev/null 2>&1 && [[ -d "$CANONICAL_GUARD_CHECK_CWD" ]]; then
111
+ GIT_DIR_RESOLVED=$(cd "$CANONICAL_GUARD_CHECK_CWD" && git rev-parse --git-dir 2>/dev/null | head -1 || echo "")
112
+ GIT_COMMON_RESOLVED=$(cd "$CANONICAL_GUARD_CHECK_CWD" && git rev-parse --git-common-dir 2>/dev/null | head -1 || echo "")
113
+ if [[ -n "$GIT_DIR_RESOLVED" ]] && [[ -n "$GIT_COMMON_RESOLVED" ]]; then
114
+ # Normalize to absolute paths so equality is structural, not textual.
115
+ GIT_DIR_ABS=$(cd "$CANONICAL_GUARD_CHECK_CWD" && cd "$GIT_DIR_RESOLVED" 2>/dev/null && pwd || echo "$GIT_DIR_RESOLVED")
116
+ GIT_COMMON_ABS=$(cd "$CANONICAL_GUARD_CHECK_CWD" && cd "$GIT_COMMON_RESOLVED" 2>/dev/null && pwd || echo "$GIT_COMMON_RESOLVED")
117
+ if [[ "$GIT_DIR_ABS" == "$GIT_COMMON_ABS" ]]; then
118
+ # We are in the canonical checkout. Now check for active worktrees.
119
+ WORKTREES_JSON="$PROJECT_DIR/.caws/worktrees.json"
120
+ if [[ -f "$WORKTREES_JSON" ]] && command -v node >/dev/null 2>&1; then
121
+ FIRST_ACTIVE_WT=$(node -e "
122
+ try {
123
+ var reg = JSON.parse(require('fs').readFileSync('$WORKTREES_JSON', 'utf8'));
124
+ function entriesOf(r) {
125
+ if (!r || typeof r !== 'object') return [];
126
+ if (r.worktrees && typeof r.worktrees === 'object') {
127
+ return Object.entries(r.worktrees);
128
+ }
129
+ var out = [];
130
+ for (var k in r) {
131
+ if (Object.prototype.hasOwnProperty.call(r, k)) {
132
+ var v = r[k];
133
+ if (v && typeof v === 'object') out.push([k, v]);
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+ var entries = entriesOf(reg);
139
+ // 'active' is the documented status; entries without an
140
+ // explicit status (legacy/in-flight registry shapes) are
141
+ // also treated as active because the CLI's createWorktree
142
+ // does not always emit a status field.
143
+ var active = entries.filter(function(e) {
144
+ var s = e[1] && e[1].status;
145
+ return s === 'active' || s === undefined || s === null || s === '';
146
+ });
147
+ if (active.length > 0) console.log(active[0][0]);
148
+ else console.log('');
149
+ } catch(e) { console.log(''); }
150
+ " 2>/dev/null || echo "")
151
+ if [[ -n "$FIRST_ACTIVE_WT" ]]; then
152
+ # Predicate (a) canonical + (b) at least one active worktree
153
+ # is satisfied. Now check (c) mutation command keywords.
154
+ # Read-only commands (status, log, diff, show, fetch w/o --prune,
155
+ # rev-parse, ls-files, branch -v, stash list) are NOT in this
156
+ # set; they fall through to the existing guard rules.
157
+ if echo "$COMMAND" | grep -qE '(^|[[:space:];&|])git\s+checkout\s+[^[:space:]-]'; then
158
+ canonical_guard_emit_block "git checkout (branch switch)" "$FIRST_ACTIVE_WT"
159
+ exit 2
160
+ fi
161
+ if echo "$COMMAND" | grep -qE '(^|[[:space:];&|])git\s+switch\s+[^[:space:]-]'; then
162
+ canonical_guard_emit_block "git switch (branch switch)" "$FIRST_ACTIVE_WT"
163
+ exit 2
164
+ fi
165
+ if echo "$COMMAND" | grep -qE '(^|[[:space:];&|])git\s+branch\s+(-f|--force)'; then
166
+ canonical_guard_emit_block "git branch -f (force branch update)" "$FIRST_ACTIVE_WT"
167
+ exit 2
168
+ fi
169
+ # git reset variants other than --hard (already covered later in
170
+ # this file) — --keep, --merge, --soft, --mixed, or with no
171
+ # mode flag — mutate the canonical's working tree/HEAD.
172
+ if echo "$COMMAND" | grep -qE '(^|[[:space:];&|])git\s+reset\b' \
173
+ && ! echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
174
+ canonical_guard_emit_block "git reset (HEAD mutation)" "$FIRST_ACTIVE_WT"
175
+ exit 2
176
+ fi
177
+ fi
178
+ fi
179
+ fi
180
+ fi
181
+ fi
182
+ # ─── /CANONICAL-CHECKOUT-WORKTREE-GUARD-001 ──────────────────────────
183
+
58
184
  # Block cross-boundary file copies (worktree → main).
59
185
  WORKTREE_BASE="$PROJECT_DIR/.caws/worktrees"
60
186
  if [[ -d "$WORKTREE_BASE" ]]; then
@@ -1,21 +1,37 @@
1
1
  #!/bin/bash
2
2
  # CAWS-MANAGED-HOOK
3
3
  # hook_pack: claude-code
4
- # hook_pack_version: 2
4
+ # hook_pack_version: 11
5
5
  # caws_min_major: 11
6
6
  # lineage_refs: 4,8,13
7
7
  # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
8
  #
9
- # CAWS Worktree Write Guard for Claude Code (v11-shape, intentionally
10
- # fail-open for v11.1).
9
+ # CAWS Worktree Write Guard for Claude Code.
11
10
  #
12
- # This hook fires on Write/Edit and currently allows all writes from the
13
- # main checkout. Worktree-first enforcement returns when worktree lifecycle
14
- # is restored in CLI-WORKTREE-001 (Slice 6). Until then, this hook serves
15
- # as the managed-install seat for the worktree-write enforcement surface
16
- # and asserts the always-allowed allowlist so .caws/, .claude/, docs/,
17
- # scripts/, tmp/, and tests/ writes are never inadvertently blocked by a
18
- # future enforcement pass that forgets the allowlist.
11
+ # Two responsibilities:
12
+ #
13
+ # 1. Canonical-spec-materialization refusal
14
+ # (WORKTREE-SPEC-CANONICAL-ACCESS-GUARD-001 A1/A2).
15
+ # From inside a linked worktree (git rev-parse --git-common-dir !=
16
+ # git rev-parse --git-dir, after realpath normalization), refuse
17
+ # Read/Write/Edit tool calls whose file_path resolves under
18
+ # <linked-worktree>/.caws/specs/*. Such files would be private
19
+ # materialized copies of canonical spec authority, divergent from
20
+ # the canonical .caws/specs bytes, silently consulted by anything
21
+ # that walks cwd upward. The refusal MUST fire BEFORE the broad
22
+ # .caws/* allowlist below, otherwise the allowlist would exit 0
23
+ # first and the slice would appear implemented while the target
24
+ # path still bypassed the guard. The canonical checkout itself
25
+ # (git_common_dir == git_dir) IS spec authority and is allowed
26
+ # through this predicate; this refusal targets the linked-worktree
27
+ # materialization class only.
28
+ #
29
+ # 2. Base-branch write enforcement (intentionally fail-open for
30
+ # v11.1, restored in CLI-WORKTREE-001). The hook serves as the
31
+ # managed-install seat for the worktree-write enforcement surface
32
+ # and asserts the always-allowed allowlist so .caws/, .claude/,
33
+ # docs/, scripts/, tmp/, and tests/ writes are never inadvertently
34
+ # blocked by a future enforcement pass that forgets the allowlist.
19
35
  #
20
36
  # Worktree-active enforcement (when restored) must read the worktrees
21
37
  # registry under both shapes:
@@ -34,23 +50,122 @@ TOOL_NAME="$HOOK_TOOL_NAME"
34
50
  FILE_PATH="$HOOK_FILE_PATH"
35
51
 
36
52
  case "$TOOL_NAME" in
37
- Write|Edit) ;;
53
+ Read|Write|Edit) ;;
38
54
  *) exit 0 ;;
39
55
  esac
40
56
 
41
- PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
42
- PROJECT_DIR="$(cd "$PROJECT_DIR" 2>/dev/null && pwd || printf '%s\n' "$PROJECT_DIR")"
57
+ # WORKTREE_ROOT: where the agent is operating from. This is the cwd
58
+ # whose .caws/specs/* path is the refusal target. Kept distinct from
59
+ # CANONICAL_ROOT below — these MUST NOT be conflated for the spec-path
60
+ # predicate.
61
+ WORKTREE_ROOT="${CLAUDE_PROJECT_DIR:-.}"
62
+ WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" 2>/dev/null && pwd -P || printf '%s\n' "$WORKTREE_ROOT")"
43
63
 
64
+ # _realpath: best-effort realpath. macOS lacks `readlink -f` by default;
65
+ # python3 is available on every supported runner (CI matrix verified).
66
+ # Falls back to the original path if realpath cannot resolve.
67
+ _realpath() {
68
+ local p="$1"
69
+ if [[ -z "$p" ]]; then
70
+ printf '%s\n' ""
71
+ return 0
72
+ fi
73
+ if command -v python3 >/dev/null 2>&1; then
74
+ python3 -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$p" 2>/dev/null || printf '%s\n' "$p"
75
+ else
76
+ printf '%s\n' "$p"
77
+ fi
78
+ }
79
+
80
+ # Linked-worktree detection via git as primary signal. CAWS registry
81
+ # (.caws/worktrees.json) is consulted ONLY for diagnostic enrichment;
82
+ # a registry desync MUST NOT suppress the refusal (I3).
83
+ IS_LINKED_WORKTREE=0
84
+ CANONICAL_ROOT=""
44
85
  if command -v git >/dev/null 2>&1; then
45
- GIT_COMMON_DIR=$(cd "$PROJECT_DIR" && git rev-parse --git-common-dir 2>/dev/null || echo "")
46
- if [[ -n "$GIT_COMMON_DIR" ]] && [[ "$GIT_COMMON_DIR" != ".git" ]]; then
47
- CANDIDATE=$(cd "$PROJECT_DIR" && cd "$GIT_COMMON_DIR/.." 2>/dev/null && pwd || echo "")
48
- if [[ -n "$CANDIDATE" ]] && [[ -d "$CANDIDATE/.caws" ]]; then
49
- PROJECT_DIR="$CANDIDATE"
86
+ GIT_COMMON_DIR_RAW="$(cd "$WORKTREE_ROOT" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null || printf '')"
87
+ GIT_DIR_RAW="$(cd "$WORKTREE_ROOT" 2>/dev/null && git rev-parse --git-dir 2>/dev/null || printf '')"
88
+ if [[ -n "$GIT_COMMON_DIR_RAW" ]] && [[ -n "$GIT_DIR_RAW" ]]; then
89
+ # Resolve relative paths against WORKTREE_ROOT before realpath.
90
+ case "$GIT_COMMON_DIR_RAW" in
91
+ /*) GIT_COMMON_DIR_ABS="$GIT_COMMON_DIR_RAW" ;;
92
+ *) GIT_COMMON_DIR_ABS="$WORKTREE_ROOT/$GIT_COMMON_DIR_RAW" ;;
93
+ esac
94
+ case "$GIT_DIR_RAW" in
95
+ /*) GIT_DIR_ABS="$GIT_DIR_RAW" ;;
96
+ *) GIT_DIR_ABS="$WORKTREE_ROOT/$GIT_DIR_RAW" ;;
97
+ esac
98
+ GIT_COMMON_DIR="$(_realpath "$GIT_COMMON_DIR_ABS")"
99
+ GIT_DIR="$(_realpath "$GIT_DIR_ABS")"
100
+ if [[ -n "$GIT_COMMON_DIR" ]] && [[ "$GIT_COMMON_DIR" != "$GIT_DIR" ]]; then
101
+ IS_LINKED_WORKTREE=1
102
+ # CANONICAL_ROOT = parent of GIT_COMMON_DIR. Used for allowlist
103
+ # rewriting only; NOT used for the spec-path refusal predicate.
104
+ CANONICAL_CANDIDATE="$(_realpath "$GIT_COMMON_DIR/..")"
105
+ if [[ -n "$CANONICAL_CANDIDATE" ]] && [[ -d "$CANONICAL_CANDIDATE/.caws" ]]; then
106
+ CANONICAL_ROOT="$CANONICAL_CANDIDATE"
107
+ fi
50
108
  fi
51
109
  fi
52
110
  fi
53
111
 
112
+ # Canonical-spec-materialization refusal (I1: BEFORE the allowlist).
113
+ #
114
+ # Predicate: tool_name in {Read,Write,Edit} (already gated above)
115
+ # AND is_linked_worktree (via git signal)
116
+ # AND FILE_PATH resolves under <WORKTREE_ROOT>/.caws/specs/.
117
+ #
118
+ # WORKTREE_ROOT is the cwd-as-resolved-via-CLAUDE_PROJECT_DIR. NOT
119
+ # CANONICAL_ROOT, NOT a PROJECT_DIR that has been rewritten upward. The
120
+ # refused path lives under the LINKED worktree's tree.
121
+ if [[ "$IS_LINKED_WORKTREE" == "1" ]] && [[ -n "$FILE_PATH" ]]; then
122
+ # WORKTREE_ROOT is already realpath-normalized (pwd -P above), so
123
+ # SPEC_ROOT inherits that normalization. We MUST also normalize
124
+ # FILE_PATH_ABS through _realpath so the comparison is symlink-
125
+ # immune. On macOS, /tmp -> /private/tmp; without normalization, an
126
+ # agent-supplied /tmp/.../.caws/specs/X.yaml would NOT prefix-match
127
+ # SPEC_ROOT=/private/tmp/.../.caws/specs because the literal strings
128
+ # diverge. python3 os.path.realpath resolves the existing prefix
129
+ # even when the leaf does not exist (Write tool case).
130
+ SPEC_ROOT="$WORKTREE_ROOT/.caws/specs"
131
+ case "$FILE_PATH" in
132
+ /*) FILE_PATH_ABS="$FILE_PATH" ;;
133
+ *) FILE_PATH_ABS="$WORKTREE_ROOT/$FILE_PATH" ;;
134
+ esac
135
+ FILE_PATH_ABS="$(_realpath "$FILE_PATH_ABS")"
136
+ case "$FILE_PATH_ABS" in
137
+ "$SPEC_ROOT"/*|"$SPEC_ROOT")
138
+ echo "[worktree-write-guard.sh] BLOCKED: $FILE_PATH" >&2
139
+ echo "[worktree-write-guard.sh] Refusing $TOOL_NAME against a linked-worktree .caws/specs/ path." >&2
140
+ echo "[worktree-write-guard.sh]" >&2
141
+ echo "[worktree-write-guard.sh] Linked worktrees must not use worktree-local .caws/specs/ files as authority." >&2
142
+ echo "[worktree-write-guard.sh] That path would be a private materialized copy, not canonical spec authority." >&2
143
+ echo "[worktree-write-guard.sh] CAWS resolves spec reads through the canonical control plane regardless of cwd." >&2
144
+ echo "[worktree-write-guard.sh]" >&2
145
+ echo "[worktree-write-guard.sh] To read a spec from any cwd (including this worktree), use:" >&2
146
+ echo "[worktree-write-guard.sh] caws specs show <id>" >&2
147
+ echo "[worktree-write-guard.sh]" >&2
148
+ echo "[worktree-write-guard.sh] To check scope from any cwd, use:" >&2
149
+ echo "[worktree-write-guard.sh] caws scope show <path>" >&2
150
+ echo "[worktree-write-guard.sh] caws scope check <path>" >&2
151
+ echo "[worktree-write-guard.sh]" >&2
152
+ echo "[worktree-write-guard.sh] If sparse-checkout was disabled in this worktree and you need to restore" >&2
153
+ echo "[worktree-write-guard.sh] the canonical-only invariant, run from the canonical checkout:" >&2
154
+ echo "[worktree-write-guard.sh] caws worktree repair-sparse <name>" >&2
155
+ exit 2
156
+ ;;
157
+ esac
158
+ fi
159
+
160
+ # Legacy allowlist preserved from v11.1 fail-open base-branch enforcement.
161
+ # For the allowlist, use PROJECT_DIR rewritten toward the canonical checkout
162
+ # (the historical behavior) so that .caws/ etc. paths under canonical also
163
+ # match when the agent is operating from inside a linked worktree.
164
+ PROJECT_DIR="$WORKTREE_ROOT"
165
+ if [[ -n "$CANONICAL_ROOT" ]]; then
166
+ PROJECT_DIR="$CANONICAL_ROOT"
167
+ fi
168
+
54
169
  # Always-allowed paths bypass any future enforcement.
55
170
  # User-global Claude state lives outside the repo; .caws/, .claude/, docs/,
56
171
  # scripts/, tmp/, .archive/, and .githooks/ are coordination/governance