ecoportal-api 0.10.16 → 0.10.17

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/.gitignore +2 -0
  3. data/.ai-assistance/bridge/.gitignore +10 -0
  4. data/.ai-assistance/bridge/CLAUDE.md +96 -0
  5. data/.ai-assistance/bridge/archive/.gitkeep +0 -0
  6. data/.ai-assistance/bridge/inbox/.gitkeep +0 -0
  7. data/.ai-assistance/bridge/outbox/.gitkeep +0 -0
  8. data/.ai-assistance/capabilities/assumptions-log.md +23 -0
  9. data/.ai-assistance/scripts/bridge-inbox-check.sh +119 -0
  10. data/.ai-assistance/scripts/bridge-init.sh +86 -0
  11. data/.ai-assistance/scripts/confine-to-subtree.sh +58 -0
  12. data/.ai-assistance/scripts/dirty-tree-guard.sh +96 -0
  13. data/.ai-assistance/scripts/distill_procedural.py +602 -0
  14. data/.ai-assistance/scripts/log-mcp-access.sh +24 -0
  15. data/.ai-assistance/scripts/log-skill-usage.sh +79 -0
  16. data/.ai-assistance/scripts/log_mcp_access.py +158 -0
  17. data/.ai-assistance/scripts/observe-session.sh +13 -0
  18. data/.ai-assistance/scripts/observe_session.py +287 -0
  19. data/.ai-assistance/scripts/protect-host-paths.sh +135 -0
  20. data/.ai-assistance/scripts/scrub.py +1149 -0
  21. data/.ai-assistance/scripts/scrub.py.sha256 +6 -0
  22. data/.ai-assistance/scripts/surface-procedural.sh +9 -0
  23. data/.ai-assistance/scripts/surface_procedural.py +101 -0
  24. data/.ai-assistance/skills/ep-ai-manager/SKILL.md +519 -0
  25. data/.ai-assistance/skills/project-self-docs/SKILL.md +259 -0
  26. data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
  27. data/.ai-assistance/standards-version.json +12 -0
  28. data/.ai-assistance/version.json +8 -0
  29. data/.claude/.gitignore +2 -0
  30. data/.claude/settings.json +128 -0
  31. data/CHANGELOG.md +8 -5
  32. data/CLAUDE.md +95 -71
  33. data/docs/self-docs/ARCHITECTURE.md +145 -0
  34. data/docs/self-docs/CHANGES.jsonl +7 -0
  35. data/docs/self-docs/COMPLIANCE.md +66 -0
  36. data/docs/self-docs/CONVENTIONS.md +74 -0
  37. data/docs/self-docs/INTEGRATIONS.md +62 -0
  38. data/docs/self-docs/OPERATIONS.md +64 -0
  39. data/docs/self-docs/OVERVIEW.md +61 -0
  40. data/docs/self-docs/STATUS.md +71 -0
  41. data/docs/self-docs/self-docs-index.json +51 -0
  42. data/docs/worklog.md +48 -0
  43. data/lib/ecoportal/api/common/client/with_retry.rb +6 -0
  44. data/lib/ecoportal/api/version.rb +1 -1
  45. metadata +40 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6998cdda6bc8a40de4a368ab81d522cd9828cd8dbbb559a59948976e3f57dcb
4
- data.tar.gz: ba86db2250fad20b429d1f5afa26a08b064e0eeb14ea90f096dceaf4973d362e
3
+ metadata.gz: e6d3ab5bbf5ab30cdf4a53a91eb9727156759a19efc68754d475ecc0a8fa45ba
4
+ data.tar.gz: 9fcd22e6b949e5b3ea302fabeedb5b502035d322b27b8e91c8b5e4ee3980869d
5
5
  SHA512:
