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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .githooks/post-merge
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
files=(docs/dev-hq/data.js docs/dev-hq/data.json)
# A branch must not carry a regenerated snapshot: it conflicts with every other
# PR (CI-CONFLICT-01). Only main and hq-snapshot branches regenerate.
# shellcheck source=scripts/lib/hotspots.sh
. "$ROOT/scripts/lib/hotspots.sh"
branch="$(hs_branch "$ROOT")"
if ! hs_may_touch "$branch"; then
echo "post-merge: Branch '${branch:-detached}' erzeugt die HQ-Momentaufnahme nicht neu (Konfliktherd); das tut main." >&2
exit 0
fi
if [ -n "$(git status --porcelain -- "${files[@]}")" ]; then
echo "post-merge: lokale HQ-Aenderungen bleiben erhalten; spaeter npm run hq ausfuehren." >&2
exit 0
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ bash scripts/ci/gates.sh lane prepush # the full local lane
bash scripts/ci/gates.sh --from clippy lane linux # resume after a failure
```

Generated and shared files are written on `main` only: a branch must not
commit `docs/dev-hq/data.js|json` or `.pa/ACTIVITY.md` (gate `hotspot-guard`;
PRs merge without our local merge driver, so each such change conflicts with
every other PR). `sync.sh note` writes to the untracked `.pa/ACTIVITY.local.md`
on a branch (summary goes into the PR text), `post-merge` regenerates the
snapshot on `main` only, and the coordinator refreshes it in one small PR from a
`<vendor>/hq-snapshot-*` branch (`npm run hq`, commit just the two files).

Lanes: `precommit`, `prepush`, `linux`, `windows`, `release`, `audit`. `ci.yml`,
`release.yml`, `audit.yml` and both git hooks call exactly these lanes — one
step per lane, no list left in the YAML. Drift is not checked, it is impossible.
Expand Down
5 changes: 5 additions & 0 deletions scripts/ci/gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ GATES=(
# can invoke them directly after checkout.
"script-modes|prepush,linux,release|.|bash scripts/ci/script-modes.sh"
"selftest-script-modes|prepush,linux,release|.|bash scripts/test-script-modes.sh"
# CI-CONFLICT-01: branches must not commit the generated HQ snapshot or
# .pa/ACTIVITY.md - GitHub/Mergify merge without our merge driver, so each
# such PR conflicts with every other one. The self-test proves it can fail.
"hotspot-guard|prepush,branchpush,linux|.|bash scripts/ci/hotspot-guard.sh"
"selftest-hotspot-guard|prepush,linux,release|.|bash scripts/test-hotspot-guard.sh"
"architecture-drift|precommit,prepush,linux|.|node --test scripts/ci/architecture-drift.test.mjs && node scripts/ci/architecture-drift.mjs check"

# CI-06: the trailers of the new commits against origin/main, with the very
Expand Down
38 changes: 38 additions & 0 deletions scripts/ci/hotspot-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# hotspot-guard (CI-CONFLICT-01): a branch must not change the generated HQ
# snapshot or the shared journal. Rules and reasons: scripts/lib/hotspots.sh.
#
# Env: HOTSPOT_BRANCH (branch name override), HOTSPOT_BASE (base commit; the
# default is the merge-base with origin/main - no base is a failure, not a pass).
set -uo pipefail

ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
# shellcheck source=scripts/lib/hotspots.sh
. "$ROOT/scripts/lib/hotspots.sh"
cd "$ROOT" || exit 1

branch="$(hs_branch "$ROOT")"
if [ -n "$branch" ] && hs_may_touch "$branch"; then
echo "hotspot-guard: branch '$branch' may change the generated files - nothing to check"
exit 0
fi

base="${HOTSPOT_BASE:-$(git merge-base origin/main HEAD 2> /dev/null)}"
if [ -z "$base" ]; then
echo "hotspot-guard: no base commit (git fetch origin, or set HOTSPOT_BASE)" >&2
exit 1
fi

hit="$(git diff --name-only "$base" HEAD -- "${HS_FILES[@]}")" || exit 1
if [ -z "$hit" ]; then
echo "hotspot-guard: ok - no hotspot file changed on '${branch:-detached HEAD}'"
exit 0
fi

echo "hotspot-guard: branch '${branch:-detached HEAD}' changes files that conflict across PRs:" >&2
printf ' %s\n' $hit >&2
echo "These are written on main only (AGENTS.md, Checks). Drop the change and keep the branch:" >&2
echo " git checkout $base -- $(printf '%s ' $hit)" >&2
echo " git commit -m 'chore: drop generated hotspot files'" >&2
echo "A journal note belongs in the PR text; the snapshot is refreshed by the coordinator on a hq-snapshot branch." >&2
exit 1
32 changes: 32 additions & 0 deletions scripts/lib/hotspots.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Shared rules for the merge-conflict hotspots (CI-CONFLICT-01). Functions
# only - nothing runs when sourced.
#
# GitHub and Mergify compute mergeability without our local merge driver, so
# every branch that commits one of these files re-conflicts with every other
# one the moment the first lands. Only main and a dedicated snapshot branch
# (`<vendor>/hq-snapshot...`, made by the coordinator) may commit them. Queue
# branches may carry such a commit while Mergify validates it before merging.

HS_FILES=(docs/dev-hq/data.js docs/dev-hq/data.json .pa/ACTIVITY.md)

# Branch name to judge: explicit override, then the CI refs, then git. Empty
# (detached HEAD, nothing known) is not a branch that may touch the files.
hs_branch() { # [root]
if [ -n "${HOTSPOT_BRANCH:-}" ]; then
printf '%s' "$HOTSPOT_BRANCH"
elif [ -n "${GITHUB_HEAD_REF:-}" ]; then
printf '%s' "$GITHUB_HEAD_REF"
elif [ "${GITHUB_ACTIONS:-}" = "true" ] && [ -n "${GITHUB_REF_NAME:-}" ]; then
printf '%s' "$GITHUB_REF_NAME"
else
git -C "${1:-.}" branch --show-current 2> /dev/null
fi
}

hs_may_touch() { # branch
case "$1" in
main | master | gh-readonly-queue/* | mergify/merge-queue/* | hq-snapshot* | */hq-snapshot*) return 0 ;;
esac
return 1
}
25 changes: 21 additions & 4 deletions scripts/lib/hq-post-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { spawnSync } from 'node:child_process';

const root = resolve(import.meta.dirname, '../..');
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash';
// The hook judges the branch from CI refs first; the fixture repo must not see them.
function cleanEnv() {
const env = { ...process.env };
for (const name of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'HOTSPOT_BRANCH', 'GITHUB_ACTIONS', 'GITHUB_HEAD_REF', 'GITHUB_REF_NAME']) delete env[name];
return env;
}
function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'hq-post-merge-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
function run(exe, args) {
const env = { ...process.env };
for (const name of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR']) delete env[name];
const result = spawnSync(exe, args, { cwd: dir, env, encoding: 'utf8' });
const result = spawnSync(exe, args, { cwd: dir, env: cleanEnv(), encoding: 'utf8' });
assert.equal(result.status, 0, result.stdout + result.stderr);
return result.stdout + result.stderr;
}
Expand All @@ -27,6 +31,8 @@ function fixture(t) {
mkdirSync(join(dir, 'docs/dev-hq'), { recursive: true });
copyFileSync(join(root, '.githooks/post-merge'), join(dir, '.githooks/post-merge'));
copyFileSync(join(root, '.githooks/merge-hqdata'), join(dir, '.githooks/merge-hqdata'));
mkdirSync(join(dir, 'scripts/lib'));
copyFileSync(join(root, 'scripts/lib/hotspots.sh'), join(dir, 'scripts/lib/hotspots.sh'));
writeFileSync(join(dir, 'STAND.md'), 'base');
writeFileSync(join(dir, 'scripts/dev-hq.mjs'), `
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
Expand Down Expand Up @@ -115,7 +121,7 @@ test('post-merge generator failure cannot publish a partial pair', t => {
writeFileSync(join(process.argv[process.argv.indexOf('--out') + 1], 'data.json'), 'partial');
process.exit(1);
`);
const result = spawnSync(bash, ['.githooks/post-merge'], {cwd: f.dir, encoding: 'utf8'});
const result = spawnSync(bash, ['.githooks/post-merge'], {cwd: f.dir, env: cleanEnv(), encoding: 'utf8'});
assert.equal(result.status, 1, result.stdout + result.stderr);
assert.equal(readFileSync(join(f.dir, 'docs/dev-hq/data.json'), 'utf8'), before);
});
Expand All @@ -135,3 +141,14 @@ test('post-merge retains lessons with the real HQ generator', t => {
assert.ok(actual.lessons.length > 0);
assert.equal(actual.lessons.length, Array.isArray(source) ? source.length : source.lessons.length);
});

test('post-merge leaves the snapshot alone on a non-main branch', t => {
const f = fixture(t);
f.git('checkout', '-qb', 'claude/some-work');
writeFileSync(join(f.dir, 'STAND.md'), 'changed source');
f.git('commit', '-qam', 'change source');
const before = readFileSync(join(f.dir, 'docs/dev-hq/data.json'), 'utf8');
f.hook();
assert.equal(readFileSync(join(f.dir, 'docs/dev-hq/data.json'), 'utf8'), before);
assert.equal(f.git('status', '--porcelain').trim(), '');
});
2 changes: 1 addition & 1 deletion scripts/session-end-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ instanz="${1:-agent}"
root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
[ -f "$root/scripts/sync.sh" ] || exit 0

act="$root/.pa/ACTIVITY.md"
act="$(bash "$root/scripts/sync.sh" where 2>/dev/null)" || exit 0
if [ -f "$act" ]; then
alter=$(( $(date +%s) - $(stat -c %Y "$act" 2>/dev/null || echo 0) ))
[ "$alter" -lt 1800 ] && exit 0
Expand Down
25 changes: 21 additions & 4 deletions scripts/sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
# Hängt einen Aktivitäts-Eintrag an .pa/ACTIVITY.md an. Zeitstempel,
# Branch und Uncommitted-Liste werden automatisch aus git ermittelt.
# Neue Top-Level-Dateien/Ordner in der Zusammenfassung namentlich nennen.
# Nur auf main (und hq-snapshot-Zweigen) landet der Eintrag in der
# getrackten Datei; auf jedem anderen Branch in .pa/ACTIVITY.local.md
# (ungetrackt) - die getrackte Datei ist ein Konfliktherd (CI-CONFLICT-01).
# Die Zusammenfassung gehoert dort in den PR-Text.

set -u

Expand All @@ -17,12 +21,17 @@ if [ -z "$ROOT" ]; then
echo "sync.sh: nicht in einem git-Repository" >&2
exit 1
fi
ACTIVITY="$ROOT/.pa/ACTIVITY.md"
# shellcheck source=scripts/lib/hotspots.sh
. "$ROOT/scripts/lib/hotspots.sh"
TRACKED="$ROOT/.pa/ACTIVITY.md"
ACTIVITY="$TRACKED"
branch="$(hs_branch "$ROOT")"
hs_may_touch "$branch" || ACTIVITY="$ROOT/.pa/ACTIVITY.local.md"
Comment on lines +27 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Schreibe ACTIVITY auch auf Snapshot-Branches nur lokal

Auf einem <vendor>/hq-snapshot-*-Branch liefert hs_may_touch Erfolg, weshalb diese Zuweisung .pa/ACTIVITY.md als Ziel behält; zugleich beendet hotspot-guard für denselben Branch seine Prüfung vorzeitig. Ein vorgeschriebener sync.sh note verändert dadurch das getrackte Journal, obwohl der dedizierte Snapshot-PR laut Regel nur die beiden docs/dev-hq/data.*-Dateien enthalten soll. Die Snapshot-Ausnahme muss für das Journal getrennt von der Berechtigung zur Snapshot-Regeneration behandelt werden.

AGENTS.md reference: AGENTS.md:L148-L154

Useful? React with 👍 / 👎.


print_activity_tail() {
if [ -f "$ACTIVITY" ]; then
if [ -f "$TRACKED" ]; then
echo "== .pa/ACTIVITY.md (letzte Einträge) =="
tail -n 40 "$ACTIVITY"
tail -n 40 "$TRACKED"
Comment on lines +32 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Zeige den lokalen Handover beim Start an

Auf jedem normalen Arbeitsbranch schreibt sync.sh note den Handover nun nach .pa/ACTIVITY.local.md, aber sync.sh start liest hier weiterhin ausschließlich $TRACKED. Damit sieht die nächste Sitzung beim vorgeschriebenen Start-Check nur das globale Journal und nicht die gerade für diesen Arbeitsbaum hinterlassene Zusammenfassung; die neue lokale Notiz hat im Repository keinen anderen Leser. Gib beim Start zusätzlich den Tail von $ACTIVITY aus, wenn dieser von $TRACKED abweicht.

AGENTS.md reference: AGENTS.md:L294-L297

Useful? React with 👍 / 👎.

else
echo "== .pa/ACTIVITY.md existiert noch nicht =="
fi
Expand Down Expand Up @@ -109,10 +118,18 @@ case "$cmd" in
fi
} >> "$ACTIVITY"
echo "Eintrag an $ACTIVITY angehängt."
if [ "$ACTIVITY" != "$TRACKED" ]; then
echo "Hinweis: Branch '${branch:-detached}' darf .pa/ACTIVITY.md nicht committen (Konfliktherd)."
echo " Der Eintrag liegt in der ungetrackten Datei .pa/ACTIVITY.local.md; die Zusammenfassung gehört in den PR-Text."
fi
;;

where)
echo "$ACTIVITY"
;;

*)
echo "Aufruf: bash scripts/sync.sh start|note" >&2
echo "Aufruf: bash scripts/sync.sh start|note|where" >&2
exit 1
;;
esac
88 changes: 88 additions & 0 deletions scripts/test-hotspot-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Self-test for CI-CONFLICT-01: branches must not commit the generated HQ
# snapshot or .pa/ACTIVITY.md (scripts/ci/hotspot-guard.sh, scripts/sync.sh).
# Each case runs in a fresh temp repo; passing cases print `ok <name>`.
set -uo pipefail

HERE="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/hotspot-guard.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT
fails=0

pass() { echo "ok $1"; }
fail() { echo "FEHLER $1"; fails=$((fails + 1)); }

# A repo that carries the scripts under test, one base commit on main.
repo() { # name
local d="$tmp/$1"
mkdir -p "$d/scripts/ci" "$d/scripts/lib" "$d/docs/dev-hq" "$d/.pa"
cp "$HERE/scripts/sync.sh" "$d/scripts/"
cp "$HERE/scripts/ci/hotspot-guard.sh" "$d/scripts/ci/" 2> /dev/null
cp "$HERE/scripts/lib/hotspots.sh" "$d/scripts/lib/" 2> /dev/null
(
cd "$d" || exit 1
git init -q -b main . && git config user.email t@t && git config user.name t
printf 'a\n' > docs/dev-hq/data.js; printf '{}\n' > docs/dev-hq/data.json
printf '# ACTIVITY\n' > .pa/ACTIVITY.md; printf 'x\n' > other.txt
git add -A && git commit -qm base
)
echo "$d"
}

guard() { # dir -> exit code of the guard in dir (CI env stripped)
(cd "$1" && env -u GITHUB_ACTIONS -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u HOTSPOT_BRANCH \
HOTSPOT_BASE="$(git rev-parse main)" bash scripts/ci/hotspot-guard.sh > "$1/out" 2>&1)
}

# branch-with-change <name> <branch> <file>: commit a change to <file> on <branch>
branch_change() {
local d
d="$(repo "$1")"
(cd "$d" && git checkout -q -b "$2" && printf 'changed\n' >> "$3" && git commit -qam change)
echo "$d"
}

for f in docs/dev-hq/data.js docs/dev-hq/data.json .pa/ACTIVITY.md; do
d="$(branch_change "rej-$(basename "$f")" claude/some-work "$f")"
if guard "$d"; then fail "guard rejects package branch touching $f"; else
grep -q "$f" "$d/out" && grep -q "git checkout" "$d/out" && pass "guard rejects package branch touching $f" ||
fail "guard rejects package branch touching $f (message lacks file or fix)"
fi
done

d="$(branch_change other claude/some-work other.txt)"
guard "$d" && pass "guard accepts branch touching other files only" || fail "guard accepts branch touching other files only"

d="$(repo mainc)"
(cd "$d" && printf 'n\n' >> .pa/ACTIVITY.md && git commit -qam note)
(cd "$d" && env -u GITHUB_ACTIONS -u GITHUB_HEAD_REF HOTSPOT_BASE="$(git rev-parse HEAD~1)" bash scripts/ci/hotspot-guard.sh > out 2>&1) &&
pass "guard accepts main touching the hotspot files" || fail "guard accepts main touching the hotspot files"

d="$(branch_change snap claude/hq-snapshot-refresh docs/dev-hq/data.json)"
guard "$d" && pass "guard accepts a dedicated hq-snapshot branch" || fail "guard accepts a dedicated hq-snapshot branch"

d="$(branch_change queue mergify/merge-queue/0123456789 docs/dev-hq/data.json)"
guard "$d" && pass "guard accepts a Mergify merge queue branch" || fail "guard accepts a Mergify merge queue branch"

d="$(branch_change nobase claude/some-work docs/dev-hq/data.js)"
(cd "$d" && env -u HOTSPOT_BASE -u GITHUB_ACTIONS -u GITHUB_HEAD_REF bash scripts/ci/hotspot-guard.sh > out 2>&1) &&
fail "guard fails closed without a base" || pass "guard fails closed without a base"

# sync.sh note: on a branch the tracked journal stays untouched.
d="$(repo note-branch)"
(cd "$d" && git checkout -q -b claude/some-work)
out="$(cd "$d" && env -u HOTSPOT_BRANCH -u GITHUB_ACTIONS -u GITHUB_HEAD_REF bash scripts/sync.sh note test "summary" 2>&1)"
if [ -z "$(git -C "$d" diff --name-only -- .pa/ACTIVITY.md)" ] && grep -q summary "$d/.pa/ACTIVITY.local.md" 2> /dev/null &&
printf '%s' "$out" | grep -q "ACTIVITY.local.md"; then
pass "sync note on a package branch leaves the tracked ACTIVITY.md alone"
else
fail "sync note on a package branch leaves the tracked ACTIVITY.md alone"
fi

d="$(repo note-main)"
(cd "$d" && env -u HOTSPOT_BRANCH -u GITHUB_ACTIONS -u GITHUB_HEAD_REF bash scripts/sync.sh note test "summary" > /dev/null 2>&1)
grep -q summary "$d/.pa/ACTIVITY.md" && pass "sync note on main appends to the tracked ACTIVITY.md" ||
fail "sync note on main appends to the tracked ACTIVITY.md"

[ "$fails" -eq 0 ] || { echo "$fails Fehler"; exit 1; }
echo "alle Hotspot-Faelle ok"
Loading