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
32 changes: 15 additions & 17 deletions .claude/hooks/block-protected-branch.sh
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
#!/usr/bin/env bash
# Claude Code PreToolUse hook (Bash): stop an agent from committing/pushing
# directly on master or dev. This is the "graceful" layer — the .husky git
# hooks are the real enforcement (and fire for any tool, not just Claude).
# PreToolUse hook (Bash / Grok run_terminal_command): stop an agent from
# committing/pushing/merging directly on master or dev. This is the "graceful"
# layer — the .husky git hooks are the real enforcement (and fire for any tool).
#
# Exit 2 blocks the tool call and feeds stderr back to the model so it can
# self-correct by starting a feature branch.

set -euo pipefail

HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
MATCHER="$HOOK_DIR/git-mutating-subcommand.py"

if [ "${1:-}" = --self-test ]; then
exec python3 "$MATCHER" --self-test
fi

input=$(cat)
cmd=$(printf '%s' "$input" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)/\1/p')

# Only care about git commit / push / merge as real subcommands.
# Pattern: git, optional intermediate tokens (e.g. -C path), then commit|push|merge
# as a whole token. The char after the subcommand must not be alnum/_/- so we do
# NOT match false friends: merge-base, merge-file, merge-tree, commit-tree.
# Example that must stay allowed: `git merge-base --is-ancestor origin/master origin/dev`
if ! printf '%s' "$cmd" | grep -Eq \
'(^|[^[:alnum:]_/-])git[[:space:]]+(.+[[:space:]])?(commit|push|merge)([^[:alnum:]_-]|$)'; then
# Honor the same escape hatch as the git hooks (also recognized inside the matcher).
if ! printf '%s' "$input" | python3 "$MATCHER"; then
exit 0
fi

# Honor the same escape hatch as the git hooks.
case "$cmd" in
*ALLOW_PROTECTED_COMMIT=1*) exit 0 ;;
esac

branch=$(git -C "${CLAUDE_PROJECT_DIR:-.}" symbolic-ref --short HEAD 2>/dev/null)
branch=$(git -C "${CLAUDE_PROJECT_DIR:-.}" symbolic-ref --short HEAD 2>/dev/null || true)
case "$branch" in
master|dev)
echo "Blocked: '$branch' is a protected branch. Do not commit/push/merge directly onto it." >&2
Expand Down
128 changes: 128 additions & 0 deletions .claude/hooks/git-mutating-subcommand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Detect git commit/push/merge as real subcommands in a shell snippet.

