Skip to content

Commit 78ef050

Browse files
Flix6xclaude
andcommitted
Merge main (typed DeviceInventory, #2321) into feat/chp
Adapt the CHP converter-port concept to the device inventory: FlexDevice entries carry their coupling name and signed coupling coefficient, and DeviceInventory.coupling_groups replaces Scheduler._build_coupling_groups as the single source of truth for the solver's coupling groups, keyed by canonical device indices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qxM7UZ5wHTz3ftz1Mf9yy Signed-off-by: F.N. Claessen <felix@seita.nl>
2 parents ecf10b0 + db31a27 commit 78ef050

105 files changed

Lines changed: 8956 additions & 1080 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/hooks/worktree-guard.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env bash
2+
# Blocking PreToolUse hook: the primary checkout is shared by multiple Claude
3+
# Code agents. Branch-mutating git commands (checkout/switch/merge/rebase/
4+
# reset/cherry-pick/stash) run there can switch the branch or rewrite history
5+
# out from under another agent, and `git stash` silently pockets other agents'
6+
# uncommitted work. This hook blocks those commands when they target the
7+
# primary checkout, and allows them when they target a linked `git worktree`,
8+
# where mutating branch state only affects that worktree.
9+
#
10+
# Precision caveat: a PreToolUse hook does NOT run in the Bash tool's
11+
# persistent working directory — it runs in the project dir. So this guard is
12+
# "cwd + -C/--git-dir aware, not shell-state aware": if an agent ran
13+
# `cd <worktree>` in an earlier Bash call and then runs a bare `git switch`,
14+
# we evaluate against the primary checkout and block it. That is the safe
15+
# direction to fail (annoying, not dangerous) — use `git -C <worktree> ...`,
16+
# or `cd <worktree> && git ...` in the same command, to be recognised.
17+
#
18+
# Exit 2 = blocking error (Claude sees stderr and can course-correct).
19+
# Exit 0 = allow. Fails open (exit 0) on any parsing problem — never break
20+
# the user's shell over a malformed hook payload or a missing `jq`.
21+
22+
set -euo pipefail
23+
24+
payload="$(cat)"
25+
26+
command -v jq >/dev/null 2>&1 || exit 0
27+
28+
command="$(echo "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
29+
[ -n "$command" ] || exit 0
30+
31+
# Self-filter: only act on git commands that could mutate branch/worktree
32+
# state. The settings-level "if" matcher is not honoured by all Claude Code
33+
# versions, so replicate the filter here. Global options such as `-C <path>`,
34+
# `--git-dir=<path>` or `-c foo=bar` may sit between `git` and the verb, so
35+
# allow a run of option words (each optionally followed by its value) first.
36+
verb_re='\bgit[[:space:]]+(-[^[:space:]]*([[:space:]]+[^-[:space:];&|][^[:space:]]*)?[[:space:]]+)*(checkout|switch|merge|rebase|reset|cherry-pick|stash)\b'
37+
echo "$command" | grep -qE "$verb_re" || exit 0
38+
39+
# `git checkout -- <path>` / `git checkout <ref> -- <path>` only restore
40+
# files from the index/a ref; they never move HEAD or the branch pointer.
41+
# Allow those explicitly even though they match the "checkout" verb above.
42+
if echo "$command" | grep -qE '\bcheckout\b[^;&|]*[[:space:]]--[[:space:]]'; then
43+
exit 0
44+
fi
45+
46+
project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}"
47+
48+
# Work out which directory the git command actually targets, in priority
49+
# order: an explicit `-C <path>` / `--git-dir=<path>` on the git invocation
50+
# (the idiomatic way to drive another worktree without cd-ing), else a `cd`
51+
# earlier in the same command (same idea as pre-commit-check.sh's target
52+
# check), else the directory this hook runs in.
53+
target=""
54+
if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then
55+
target="$(echo "$command" | grep -oE '[[:space:]]-C[[:space:]]+[^[:space:];&|]+' | head -n1 | sed -E 's/^[[:space:]]*-C[[:space:]]+//')"
56+
elif echo "$command" | grep -qE '\-\-git-dir[=[:space:]]'; then
57+
target="$(echo "$command" | grep -oE '\-\-git-dir[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--git-dir[=[:space:]]//')"
58+
elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then
59+
target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')"
60+
fi
61+
62+
# Strip surrounding quotes/whitespace and expand ~ where we safely can. Do the
63+
# expansion in a subshell with `set +u` so an unbound variable can't abort us.
64+
target="$(echo "$target" | sed -E "s/^[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*$//")"
65+
if [ -n "$target" ]; then
66+
case "$target" in
67+
*['$~']*)
68+
# The path needs shell expansion. Only `~` is safe to expand here: a
69+
# variable like `-C "$WT"` was set in an earlier Bash call, whose state
70+
# this hook cannot see, so we cannot tell which checkout it points at.
71+
expanded="$(set +u; eval "echo $target" 2>/dev/null || true)"
72+
if [ -n "$expanded" ] && [ -d "$expanded" ]; then
73+
target="$expanded"
74+
else
75+
# Block rather than guess — the safe direction, same as a bare
76+
# `git switch` whose cwd we cannot see.
77+
unresolved="$target"
78+
target="$project_dir"
79+
fi
80+
;;
81+
esac
82+
fi
83+
84+
if [ -n "$target" ]; then
85+
# An absolute target outside the project dir entirely isn't our business.
86+
case "$target" in
87+
"$project_dir"|"$project_dir"/*) : ;;
88+
/*) exit 0 ;;
89+
esac
90+
else
91+
target="$(pwd)"
92+
fi
93+
94+
[ -d "$target" ] || exit 0
95+
96+
if [ -n "${unresolved:-}" ]; then
97+
cat >&2 <<EOF
98+
Blocked: cannot verify where this git command points. Its target path
99+
($unresolved) uses a shell variable this hook cannot see — a PreToolUse hook
100+
does not share your shell's state, so it may well be the shared primary
101+
checkout, where branch switching/merging clobbers other agents' sessions.
102+
103+
Re-run it with a literal path, which this hook can check:
104+
git -C /absolute/path/to/worktree <verb> ...
105+
EOF
106+
exit 2
107+
fi
108+
109+
# Only guard when the target is inside a git repo at all.
110+
git_dir="$(git -C "$target" rev-parse --git-dir 2>/dev/null || true)"
111+
common_dir="$(git -C "$target" rev-parse --git-common-dir 2>/dev/null || true)"
112+
{ [ -n "$git_dir" ] && [ -n "$common_dir" ]; } || exit 0
113+
114+
# Normalize to absolute paths (git may return them relative to the target).
115+
abspath() {
116+
local p="$1"
117+
case "$p" in
118+
/*) : ;;
119+
*) p="$target/$p" ;;
120+
esac
121+
(cd "$(dirname "$p")" 2>/dev/null && echo "$(pwd)/$(basename "$p")") || echo "$p"
122+
}
123+
git_dir_abs="$(abspath "$git_dir")"
124+
common_dir_abs="$(abspath "$common_dir")"
125+
126+
# In a linked worktree, --git-dir (.../.git/worktrees/<name>) differs from
127+
# --git-common-dir (.../.git). In the primary checkout they're the same.
128+
# Mutating git state is safe in a linked worktree, so allow it there.
129+
if [ "$git_dir_abs" != "$common_dir_abs" ]; then
130+
exit 0
131+
fi
132+
133+
cat >&2 <<'EOF'
134+
Blocked: this git command targets the shared primary checkout — other agents
135+
may be working there right now. Do not switch branches, merge, rebase, reset
136+
or cherry-pick in it: that yanks the branch out from under another agent's
137+
session and litters the tree with leftovers. `git stash` is blocked for the
138+
same reason — in a shared checkout it silently pockets *other agents'*
139+
uncommitted work.
140+
141+
Create your own worktree and work there instead, e.g.:
142+
git worktree add --detach <scratchpad>/<name> <ref>
143+
# or, for a new branch:
144+
git worktree add -b <branch> <dir> <base>
145+
146+
Then target it explicitly — both of these forms are recognised:
147+
git -C <worktree> switch <branch>
148+
cd <worktree> && git merge main
149+
150+
Note: this hook cannot see your shell's persistent working directory, so a
151+
bare `git switch` is always judged against the primary checkout even if you
152+
cd'd into a worktree in an earlier call. Use `git -C <worktree> ...`.
153+
154+
Read-only git commands (status, log, diff, show, fetch, worktree, rev-parse,
155+
branch listing, etc.) and file-only `git checkout -- <path>` restores are
156+
fine to run here.
157+
EOF
158+
exit 2

.claude/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/test-reminder.sh\"",
4646
"if": "Bash(git push:*)",
4747
"timeout": 10
48+
},
49+
{
50+
"type": "command",
51+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\"",
52+
"if": "Bash(git:*)",
53+
"timeout": 10
4854
}
4955
]
5056
}

.github/workflows/docker-build.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,27 @@ jobs:
4747
db upgrade
4848
- name: Add toy user
4949
run: docker exec --env-file .env fm-container flexmeasures
50-
add toy-account
50+
add toy-account --kind battery --shell-vars | grep '^FM_TOY_' >> $GITHUB_ENV
5151
- name: Generate prices dummy data
5252
run: ci/generate-dummy-price.sh
5353
- name: Copy prices dummy data
5454
run: docker cp prices-tomorrow.csv fm-container:/app/prices-tomorrow.csv
5555
- name: Add beliefs
5656
run: |
5757
docker exec --env-file .env fm-container flexmeasures \
58-
add beliefs --sensor 1 --source toy-user prices-tomorrow.csv --timezone Europe/Amsterdam
58+
add beliefs --sensor ${FM_TOY_PRICE_SENSOR_ID} --source toy-user prices-tomorrow.csv --timezone Europe/Amsterdam
5959
- name: Export TOMORROW
6060
run: echo "TOMORROW=$(date --date="next day" '+%Y-%m-%d')"
6161
>> $GITHUB_ENV
6262
- name: Add schedule
6363
run: |
6464
docker exec --env-file .env fm-container flexmeasures \
65-
add schedule --sensor 2 --start ${TOMORROW}T07:00+01:00 \
65+
add schedule --sensor ${FM_TOY_BATTERY_SENSOR_ID} --start ${TOMORROW}T07:00+01:00 \
6666
--duration PT12H --soc-at-start 50% --flex-model '{"roundtrip-efficiency": "90%"}'
6767
- name: Add Toy Account for process
68-
run: docker exec --env-file .env fm-container flexmeasures add toy-account --kind process
68+
run: docker exec --env-file .env fm-container flexmeasures add toy-account --kind process --shell-vars | grep '^FM_TOY_' >> $GITHUB_ENV
6969
- name: Add Process schedule
7070
run: |
71-
docker exec --env-file .env fm-container flexmeasures add schedule --sensor 5 --scheduler ProcessScheduler \
72-
--start ${TOMORROW}T00:00:00+02:00 --duration PT24H --flex-context '{"consumption-price": {"sensor": 1}}' \
71+
docker exec --env-file .env fm-container flexmeasures add schedule --sensor ${FM_TOY_PROCESS_BREAKABLE_SENSOR_ID} --scheduler ProcessScheduler \
72+
--start ${TOMORROW}T00:00:00+02:00 --duration PT24H --flex-context '{"consumption-price": {"sensor": '"${FM_TOY_PRICE_SENSOR_ID}"'}}' \
7373
--flex-model "{\"duration\": \"PT4H\", \"process-type\": \"BREAKABLE\", \"power\": 0.2, \"time-restrictions\": [{\"start\": \"${TOMORROW}T15:00:00+02:00\", \"duration\": \"PT1H\"}]}"

.github/workflows/docker-publish.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ on:
1616
tag:
1717
description: "Git tag to build/push (e.g. v1.0.0). Required when run manually."
1818
required: false
19+
latest:
20+
description: "Also push as `latest`. Only tick this when re-running a stable release; never for a pre-release."
21+
type: boolean
22+
required: false
23+
default: false
1924

2025
permissions:
2126
contents: read
@@ -42,8 +47,24 @@ jobs:
4247
# on the `release` webhook payload, reflecting whether the "Set as
4348
# a pre-release" checkbox was ticked when the GitHub Release was
4449
# created. We only want stable releases to also claim `:latest`.
50+
#
51+
# On `workflow_dispatch` there is no release payload at all, so that
52+
# field renders as an empty string -- which would sail past a bare
53+
# `!= "true"` check and clobber `:latest` with a manually built image.
54+
# Hence the explicit event check, with an opt-in input to keep the
55+
# documented "re-run a stable release" path working.
56+
if [ "${{ github.event_name }}" = "release" ]; then
57+
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
58+
PUSH_LATEST=false
59+
else
60+
PUSH_LATEST=true
61+
fi
62+
else
63+
PUSH_LATEST="${{ inputs.latest }}"
64+
fi
65+
4566
IMAGE_TAGS="lfenergy/flexmeasures:${TAG}"
46-
if [ "${{ github.event.release.prerelease }}" != "true" ]; then
67+
if [ "$PUSH_LATEST" = "true" ]; then
4768
IMAGE_TAGS="${IMAGE_TAGS}
4869
lfenergy/flexmeasures:latest"
4970
fi

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,14 @@ repos:
2929
pass_filenames: false
3030
language: system
3131
entry: bash -c '[[ "$GITHUB_ACTIONS" == "true" ]] && exit 0 || (uv run poe generate-open-api-specs)'
32+
- repo: local
33+
hooks:
34+
- id: no-image-files
35+
name: Block image files from being committed
36+
language: fail
37+
entry: >-
38+
Image files must not be committed to this repo (they bloat .git forever).
39+
Put screenshots in the FlexMeasures/screenshots repo, or leave them
40+
untracked and attach them to the PR/issue manually.
41+
files: '\.(png|jpe?g|gif|bmp|tiff?|webp|ico|psd)$'
42+
exclude: '^(flexmeasures/ui/static/|documentation/)'

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ repeatable lesson was learned — not after every session. When you do update on
108108
`.github/instructions/ui-terminology.instructions.md`.
109109
- **Docstrings/comments**: exactly one space after punctuation — see
110110
`.github/instructions/docstrings.instructions.md`.
111+
- **The primary checkout is shared** by multiple agent sessions. Never `git checkout`/`switch`/
112+
`merge`/`rebase`/`reset`/`cherry-pick`/`stash` there — it can switch the branch or rewrite
113+
history out from under another agent, and `stash` pockets their uncommitted work. Do your work
114+
in your own `git worktree` instead (`git worktree add --detach <scratchpad>/<name> <ref>`), and
115+
target it with a **literal path**: `git -C /abs/path/to/worktree switch <branch>`. Read-only git
116+
commands are fine in the primary checkout. A `PreToolUse` hook
117+
(`.claude/hooks/worktree-guard.sh`) enforces this; it can't see your shell's cwd or variables,
118+
so a bare `git switch` after an earlier `cd`, or a `-C "$VAR"`, is blocked on the safe side.
111119

112120
## Session close checklist
113121

0 commit comments

Comments
 (0)