6
- metadata.gz: fb91f3d928853691c913efbd17f0db1bda9343c44b1449275864fc16e053ef0d3cd1f14fd64e90625fc3ea991a81494c2a7315d318666184c14dd3acd51344f4
7
- data.tar.gz: 57f8fc55f1d55f1733fe98720477739d72b063bae1f4a16665132e20a5560192275b5815e25d99097400fd2f5de4c7914a35823a8e5168ea34187e4e5d9ae98c
6
+ metadata.gz: 4a39f97efb44f84488674df6c29d0efc5f16a2f7083efedb2d2be866dff771629f29649ca4fd66809f5b53c4c700d8fd4f4350a0eca43d260a22e2babd71dd5c
7
+ data.tar.gz: e1931ea7266e1b32b198f1fbf0ef218b156b2b8ab2a54ccf7b9de3d95418e6154e680865fc539248147bd1a3960188166e232ced83514f400aaa309a8bca8bad
@@ -0,0 +1,2 @@
1
+ # Machine-local, never committed (see conventions/local-paths-protocol.md)
2
+ local/
@@ -0,0 +1,10 @@
1
+ # Bridge runtime state and message files are machine-local -- never committed.
2
+ # Only the structural scaffold (bridge/CLAUDE.md, .gitkeep placeholders, this file) is
3
+ # tracked; processed pairs under archive/ stay trackable for an optional audit trail.
4
+ inbox/*
5
+ outbox/*
6
+ LOCK
7
+ STATUS
8
+ queue/
9
+ !inbox/.gitkeep
10
+ !outbox/.gitkeep
@@ -0,0 +1,96 @@
1
+ # Bridge Protocol -- CoWork <-> Claude Code Delegation
2
+
3
+ The bridge is a file-based task delegation system for handing work between the CoWork
4
+ environment and the Claude Code (CLI) environment. Use it when one environment has
5
+ connector access (e.g. GitLab MCP, Gemini) that the other doesn't.
6
+
7
+ **The bridge is for delegation of discrete tasks only -- not for working tree changes.**
8
+ All file edits happen within a single environment. The bridge passes task context and results.
9
+
10
+ ---
11
+
12
+ ## Folder structure
13
+
14
+ ```
15
+ .ai-assistance/bridge/
16
+ inbox/ ← Tasks waiting to be processed (CoWork writes; Code reads)
17
+ outbox/ ← Completed results (Code writes; CoWork reads)
18
+ archive/ ← Completed inbox/outbox pairs (audit trail)
19
+ CLAUDE.md ← This file — bridge protocol documentation
20
+ LOCK ← Concurrency control (acquired/expires/intent/connector)
21
+ STATUS ← Aggregated status (pending/in-progress counts)
22
+ ```
23
+
24
+ ---
25
+
26
+ ## Task lifecycle
27
+
28
+ ```
29
+ PENDING → IN_PROGRESS → DONE (or FAILED)
30
+ CoWork writes inbox/ → Code reads + processes → Code writes outbox/ → CoWork reads + archives
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Task filename convention
36
+
37
+ ```
38
+ <username>-<uuid7>-<slug>.md
39
+ ```
40
+
41
+ Examples:
42
+ - `oscar-01j2k3l4m5n6-gemini-review.md`
43
+ - `oscar-01j2k3l4m5n6-gitlab-mr-check.md`
44
+
45
+ ---
46
+
47
+ ## Task file format
48
+
49
+ ```markdown
50
+ ---
51
+ task_id: <uuid7>
52
+ status: PENDING # PENDING | IN_PROGRESS | DONE | FAILED
53
+ connector: gemini # which connector is needed: gemini | gitlab | rovo | local-git
54
+ created_by: oscar
55
+ created_at: 2026-06-08T01:00:00Z
56
+ expires_at: 2026-06-08T02:00:00Z
57
+ intent: >
58
+ One sentence describing what needs to happen.
59
+ ---
60
+
61
+ ## Context
62
+
63
+ [Everything the processing agent needs to understand and execute the task.]
64
+
65
+ ## Expected output
66
+
67
+ [What should be written to outbox/ — format, length, key fields.]
68
+ ```
69
+
70
+ ---
71
+
72
+ ## LOCK file
73
+
74
+ Prevents concurrent bridge writes. Acquired before writing to inbox; released when done.
75
+
76
+ ```
77
+ AGENT: claude-code
78
+ USER: oscar
79
+ ACQUIRED: 2026-06-08T01:00:00Z
80
+ EXPIRES: 2026-06-08T01:30:00Z
81
+ INTENT: running gemini-review task oscar-01j2k3l4m5n6
82
+ CONNECTOR: gemini
83
+ ```
84
+
85
+ If LOCK is present and not expired: wait or fail. If LOCK is expired: it can be overwritten.
86
+
87
+ ---
88
+
89
+ ## Autonomy zones (from conventions/permission-guidance.md)
90
+
91
+ | Zone | Operations | Approval needed |
92
+ |---|---|---|
93
+ | 1 | All reads, git status/log/diff, tests, linting | None |
94
+ | 2 | Edit + commit any file in repo (non-main branch) | Notify in same turn |
95
+ | 3 | First commit to a sibling repo | State intent, wait one exchange |
96
+ | 4 | git push, main branch changes, API/gemspec changes | Always confirm |
File without changes
File without changes
File without changes
@@ -0,0 +1,23 @@
1
+ # Assumptions Log
2
+
3
+ Dated log of assumptions made during development. Items are verified, marked STALE when
4
+ superseded, or flagged NEEDS_REVIEW when they depend on external state that may change.
5
+
6
+ ---
7
+
8
+ ## Format
9
+
10
+ ```
11
+ [YYYY-MM-DD] [STATUS] Assumption text
12
+ Source: where this came from
13
+ Verified-by: how it was confirmed (or "not yet verified")
14
+ Notes: anything relevant
15
+ ```
16
+
17
+ Status values: `VERIFIED` | `NEEDS_REVIEW` | `STALE` | `REFUTED`
18
+
19
+ ---
20
+
21
+ ## Assumptions
22
+
23
+ <!-- Add entries below -- most recent first -->
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # bridge-inbox-check.sh -- session-start actuator for the bridge inbox.
3
+ #
4
+ # Closes an actuation-coverage gap: unread cross-project bridge messages used to be
5
+ # visible only if someone manually ran a command. This is the trigger/clock that
6
+ # surfaces them automatically at session start, mirroring the standards-inbox report
7
+ # line (see CLAUDE.md and standards/governance/standards-request-protocol.md).
8
+ #
9
+ # DETERMINISTIC and zero-LLM: it just scans .ai-assistance/bridge/inbox/ for unread
10
+ # messages (STATUS: PENDING) and prints one summary line plus one line per message:
11
+ #
12
+ # [bridge] N unread message(s)
13
+ # - {title} from {FROM}
14
+ #
15
+ # Contract (mirrors the other hook scripts):
16
+ # - Silent (no output) and exit 0 when the inbox is empty or has no unread messages.
17
+ # - NEVER fails fatally -- always exits 0, even on a malformed file or missing dir.
18
+ # - No side effects: read-only, does not touch or move any message.
19
+ #
20
+ # --verbose (added 2026-07-23, dashboard-cockpit-v5): when the bridge dir HAS been
21
+ # adopted (inbox/ exists) but there is nothing to report, prints one friendly
22
+ # "[bridge] inbox clear" line instead of nothing -- a manual click with no output
23
+ # otherwise reads as "did this even run?". Bridge-not-adopted (no inbox dir at all)
24
+ # stays silent even with --verbose. DEFAULT (no flag) behavior is UNCHANGED: the
25
+ # SessionStart hook below still calls this with no arguments.
26
+ #
27
+ # Wired as a SessionStart hook in .claude/settings.json:
28
+ # bash scripts/bridge-inbox-check.sh 2>/dev/null || true
29
+ #
30
+ # Manual run (same output): bash scripts/bridge-inbox-check.sh
31
+ # Manual run, friendly-on-empty: bash scripts/bridge-inbox-check.sh --verbose
32
+ set -u
33
+
34
+ VERBOSE=0
35
+ [ "${1:-}" = "--verbose" ] && VERBOSE=1
36
+ reported=0
37
+
38
+ # Resolve the inbox by walking up from this script's OWN location, so it works whether
39
+ # the script is deployed at <root>/scripts/ (ep-ai-standards itself) or at
40
+ # <root>/.ai-assistance/scripts/ (the standard fleet deploy target) -- and from any cwd.
41
+ # A fixed "$(dirname "$0")/.." only works for the first layout; in the deploy target it
42
+ # resolves to <root>/.ai-assistance/.ai-assistance/bridge/inbox and silently finds nothing.
43
+ SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" || exit 0
44
+ INBOX=""
45
+ d="$SCRIPT_DIR"
46
+ for _ in 1 2 3 4 5; do
47
+ if [ -d "$d/.ai-assistance/bridge/inbox" ]; then INBOX="$d/.ai-assistance/bridge/inbox"; break; fi
48
+ d="$(dirname "$d")"
49
+ done
50
+
51
+ # No inbox -> nothing to report.
52
+ [ -n "$INBOX" ] && [ -d "$INBOX" ] || exit 0
53
+
54
+ # An "unread" message is a *.md file whose header carries STATUS: PENDING.
55
+ # (PENDING = not yet picked up by the receiving agent; IN_PROGRESS/DONE/FAILED are read.)
56
+ unread=""
57
+ count=0
58
+
59
+ for f in "$INBOX"/*.md; do
60
+ # Guard against the literal glob when the folder is empty.
61
+ [ -e "$f" ] || continue
62
+
63
+ # Only inspect the header block (first ~15 lines) -- cheap and format-stable.
64
+ header="$(head -n 15 "$f" 2>/dev/null || true)"
65
+
66
+ # Unread == STATUS: PENDING (case-insensitive, tolerant of surrounding whitespace).
67
+ printf '%s\n' "$header" | grep -Eiq '^[[:space:]]*STATUS:[[:space:]]*PENDING[[:space:]]*$' || continue
68
+
69
+ # Title: the text after "# TASK:" on line 1; fall back to the filename.
70
+ title="$(printf '%s\n' "$header" \
71
+ | grep -Ei '^#[[:space:]]*TASK:' \
72
+ | head -n 1 \
73
+ | sed -E 's/^#[[:space:]]*TASK:[[:space:]]*//I' 2>/dev/null || true)"
74
+ [ -n "$title" ] || title="$(basename "$f" .md)"
75
+
76
+ # Sender: the text after "FROM:"; fall back to "unknown".
77
+ from="$(printf '%s\n' "$header" \
78
+ | grep -Ei '^[[:space:]]*FROM:' \
79
+ | head -n 1 \
80
+ | sed -E 's/^[[:space:]]*FROM:[[:space:]]*//I' 2>/dev/null || true)"
81
+ [ -n "$from" ] || from="unknown"
82
+
83
+ count=$((count + 1))
84
+ unread="${unread}- ${title} from ${from}
85
+ "
86
+ done
87
+
88
+ # Report unread inbox messages (if any).
89
+ if [ "$count" -gt 0 ]; then
90
+ printf '[bridge] %d unread message(s)\n' "$count"
91
+ printf '%s' "$unread"
92
+ reported=1
93
+ fi
94
+
95
+ # Stranded-message guard: a STATUS: PENDING file in archive/ is a lifecycle contradiction --
96
+ # a message consumed-and-archived without being flipped to DONE (or archived before it was
97
+ # processed). It is invisible to the inbox scan above, so it silently looks handled. Surface it.
98
+ ARCHIVE="$(dirname "$INBOX")/archive"
99
+ stranded=0
100
+ if [ -d "$ARCHIVE" ]; then
101
+ for f in "$ARCHIVE"/*.md; do
102
+ [ -e "$f" ] || continue
103
+ head -n 15 "$f" 2>/dev/null | grep -Eiq '^[[:space:]]*STATUS:[[:space:]]*PENDING[[:space:]]*$' || continue
104
+ if [ "$stranded" -eq 0 ]; then
105
+ printf '[bridge] WARNING: STATUS: PENDING file(s) in archive/ (stranded -- consumed without closing the loop, or archived before processing):\n'
106
+ fi
107
+ stranded=$((stranded + 1))
108
+ printf ' - %s\n' "$(basename "$f")"
109
+ reported=1
110
+ done
111
+ fi
112
+
113
+ # --verbose, nothing to report: one friendly line so a manual click doesn't read as
114
+ # "did this even run?".
115
+ if [ "$VERBOSE" -eq 1 ] && [ "$reported" -eq 0 ]; then
116
+ printf '[bridge] inbox clear -- no unread messages, no stranded archive entries.\n'
117
+ fi
118
+
119
+ exit 0
@@ -0,0 +1,86 @@
1
+ #!/bin/bash
2
+ # bridge-init.sh
3
+ # Detects environment, scans bridge/inbox for pending tasks belonging to current user.
4
+ # Output: single JSON line for the agent to consume.
5
+ # Usage: source .ai-assistance/scripts/bridge-init.sh
6
+
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ BRIDGE_DIR="$SCRIPT_DIR/../bridge"
11
+ INBOX_DIR="$BRIDGE_DIR/inbox"
12
+
13
+ # --- Environment detection ---
14
+ # Prefer explicit env var; fall back to "code" (this script is most useful in Code)
15
+ ENV="${CLAUDE_ENV:-code}"
16
+
17
+ # --- Username detection ---
18
+ # Prefer explicit env var; fall back to git config
19
+ if [ -n "${BRIDGE_USER:-}" ]; then
20
+ USER_SLUG="$BRIDGE_USER"
21
+ else
22
+ GIT_USER=$(git config user.name 2>/dev/null || echo "unknown")
23
+ USER_SLUG=$(echo "$GIT_USER" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-')
24
+ fi
25
+
26
+ # --- Stale threshold (seconds) ---
27
+ # IN_PROGRESS tasks older than this are flagged as stale (likely a crashed session).
28
+ STALE_THRESHOLD_SECONDS=3600 # 1 hour
29
+
30
+ # --- Scan inbox ---
31
+ pending_tasks=()
32
+ stale_tasks=()
33
+
34
+ if [ -d "$INBOX_DIR" ]; then
35
+ while IFS= read -r -d '' file; do
36
+ filename=$(basename "$file")
37
+ # Only files prefixed with this user's slug
38
+ if [[ "$filename" == ${USER_SLUG}-* ]]; then
39
+ if grep -q "^STATUS: PENDING" "$file" 2>/dev/null; then
40
+ pending_tasks+=("$filename")
41
+ elif grep -q "^STATUS: IN_PROGRESS" "$file" 2>/dev/null; then
42
+ # Check how old the file is
43
+ if command -v stat &>/dev/null; then
44
+ # GNU stat (Linux/Git-Bash): -c %Y gives mtime as epoch seconds
45
+ # macOS stat uses -f %m — try GNU first, fall back gracefully
46
+ mtime=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null || echo 0)
47
+ now=$(date +%s)
48
+ age=$(( now - mtime ))
49
+ if [ "$age" -ge "$STALE_THRESHOLD_SECONDS" ]; then
50
+ stale_tasks+=("$filename")
51
+ fi
52
+ fi
53
+ # If stat is unavailable we silently skip stale detection for this file
54
+ fi
55
+ fi
56
+ done < <(find "$INBOX_DIR" -maxdepth 1 -name "*.md" \
57
+ -not -name "*.draft.md" \
58
+ -not -name "*.local.md" \
59
+ -not -name ".gitkeep" \
60
+ -print0 2>/dev/null)
61
+ fi
62
+
63
+ # --- Build JSON output ---
64
+ count=${#pending_tasks[@]}
65
+ stale_count=${#stale_tasks[@]}
66
+
67
+ tasks_json="["
68
+ for i in "${!pending_tasks[@]}"; do
69
+ tasks_json+="\"${pending_tasks[$i]}\""
70
+ [ $i -lt $((count - 1)) ] && tasks_json+=","
71
+ done
72
+ tasks_json+="]"
73
+
74
+ stale_json="["
75
+ for i in "${!stale_tasks[@]}"; do
76
+ stale_json+="\"${stale_tasks[$i]}\""
77
+ [ $i -lt $((stale_count - 1)) ] && stale_json+=","
78
+ done
79
+ stale_json+="]"
80
+
81
+ output="{\"env\":\"$ENV\",\"user\":\"$USER_SLUG\",\"pending\":$count,\"tasks\":$tasks_json,\"stale\":$stale_count,\"stale_tasks\":$stale_json}"
82
+
83
+ # Write to status file so Code can read it on startup (hook stdout is not injected into context)
84
+ echo "$output" > "$BRIDGE_DIR/STATUS"
85
+
86
+ echo "$output"
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # confine-to-subtree.sh -- PreToolUse hook (matcher: Bash) for a SUBFOLDER-SCOPED
3
+ # deployment. Denies Bash commands that WRITE or DELETE outside the project subtree
4
+ # (the cwd Claude was launched from, e.g. <core-repo>/doc/scripts), so the AI cannot
5
+ # modify the rest of the host repo. READS anywhere are allowed (the scripts framework
6
+ # needs to read the app's models); git is allowed (it manages the repo, incl the
7
+ # parent .git). Standard: standards/tooling/subfolder-deployment.md.
8
+ #
9
+ # Structured deny + exit 0 (holds under --dangerously-skip-permissions). Reads JSON via
10
+ # python -c (a heredoc would steal stdin). Fails OPEN on any parse error. Heuristic
11
+ # (command-string) confinement -- pair with settings.deny Edit/Write(../**), which
12
+ # reliably confines Claude's own file tools, for defense-in-depth.
13
+ PY=python3
14
+ command -v python3 >/dev/null 2>&1 || PY=python
15
+ exec "$PY" -c "
16
+ import sys, json, re
17
+ try:
18
+ data = json.loads(sys.stdin.read())
19
+ except Exception:
20
+ sys.exit(0)
21
+ if data.get(\"tool_name\") != \"Bash\":
22
+ sys.exit(0)
23
+ cmd = (data.get(\"tool_input\") or {}).get(\"command\", \"\") or \"\"
24
+ cwd = (data.get(\"cwd\") or \"\").replace(chr(92), \"/\").rstrip(\"/\")
25
+
26
+ def deny(reason):
27
+ print(json.dumps({\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\",
28
+ \"permissionDecision\": \"deny\", \"permissionDecisionReason\": reason}}))
29
+ sys.exit(0)
30
+
31
+ # git manages the whole repo (incl the parent .git); allow it. Commit-scope discipline
32
+ # (git add <subtree>) is a documented convention, not enforced here.
33
+ if re.match(r\"^\s*git\b\", cmd):
34
+ sys.exit(0)
35
+
36
+ WRITE = r\"(?:\brm\b|\brmdir\b|\bmv\b|\bcp\b|\bdd\b|\btee\b|\btruncate\b|\binstall\b|\bmkdir\b|\bln\b|>>?)\"
37
+ if not re.search(WRITE, cmd):
38
+ sys.exit(0) # read-only command -> always allowed
39
+
40
+ # Signal 1: a parent-escaping relative path (../) in a write command.
41
+ if re.search(r\"\.\./\", cmd):
42
+ deny(\"Blocked: write/delete escapes the project subtree via ../ (subfolder-scoped deploy).\")
43
+
44
+ # Signal 2: an absolute path token not under cwd (and not a temp dir).
45
+ ALLOWED = (\"/tmp/\", \"/var/tmp/\", \"/private/tmp/\", \"/var/folders/\", \"/private/var/folders/\")
46
+ for raw in cmd.split():
47
+ t = raw.strip(chr(34)).strip(chr(39)).replace(chr(92), \"/\")
48
+ if not (t.startswith(\"/\") or re.match(r\"^[A-Za-z]:/\", t)):
49
+ continue
50
+ if any(t.startswith(a) for a in ALLOWED):
51
+ continue
52
+ if cwd and (t == cwd or t.startswith(cwd + \"/\")):
53
+ continue # inside the subtree
54
+ deny(\"Blocked: write/delete targets an absolute path outside the project subtree \"
55
+ \"(subfolder-scoped deploy): \" + raw)
56
+
57
+ sys.exit(0) # writes appear to stay within the subtree -> allow
58
+ "
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # dirty-tree-guard.sh -- session hook that warns about a dirty tree ON THE DEFAULT BRANCH.
3
+ #
4
+ # Closes a recurring hygiene gap (Oscar-flagged, memory feedback-keep-main-tree-clean):
5
+ # loose, uncommitted files left behind on main/master -- often in a sibling repo after
6
+ # agent work. The rule is: work happens on a feature branch; the default branch stays
7
+ # clean. This surfaces a violation the moment it exists, at session start and stop.
8
+ #
9
+ # DETERMINISTIC and zero-LLM. It warns ONLY when BOTH are true:
10
+ # (a) HEAD is the repo DEFAULT branch (main or master -- DETECTED, not hardcoded), AND
11
+ # (b) git status --porcelain is non-empty (the tree has uncommitted changes).
12
+ # On a feature branch a dirty tree is normal mid-work -> stay SILENT.
13
+ #
14
+ # [dirty-tree-guard] N uncommitted change(s) on <branch> (default branch):
15
+ # - path/one
16
+ # ... (+M more)
17
+ # hint: move work to a feature branch (bash .ai-assistance/scripts/new-feature-branch.sh feat/<slug>) before committing.
18
+ #
19
+ # Contract (mirrors the other hook scripts):
20
+ # - Silent (no output) and exit 0 when clean, or when NOT on the default branch.
21
+ # - Safe outside a git repo: detects and exits 0 silently.
22
+ # - NEVER fails fatally -- always exits 0 (advisory only). Read-only, no side effects.
23
+ #
24
+ # Wired as a SessionStart AND Stop hook in .claude/settings.json:
25
+ # bash .ai-assistance/scripts/dirty-tree-guard.sh 2>/dev/null || true
26
+ #
27
+ # Manual run (same output): bash scripts/dirty-tree-guard.sh
28
+ set -u
29
+
30
+ # Cap on how many loose paths to list before summarising the remainder.
31
+ MAX_PATHS=15
32
+
33
+ # git present? If not, this is not a context we can (or should) inspect.
34
+ command -v git >/dev/null 2>&1 || exit 0
35
+
36
+ # Inside a git work tree? Run from wherever the session cwd is; git resolves the repo.
37
+ # Outside any repo this prints "false" (or errors) -> exit 0 silently.
38
+ in_tree="$(git rev-parse --is-inside-work-tree 2>/dev/null || true)"
39
+ [ "$in_tree" = "true" ] || exit 0
40
+
41
+ # Current branch. Detached HEAD -> empty -> treat as "not the default branch" -> silent.
42
+ branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
43
+ [ -n "$branch" ] && [ "$branch" != "HEAD" ] || exit 0
44
+
45
+ # Detect the DEFAULT branch -- never hardcode. Preference order:
46
+ # 1. origin/HEAD symbolic ref (the remote default, e.g. origin/main)
47
+ # 2. a local main, then master, if either exists
48
+ # Fall back to empty (no detectable default) -> silent, so we never warn on a repo
49
+ # whose default we could not establish.
50
+ default=""
51
+ remote_head="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
52
+ if [ -n "$remote_head" ]; then
53
+ # Strip the leading origin/ prefix to get the bare branch name.
54
+ default="${remote_head#origin/}"
55
+ fi
56
+ if [ -z "$default" ]; then
57
+ if git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
58
+ default="main"
59
+ elif git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
60
+ default="master"
61
+ fi
62
+ fi
63
+
64
+ # Not on the default branch -> a dirty tree is expected mid-work -> silent.
65
+ [ -n "$default" ] && [ "$branch" = "$default" ] || exit 0
66
+
67
+ # On the default branch: is the tree dirty?
68
+ status="$(git status --porcelain 2>/dev/null || true)"
69
+ [ -n "$status" ] || exit 0
70
+
71
+ # Dirty on default. Count and list (capped).
72
+ total="$(printf '%s\n' "$status" | grep -c . || true)"
73
+ [ -n "$total" ] || total=0
74
+ [ "$total" -gt 0 ] || exit 0
75
+
76
+ printf '[dirty-tree-guard] %s uncommitted change(s) on %s (default branch):\n' "$total" "$branch"
77
+
78
+ shown=0
79
+ while IFS= read -r line; do
80
+ [ -n "$line" ] || continue
81
+ # Porcelain v1 line: 2-char status, a space, then the path. Strip the status prefix.
82
+ path="$(printf '%s' "$line" | cut -c4-)"
83
+ printf ' - %s\n' "$path"
84
+ shown=$((shown + 1))
85
+ [ "$shown" -ge "$MAX_PATHS" ] && break
86
+ done <<INNER_EOF
87
+ $status
88
+ INNER_EOF
89
+
90
+ if [ "$total" -gt "$shown" ]; then
91
+ printf ' ... (+%s more)\n' "$((total - shown))"
92
+ fi
93
+
94
+ printf 'hint: move work to a feature branch (bash .ai-assistance/scripts/new-feature-branch.sh feat/<slug>) before committing, then keep %s clean.\n' "$branch"
95
+
96
+ exit 0