Used by block-protected-branch.sh. First non-option token after `git` must be
exactly commit, push, or merge — not merge-base, and not the word "merge" in a
log message or tool description.
"""
from __future__ import annotations

import json
import re
import shlex
import sys

MUTATING = {"commit", "push", "merge"}


def extract_command(raw: str) -> str:
raw = raw.strip()
if not raw:
return ""
try:
data = json.loads(raw)
except json.JSONDecodeError:
return raw
if not isinstance(data, dict):
return ""
cmd = data.get("command")
if isinstance(cmd, str) and cmd:
return cmd
tool_input = data.get("tool_input")
if isinstance(tool_input, dict):
inner = tool_input.get("command")
if isinstance(inner, str):
return inner
return ""


def _statements(cmd: str) -> list[str]:
return re.split(r"[;\n]|\|\||&&|\|", cmd)


def _first_git_subcommand(argv: list[str]) -> tuple[str | None, bool]:
i = 0
while i < len(argv) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", argv[i]):
i += 1
if i >= len(argv):
return None, False
if not re.search(r"(^|/)git$", argv[i]):
return None, False
allow = any(re.match(r"ALLOW_PROTECTED_COMMIT=", a) for a in argv[: i + 1])
i += 1
while i < len(argv):
a = argv[i]
if a == "--":
i += 1
break
if a in ("-C", "-c"):
i += 2
continue
if a.startswith("-"):
i += 1
continue
return a, allow
if i < len(argv):
return argv[i], allow
return None, allow


def is_mutating_git(cmd: str) -> bool:
for stmt in _statements(cmd):
stmt = stmt.strip()
if not stmt:
continue
try:
argv = shlex.split(stmt)
except ValueError:
argv = stmt.split()
sub, allow = _first_git_subcommand(argv)
if sub in MUTATING and not allow:
return True
return False


def self_test() -> int:
samples = [
("git log --oneline", False),
("git merge-base --is-ancestor a b", False),
("/usr/bin/git merge-base --is-ancestor a b", False),
("git status", False),
("git switch -c feature/x", False),
("git merge other", True),
("git commit -m msg", True),
("git push origin HEAD", True),
("git -C /tmp merge other", True),
("git -C /tmp merge-base a b", False),
('git log; echo "merge status"', False),
("ALLOW_PROTECTED_COMMIT=1 git commit -m x", False),
]
failed = 0
for sample, expect in samples:
got = is_mutating_git(sample)
if got != expect:
print(f"FAIL {sample!r}: got {got}, want {expect}", file=sys.stderr)
failed += 1
payload = json.dumps({
"command": "git log --oneline",
"description": "Inspect serialize branch history and merge status",
})
if is_mutating_git(extract_command(payload)):
print("FAIL json description containing 'merge' blocked git log", file=sys.stderr)
failed += 1
if failed:
return 1
print(f"{len(samples) + 1} checks passed")
return 0


def main(argv: list[str]) -> int:
if argv[1:] == ["--self-test"]:
return self_test()
raw = sys.stdin.read()
cmd = extract_command(raw)
return 0 if is_mutating_git(cmd) else 1


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
4 changes: 4 additions & 0 deletions CONTINUITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1778,3 +1778,7 @@ Temporary, reversible: `./scripts/services.sh --start` and `./scripts/deploy-cau
## 2026-08-18 — Faster local seed

`./scripts/data.sh --seed` now defaults to **tiny** (was small). `gen:small` and `gen:tiny` both pass `--skip-invariants`. Statement publish reuses one document store (or parallel PublishedData writes across Hardhat wallets, receipts awaited in a batch). Seed RPC clients poll every 50ms. See `fake-data-generation/generateStatements.ts`, `fake-data-generation/seedRpc.ts`, `scripts/data.sh`.

## 2026-08-20 — Anvil `--state` restart dump

Local `hardhat-node` was coming back empty after `stack.restart-consistency` because Docker SIGTERM did not make Anvil dump `/data/state.json` (then a fresh deploy + trust wiring filled ~41 blocks with no seed). Fix: `scripts/anvil-docker-entrypoint.sh` maps SIGTERM→SIGINT, compose `--state-interval 15` and 60s grace, restart check records block height and SIGINTs Anvil before stop. Recreate `hardhat-node` to mount the wrapper.
29 changes: 0 additions & 29 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ When an item from this page is done and no longer needs an LLM implementor's att

----

- **(Tell)** Index `ProjectFactory.ProjectCreated` (or add a creator-filtered SDK query) so CauseStarter’s “projects you created” list does not depend on `eth_getLogs` from block 0. Tracked in [`causestarter/TODO.md`](causestarter/TODO.md). Public RPCs often reject unbounded log ranges and the current catch returns `[]`.

- Add a fresh-stack integration test for the alignment-trust bootstrap: publish
an alignment vouch from a previously unknown wallet, observe the service's
`TrustSet(..., 100)`, confirm a wallet with no personal graph sees that vouch
Expand All @@ -33,33 +31,6 @@ When an item from this page is done and no longer needs an LLM implementor's att

- Fix the canonical Playwright user journeys (`stack.user-journeys`, exit 1). The content-funding flow reverts in `verifyChannel` with `InvalidVerifierSignature()` (custom error `0x0574e985`) when creating a channel and landing on the creators page, and retries hit the same error. Either the signer/verifier key the E2E harness uses no longer matches the deployed `ChannelRegistry` verifier, or the signed payload's shape/domain changed.

- Stop `stack.fresh-seeded` and `stack.restart-consistency` from racing in the deep
cadence. On 2026-08-19 the nightly run seeded successfully and then destroyed the
result: `stack.fresh-seeded` started 02:15:12 and was still mid-run when
`stack.restart-consistency` began at 02:18:31 (see
`verifier/artifacts/stack.restart-consistency/2026-08-19T06-18-31.768Z-7d0c3d6e/command.log`,
which ends in `Error response from daemon: No such container: 1ffc7816…`). Its
`docker compose stop hardhat-node && up -d` replaced the seeded anvil with an empty
one at 02:19:30; `hardhat-deploy` then redeployed contracts (blocks 1-31) and the
alignment-trust bootstrap wired trust (blocks 32-41), and nothing else ever landed.
Adam woke up to a stack with 48 blocks, 119 logs, and zero cause rosters — while the
cadence summary reported `PASS stack.fresh-seeded`. Both checks are destructive to the
local stack and must be serialized (or share a lock / be placed in a mutually exclusive
supervisor group); a green `fresh-seeded` that another check has since wiped is worse
than a red one. Two sub-issues found alongside it:
(a) `anvil --state /data/state.json` is not giving restart durability — the restarted
anvil came up empty rather than reloading the snapshot, which is also why
`stack.restart-consistency` itself failed. Its comment claims `up -d --no-deps` avoids
rerunning `hardhat-deploy`, but deploy ran anyway.
(b) `stack.fresh-seeded` only probes endpoint reachability, so an unseeded-but-healthy
stack passes it. It should assert the seed's own artifacts exist (e.g. the
`local-food-systems` / `christianity` roster refs for Hardhat #0 and the
`bookmarked-causes` refs for #0-#9), not just that the RPC answers.
Workaround if you hit this again: `./scripts/data.sh --seed=tiny --use-hardhat-accounts
--allow-seed-on-existing-data` reseeds onto the live stack without a wipe.



- [ ] **(Tell)** Measure whether the proposed planks/views model can fold `DirectSupport` events per plank client-side at approximately 10⁵ signers, or whether it needs a server-side fold. This is currently an unmeasured assertion in [shaping-your-cause-statements.md](docs/founder/shaping-your-cause-statements.md). Report the setup, timings, memory/browser behavior, and conclusion; do not build the server-side path yet.

- Verify the new local public-goods demo-seed storyline against a live stack. `PROJECT_SEED_METADATA[0]` is now "Riverside Community Garden" (aligned to `fundable-projects`/`local-community`/`local-food-systems`), `DETERMINISTIC_SEED_PROJECT_ALIGNMENT_COUNT` is 6 so no existing storyline lost its alignment, and `gen:seed:local` runs 12 users to keep the success-attester pool satisfied. Unit tests pass, but the seed has still never been run end-to-end: `stack.fresh-seeded` now passes (2026-08-03) but it seeds `tiny`, not `demo`. Run `./scripts/data.sh --wipe && ./scripts/data.sh --seed=demo` and confirm in the UI that the garden project shows an alignment vouch, contributions, and a success attestation. Consider also regenerating `data/seed-worker-outputs.json` if the Explorer fixture should mention the new cause.
Expand Down
1 change: 0 additions & 1 deletion causestarter/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ open **if they stay listed here**.

- [ ] Safety filter is MVP/heuristic + LLM policy text — not legal-grade; version/align with operator/legal specs later.
- [ ] Unpublished draft state still in `localStorage` only — multi-device recovery of *drafts* later (published rosters are on chain; published *bookmarks* follow the wallet `bookmarked-causes` ref).
- [ ] **Created-project list uses `eth_getLogs` from block 0.** Home “Projects” (`causestarter/src/lib/userProjects.ts`) looks up `ProjectCreated` on the factory with `fromBlock: 0n`, then swallows RPC errors. Public RPCs often reject unbounded log ranges, so “created” silently disappears while contributed/bookmarked still show. The indexer does not currently subscribe to `ProjectFactory.ProjectCreated` (only `LazyGivingAssuranceContractCreated`, which has no creator topic). Index that event (or add a creator-filtered SDK query) and stop using a full-history `getLogs`.
- [ ] Cause bookmarks: `bookmarked-causes` is a public wallet `updateRef`; the only user-facing warning is the Causes list-page disclaimer. Keep/remove, reconnect hydrate, and tombstoned union-sync (a later keep can restore) are in place; Playwright covers keep/remove + reconnect.
- [ ] **Project bookmarks are last-local-wins.** `bookmarked-projects` hydrates only when this device has no local key, then `persist` writes the local list over the wallet ref. A second device that bookmarked in between is clobbered. An in-flight hydrate no longer overwrites a click that landed during `getUserRef`. Reuse cause-bookmark keep/removed tombstones (or merge-before-write) before this is more than a personal list.
- [ ] Statement `bookmarks` ref is still reserved infrastructure only — no CauseStarter (or main `ui`) surface for remembering a statement without signing it.
Expand Down
22 changes: 8 additions & 14 deletions causestarter/src/lib/userProjects.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadUserProjects } from './userProjects'

const { getProject, getUserContributions, readLazyGivingProjectMetadata, getRuntimeConfigValue } = vi.hoisted(() => ({
const { getProject, getUserContributions, getUserCreatedProjects, readLazyGivingProjectMetadata } = vi.hoisted(() => ({
getProject: vi.fn(),
getUserContributions: vi.fn(),
getUserCreatedProjects: vi.fn(),
readLazyGivingProjectMetadata: vi.fn(),
getRuntimeConfigValue: vi.fn(),
}))

vi.mock('@commonality/sdk/lazy-giving', () => ({
getProject,
getUserContributions,
getUserCreatedProjects,
}))

vi.mock('@ui/lazy-giving/metadata', () => ({
readLazyGivingProjectMetadata,
}))

vi.mock('./runtimeConfig', () => ({
getRuntimeConfigValue,
}))

const USER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const PROJECT = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'

describe('loadUserProjects', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
getRuntimeConfigValue.mockReturnValue('0xcccccccccccccccccccccccccccccccccccccccc')
getUserContributions.mockResolvedValue([])
getUserCreatedProjects.mockResolvedValue([])
getProject.mockResolvedValue({
id: PROJECT,
metadataCid: 'bafy1',
Expand All @@ -42,19 +39,16 @@ describe('loadUserProjects', () => {

it('includes contributed projects', async () => {
getUserContributions.mockResolvedValue([{ projectAddress: PROJECT }])
const machinery = { publicClient: { getLogs: vi.fn().mockResolvedValue([]) } }
const machinery = {}
const rows = await loadUserProjects(machinery as never, USER)
expect(rows).toHaveLength(1)
expect(rows[0]?.title).toBe('Garden beds')
expect(rows[0]?.relations).toEqual(['contributed'])
})

it('includes created projects from factory logs', async () => {
const machinery = {
publicClient: {
getLogs: vi.fn().mockResolvedValue([{ args: { assuranceContract: PROJECT } }]),
},
}
it('includes created projects from indexed ProjectCreated events', async () => {
getUserCreatedProjects.mockResolvedValue([PROJECT])
const machinery = {}
const rows = await loadUserProjects(machinery as never, USER)
expect(rows[0]?.relations).toEqual(['created'])
})
Expand Down
24 changes: 4 additions & 20 deletions causestarter/src/lib/userProjects.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { parseAbiItem } from 'viem'
import { getProject, getUserContributions, type Project } from '@commonality/sdk/lazy-giving'
import { getProject, getUserContributions, getUserCreatedProjects, type Project } from '@commonality/sdk/lazy-giving'
import type { SDKMachinery } from '@commonality/sdk/machinery'
import { readLazyGivingProjectMetadata } from '@ui/lazy-giving/metadata'
import type { IpfsCidV1 } from '@commonality/sdk/utils'
import { mapWithConcurrency, PLANK_QUERY_CONCURRENCY } from './concurrency'
import { getRuntimeConfigValue } from './runtimeConfig'
import { listProjectBookmarks } from './projectBookmarks'

const PROJECT_CREATED_EVENT = parseAbiItem(
'event ProjectCreated(address indexed creator, address indexed token, address indexed assuranceContract, address condition)',
)

export type ProjectRelation = 'created' | 'contributed' | 'bookmarked'

export interface UserProject {
Expand All @@ -29,20 +23,10 @@ async function createdProjectAddresses(
machinery: SDKMachinery,
userAddress: string,
): Promise<string[]> {
const factory = getRuntimeConfigValue('VITE_PROJECT_FACTORY_CONTRACT_ADDRESS') as `0x${string}` | undefined
const publicClient = machinery.publicClient as
| { getLogs: (args: unknown) => Promise<Array<{ args?: { assuranceContract?: string } }>> }
| undefined
if (!factory || !publicClient?.getLogs) return []
try {
const logs = await publicClient.getLogs({
address: factory,
event: PROJECT_CREATED_EVENT,
args: { creator: userAddress as `0x${string}` },
fromBlock: 0n,
})
return logs
.map((log) => normalizeAddress(log.args?.assuranceContract ?? ''))
const addresses = await getUserCreatedProjects(machinery, userAddress)
return addresses
.map((address) => normalizeAddress(address))
.filter((address): address is string => Boolean(address))
} catch {
return []
Expand Down
19 changes: 14 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ services:
# =============================================================================
# Anvil local blockchain node (from Foundry)
# =============================================================================
# Persists full chain state (blocks + contract storage) via --state flag.
# Persists full chain state (blocks + contract storage) via --state.
# On startup: loads /data/state.json if it exists, otherwise starts fresh.
# On exit: dumps chain state to /data/state.json.
# On graceful exit and every --state-interval seconds: dumps to that file.
# The wrapper maps Docker SIGTERM to SIGINT so Anvil actually dumps instead
# of being SIGKILLed after the grace period with an empty/stale snapshot.
# Use COMMONALITY_DATA_DIR to configure where data is stored.
# Wipe this directory if you want a fresh chain.
hardhat-node:
Expand All @@ -16,9 +18,16 @@ services:
volumes:
# Persist chain data - set COMMONALITY_DATA_DIR to configure location
- ${COMMONALITY_DATA_DIR:-./data}/hardhat:/data
entrypoint: ["anvil"]
command: ["--host", "0.0.0.0", "--state", "/data/state.json"]
stop_grace_period: 30s
- ./scripts/anvil-docker-entrypoint.sh:/entrypoint.sh:ro
entrypoint: ["/bin/sh", "/entrypoint.sh"]
command:
- "--host"
- "0.0.0.0"
- "--state"
- "/data/state.json"
- "--state-interval"
- "15"
stop_grace_period: 60s
healthcheck:
test: ["CMD-SHELL", "cast block-number --rpc-url http://localhost:8545 || exit 1"]
interval: 2s
Expand Down
2 changes: 1 addition & 1 deletion inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Also, don't let any of the items get too long; usually there's a separate .md fi

- **(Tell)** Cause-board **Fully reimbursed** now means success-vouched *and* `outstandingUnreimbursedAmount === 0` (never-scouted successes omitted). It no longer reuses `AlignedProjectsList` with `statusFilterLock="succeeded"` (raised ≥ threshold). New SDK query: `getFullyReimbursedProjectsForCause`.


- **(Tell)** Indexed `ProjectFactory.ProjectCreated` in the event cache and switched CauseStarter’s “projects you created” list to `getUserCreatedProjects` (creator-filtered by topic1). No more `eth_getLogs` from block 0. Hosted indexer needs `PROJECT_FACTORY_ADDRESS` (added in `render.yaml`; also in the deployment-manifest builder). Existing stacks must reindex that contract to populate the new events.

### Security/recoverability human actions

Expand Down
Loading
Loading