Extract GameCult access planning repo
This commit is contained in:
commit
397ccfd901
14 changed files with 1422 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
119
AGENTS.md
Normal file
119
AGENTS.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# GameCult Access Instructions
|
||||
|
||||
## Project Purpose
|
||||
|
||||
GameCult Access exists to hold the shared auth and access-control architecture
|
||||
for GameCult-hosted experiments without letting that shared layer metastasize
|
||||
into a giant mixed-data pit.
|
||||
|
||||
The planned reusable backbone is:
|
||||
|
||||
- provider OAuth and linking
|
||||
- signed local sessions
|
||||
- entitlement refresh
|
||||
- capability evaluation
|
||||
- app-facing access claims
|
||||
|
||||
The planned non-shared-by-default layer is:
|
||||
|
||||
- app-domain user data
|
||||
- audience data
|
||||
- creator data
|
||||
- queue/job payloads
|
||||
- product-specific state
|
||||
|
||||
## Canonical State
|
||||
|
||||
- Treat `state/map.yaml` as the canonical current project map.
|
||||
- Treat `state/scratch.md` as disposable working memory for one bounded subgoal.
|
||||
- Treat `state/evidence.jsonl` as the distilled durable ledger of decisions,
|
||||
verified boundaries, and rejected paths.
|
||||
- Treat `state/branches.json` as hypothesis tracking, not phase/status prose.
|
||||
- Treat `notes/fresh-workspace-handoff.md` as the compact re-entry packet.
|
||||
- Treat `docs/architecture.md` as the source-grounded shared architecture note.
|
||||
- Treat `docs/implementation-plan.md` as the forward plan.
|
||||
- Treat `docs/app-profiles/repixelizer.md` and
|
||||
`docs/app-profiles/streampixels.md` as thin app bindings, not the whole
|
||||
shared architecture.
|
||||
|
||||
## Important Paths
|
||||
|
||||
- Project root: `E:\Projects\gamecult-access`
|
||||
- Shared architecture: `E:\Projects\gamecult-access\docs\architecture.md`
|
||||
- Implementation plan: `E:\Projects\gamecult-access\docs\implementation-plan.md`
|
||||
- Repixelizer profile: `E:\Projects\gamecult-access\docs\app-profiles\repixelizer.md`
|
||||
- StreamPixels profile: `E:\Projects\gamecult-access\docs\app-profiles\streampixels.md`
|
||||
- Handoff summary: `E:\Projects\gamecult-access\notes\fresh-workspace-handoff.md`
|
||||
- State CLI: `E:\Projects\gamecult-access\tools\gamecult_access_state.py`
|
||||
- Pre-compaction helper: `E:\Projects\gamecult-access\tools\gamecult_access_prepare_compaction.py`
|
||||
|
||||
## Useful Commands
|
||||
|
||||
This repo does not carry a dedicated virtualenv yet. Use any working Python
|
||||
3.11+ interpreter. On this workstation, `E:\Projects\repixelizer\.venv\Scripts\python.exe`
|
||||
is known-good.
|
||||
|
||||
Preferred here:
|
||||
|
||||
```powershell
|
||||
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_state.py status
|
||||
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_state.py add-evidence --type design --status accepted --note "..."
|
||||
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_prepare_compaction.py
|
||||
```
|
||||
|
||||
Fallback if a normal `python` command exists:
|
||||
|
||||
```powershell
|
||||
python .\tools\gamecult_access_state.py status
|
||||
python .\tools\gamecult_access_state.py add-evidence --type design --status accepted --note "..."
|
||||
python .\tools\gamecult_access_prepare_compaction.py
|
||||
```
|
||||
|
||||
## Session Bootstrap And Re-entry Protocol
|
||||
|
||||
On fresh session load:
|
||||
|
||||
1. read:
|
||||
- `state/map.yaml`
|
||||
- `notes/fresh-workspace-handoff.md`
|
||||
- `docs/architecture.md`
|
||||
- `docs/implementation-plan.md`
|
||||
2. run:
|
||||
- `python .\tools\gamecult_access_state.py status`
|
||||
- `git status --short --branch`
|
||||
- `git log --oneline -5`
|
||||
3. restate the current next action from persisted state before editing
|
||||
|
||||
After compaction or suspicious continuity loss:
|
||||
|
||||
1. rerun `gamecult_access_state.py status`
|
||||
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
|
||||
3. treat the persisted next action as authoritative unless fresh evidence
|
||||
contradicts it
|
||||
|
||||
When the user says to prepare for imminent compaction:
|
||||
|
||||
1. run `tools/gamecult_access_prepare_compaction.py`
|
||||
2. update only the state that actually changed
|
||||
3. rerun the helper
|
||||
4. commit the completed persistence pass unless the work is deliberately
|
||||
mid-surgery
|
||||
|
||||
## Operating Discipline
|
||||
|
||||
- Before substantial edits, restate the current mechanism and intended change.
|
||||
- Keep the shared backbone separate from app-domain data unless there is a very
|
||||
deliberate, source-grounded reason to share something.
|
||||
- Prefer embedded shared-package mode before inventing a dedicated auth service.
|
||||
- Do not let app profiles turn into duplicate copies of the shared architecture.
|
||||
- If the diff grows while the boundary gets blurrier, stop and simplify.
|
||||
- Before handoff or compaction, sync `state/map.yaml`, refresh
|
||||
`notes/fresh-workspace-handoff.md`, add distilled evidence when future belief
|
||||
changed, and make the next action explicit.
|
||||
|
||||
## External Context
|
||||
|
||||
- For StreamPixels architecture/auth context, prefer the indexed `StreamPixels`
|
||||
repo through `voidbot` before filesystem spelunking.
|
||||
- For GameCult deployment and host conventions, check
|
||||
`E:\Projects\gamecult-ops` before improvising infra claims.
|
||||
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# GameCult Access
|
||||
|
||||
GameCult Access is the planned shared auth and access-control backbone for
|
||||
GameCult-hosted experiments.
|
||||
|
||||
The point is painfully simple:
|
||||
|
||||
- stop rebuilding Discord/Patreon login glue in every app
|
||||
- keep signed local sessions, linked identities, and capability checks reusable
|
||||
- keep app-domain data separate so "shared auth" does not become "shared swamp"
|
||||
|
||||
## Current Shape
|
||||
|
||||
This repo is planning and extraction work, not a landed auth service yet.
|
||||
|
||||
Canonical docs:
|
||||
|
||||
- `docs/architecture.md`
|
||||
- `docs/implementation-plan.md`
|
||||
- `docs/app-profiles/repixelizer.md`
|
||||
- `docs/app-profiles/streampixels.md`
|
||||
- `state/map.yaml`
|
||||
- `notes/fresh-workspace-handoff.md`
|
||||
|
||||
## Working Loop
|
||||
|
||||
1. Rehydrate from canonical state.
|
||||
2. Keep the shared backbone separate from app-domain data.
|
||||
3. Prefer embedded shared-package mode before inventing a central auth service.
|
||||
4. Move only one organ at a time.
|
||||
5. Commit finished passes before the notes start breeding.
|
||||
96
docs/app-profiles/repixelizer.md
Normal file
96
docs/app-profiles/repixelizer.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Repixelizer Access Profile
|
||||
|
||||
## What this file is
|
||||
|
||||
This file binds Repixelizer onto the shared design in `docs/architecture.md`.
|
||||
|
||||
It is future design, not a claim that the hosted demo already has auth.
|
||||
|
||||
## Current repo boundary
|
||||
|
||||
Current live hosted-demo behavior is still just:
|
||||
|
||||
- hosted-demo runtime limits and UI flags in `E:\Projects\repixelizer\src\repixelizer\gui.py`
|
||||
- the local single-process queue in that same module
|
||||
- frontend runtime-config consumption in `E:\Projects\repixelizer\frontend\src\app.ts`
|
||||
|
||||
No Discord login, Patreon login, linked identities, local sessions, or access
|
||||
gates are landed yet.
|
||||
|
||||
## App identity
|
||||
|
||||
- `app_slug`: `repixelizer`
|
||||
- primary hostname: `repixelizer.gamecult.org`
|
||||
- runtime shape: one hosted GUI plus one queue-backed worker loop inside the app
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `app_access`
|
||||
- may load the protected hosted GUI
|
||||
- `queue_submit`
|
||||
- may create a repixelizer job
|
||||
- `job_read_own`
|
||||
- may read own job state, event stream, and final output
|
||||
- `job_cancel_own`
|
||||
- may cancel own queued or running job
|
||||
- `admin_access`
|
||||
- may inspect operational or grant/admin surfaces later
|
||||
|
||||
First-cut rule shape:
|
||||
|
||||
```text
|
||||
queue_submit = app_access
|
||||
job_read_own = app_access + resource ownership check
|
||||
job_cancel_own = app_access + resource ownership check
|
||||
```
|
||||
|
||||
## Access policy
|
||||
|
||||
```text
|
||||
app_access =
|
||||
discord.allowed_role
|
||||
|| patreon.allowed_tier
|
||||
|| grant.global_member
|
||||
|| grant.app_access
|
||||
|
||||
queue_submit = app_access
|
||||
|
||||
admin_access =
|
||||
grant.operator
|
||||
|| grant.admin_access
|
||||
```
|
||||
|
||||
## Runtime binding
|
||||
|
||||
Suggested queue/job additions:
|
||||
|
||||
- `jobs.account_id`
|
||||
- `jobs.session_id`
|
||||
- `jobs.access_revision`
|
||||
- `jobs.entitlement_checked_at`
|
||||
|
||||
Route policy:
|
||||
|
||||
- public:
|
||||
- `/api/health`
|
||||
- `/api/config`
|
||||
- public or capability-gated by policy choice:
|
||||
- `/api/queue`
|
||||
- requires `queue_submit`:
|
||||
- `POST /api/jobs`
|
||||
- requires ownership:
|
||||
- `GET /api/jobs/{job_id}`
|
||||
- `GET /api/jobs/{job_id}/events`
|
||||
- `POST /api/jobs/{job_id}/heartbeat`
|
||||
- `DELETE /api/jobs/{job_id}`
|
||||
|
||||
Important invariant:
|
||||
|
||||
- job ownership resolves from the local session/account, not from Discord or
|
||||
Patreon ids
|
||||
|
||||
## Config mapping
|
||||
|
||||
- keep runtime/queue/limit env vars under `REPIXELIZER_*`
|
||||
- move shared access machinery under `GC_ACCESS_*`
|
||||
- keep per-app role/tier/capability bindings small and app-scoped
|
||||
58
docs/app-profiles/streampixels.md
Normal file
58
docs/app-profiles/streampixels.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# StreamPixels Access Profile
|
||||
|
||||
## What this file is
|
||||
|
||||
This file records how StreamPixels should fit onto the shared access backbone in
|
||||
`docs/architecture.md` without turning streamer-audience data into shared auth
|
||||
sludge.
|
||||
|
||||
It is a boundary note, not a command to rewrite StreamPixels around Repixelizer.
|
||||
|
||||
## Current repo boundary
|
||||
|
||||
The current StreamPixels architecture already makes useful splits:
|
||||
|
||||
- the web app owns the remembered viewer session
|
||||
- the service owns provider OAuth exchange and identity resolution
|
||||
- the overlay remains auth-free
|
||||
- creator memberships and operator roles are separate from general viewer state
|
||||
|
||||
That is already compatible with the shared backbone idea.
|
||||
|
||||
## What StreamPixels can reuse
|
||||
|
||||
- signed local session mechanics
|
||||
- linked-identity and signed OAuth-state patterns
|
||||
- provider adapter structure
|
||||
- creator/operator capability evaluation
|
||||
- grant/invite primitives where they fit
|
||||
|
||||
## What StreamPixels should keep separate
|
||||
|
||||
- viewer audience profiles
|
||||
- creator memberships tied to streamer spaces
|
||||
- creator connector credentials
|
||||
- overlay/runtime state
|
||||
- audience-facing app data in general
|
||||
|
||||
In plain language:
|
||||
|
||||
- the same backbone can authenticate and authorize the control plane
|
||||
- StreamPixels audience data stays StreamPixels data
|
||||
|
||||
## Capability direction
|
||||
|
||||
Likely capability families:
|
||||
|
||||
- `viewer_access`
|
||||
- `creator_access`
|
||||
- `creator_admin`
|
||||
- `operator_access`
|
||||
|
||||
These should bind onto the existing StreamPixels creator/operator separation
|
||||
instead of flattening it into one GameCult-member gate.
|
||||
|
||||
## Invariant
|
||||
|
||||
If a future shared access store exists, it must not become the canonical home
|
||||
for streamer-audience identity and product data. That stays with StreamPixels.
|
||||
330
docs/architecture.md
Normal file
330
docs/architecture.md
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
# GameCult Hosted Access Architecture
|
||||
|
||||
## What this file is
|
||||
|
||||
This file is the shared architecture note for GameCult-hosted experiments that
|
||||
need the same basic promise:
|
||||
|
||||
- let people sign in with Discord and/or Patreon
|
||||
- decide access from GameCult/community entitlements
|
||||
- keep app runtimes tied to local sessions and local accounts, not provider ids
|
||||
|
||||
It is not a map of landed auth code yet.
|
||||
|
||||
## Problem
|
||||
|
||||
If every experiment rebuilds:
|
||||
|
||||
- provider OAuth
|
||||
- identity linking
|
||||
- entitlement refresh
|
||||
- session cookies
|
||||
- queue/job ownership
|
||||
- "Discord role OR Patreon tier" logic
|
||||
|
||||
then we get five near-identical auth pits and deserve what happens next.
|
||||
|
||||
The reusable move is to build one GameCult hosted-access layer and let each app
|
||||
bind its own policy and runtime seams onto it.
|
||||
|
||||
## Design goals
|
||||
|
||||
- one reusable local account/session model across hosted experiments
|
||||
- provider login and linking owned by one shared access layer
|
||||
- per-app capability policies instead of hard-coded one-off gate checks
|
||||
- host runtimes consume signed local session claims, not raw provider tokens
|
||||
- keep app-domain data separate unless there is a deliberate reason to share it
|
||||
- start as an embedded shared package before pretending we need a central auth
|
||||
service
|
||||
|
||||
## Non-goals
|
||||
|
||||
- becoming a general public identity provider
|
||||
- building multi-tenant SaaS auth machinery
|
||||
- inventing global billing analytics because we got bored
|
||||
- forcing cross-app single sign-on on day one before a second app exists
|
||||
|
||||
## Core concepts
|
||||
|
||||
- `Account`
|
||||
one GameCult-local person record independent of external providers
|
||||
- `LinkedIdentity`
|
||||
one external provider identity attached to exactly one local account
|
||||
- `EntitlementSnapshot`
|
||||
cached result from a provider or grant source saying whether some claim is
|
||||
currently true
|
||||
- `CapabilityGrant`
|
||||
a reusable local grant such as `global_member`, `app_access`, or
|
||||
`admin_access`, scoped either globally or to one app
|
||||
- `AppAccessProfile`
|
||||
the per-app binding that names capabilities, allowed provider sources, and
|
||||
policy rules
|
||||
- `Session`
|
||||
signed local app/browser session; the browser gets this, not provider tokens
|
||||
- `AuditEvent`
|
||||
durable record for login, link, unlink, refresh, denial, and admin overrides
|
||||
|
||||
## System shape
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Browser / app web UI"] --> B["Hosted experiment web surface"]
|
||||
B --> C["GameCult access layer"]
|
||||
C --> D["Discord OAuth + guild role checks"]
|
||||
C --> E["Patreon OAuth + campaign/tier checks"]
|
||||
C --> F["Local account/session/grant store"]
|
||||
C --> G["Signed access claims"]
|
||||
G --> H["Hosted experiment runtime"]
|
||||
```
|
||||
|
||||
The important split:
|
||||
|
||||
- the access layer owns provider OAuth, identity linking, entitlement refresh,
|
||||
and signed local sessions
|
||||
- the host experiment owns its workload, queue, editor, render path, or other
|
||||
product-specific machinery
|
||||
|
||||
Do not smear provider logic directly through every host runtime.
|
||||
|
||||
## Backbone reuse is not data merging
|
||||
|
||||
Using the same auth backbone does **not** mean all hosted experiments should
|
||||
pour their user data into one communal bucket.
|
||||
|
||||
The reusable thing is:
|
||||
|
||||
- provider OAuth plumbing
|
||||
- signed local session mechanics
|
||||
- linked-identity primitives
|
||||
- entitlement refresh
|
||||
- capability evaluation
|
||||
|
||||
The non-reusable-by-default thing is:
|
||||
|
||||
- app-domain user data
|
||||
- creator data
|
||||
- audience data
|
||||
- queue/job payloads
|
||||
- inventories, scenes, outputs, or other product state
|
||||
|
||||
Recommended default:
|
||||
|
||||
- shared auth/access **code**
|
||||
- separate app databases or at least separate app-owned schemas/tables for
|
||||
domain data
|
||||
- if a shared access store exists at all, keep it limited to auth/control-plane
|
||||
truth such as identities, grants, entitlement snapshots, sessions, and audit
|
||||
events
|
||||
|
||||
Do not let "same auth backbone" quietly mutate into "same user-data swamp."
|
||||
|
||||
## Reusable data model
|
||||
|
||||
Suggested durable core:
|
||||
|
||||
- `accounts`
|
||||
- `id`
|
||||
- `created_at`
|
||||
- `last_seen_at`
|
||||
- `display_name`
|
||||
- `primary_email` nullable
|
||||
- `linked_identities`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `provider`
|
||||
- `provider_user_id`
|
||||
- `username`
|
||||
- `access_token_encrypted`
|
||||
- `refresh_token_encrypted`
|
||||
- `token_expires_at`
|
||||
- `profile_json`
|
||||
- unique on `(provider, provider_user_id)`
|
||||
- `entitlement_snapshots`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `provider`
|
||||
- `scope`
|
||||
- `evaluated_at`
|
||||
- `is_allowed`
|
||||
- `reason_code`
|
||||
- `reason_detail`
|
||||
- `raw_summary_json`
|
||||
- `capability_grants`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `scope_type` enum: `global`, `app`
|
||||
- `scope_id` nullable app slug
|
||||
- `capability`
|
||||
- `source` enum: `manual`, `invite`, `migration`, `operator`
|
||||
- `status`
|
||||
- `expires_at` nullable
|
||||
- `note`
|
||||
- `sessions`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `app_slug`
|
||||
- `created_at`
|
||||
- `last_seen_at`
|
||||
- `expires_at`
|
||||
- `claims_json`
|
||||
- `access_revision`
|
||||
- `audit_events`
|
||||
- `id`
|
||||
- `account_id` nullable
|
||||
- `session_id` nullable
|
||||
- `app_slug` nullable
|
||||
- `event_type`
|
||||
- `event_payload_json`
|
||||
- `created_at`
|
||||
|
||||
Important invariants:
|
||||
|
||||
- one provider identity belongs to one local account only
|
||||
- provider tokens stay server-side only
|
||||
- session cookies represent local sessions, not provider trust directly
|
||||
- app-specific capability checks read local claims, not Discord or Patreon on
|
||||
every route
|
||||
|
||||
## Provider adapters
|
||||
|
||||
Shared provider/gate adapters should be boring and reusable:
|
||||
|
||||
- `DiscordRoleEntitlementProvider`
|
||||
- login via Discord OAuth
|
||||
- entitlement check via guild membership and role ids, ideally through the
|
||||
GameCult bot token server-side
|
||||
- `PatreonMembershipEntitlementProvider`
|
||||
- login via Patreon OAuth
|
||||
- entitlement check via campaign membership and tier ids
|
||||
- `ManualGrantProvider`
|
||||
- local override for staff, migrations, experiments, or emergency access
|
||||
- `InviteGrantProvider`
|
||||
- reusable invite-issued app or role grants where that pattern makes sense
|
||||
|
||||
## Policy model
|
||||
|
||||
The reusable core should not hard-code one app's access rules. It should
|
||||
evaluate capabilities against an app profile.
|
||||
|
||||
Suggested app-profile shape:
|
||||
|
||||
```text
|
||||
AppAccessProfile {
|
||||
appSlug: string
|
||||
displayName: string
|
||||
capabilities: string[]
|
||||
policyRules: CapabilityRule[]
|
||||
publicRoutes: string[]
|
||||
explanationStrings: map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
Example rule style:
|
||||
|
||||
```text
|
||||
app_access = discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access
|
||||
queue_submit = app_access
|
||||
admin_access = grant.operator || grant.admin_access
|
||||
```
|
||||
|
||||
## Auth and linking flow
|
||||
|
||||
The generic flow should be the same across hosted experiments:
|
||||
|
||||
1. user starts OAuth from the host app UI
|
||||
2. shared access layer signs OAuth `state`
|
||||
3. provider callback resolves the provider identity
|
||||
4. shared layer finds or creates the local account
|
||||
5. if the user was already signed in locally, the identity may link onto the
|
||||
existing account instead of creating a new one
|
||||
6. shared layer refreshes entitlements and capability claims
|
||||
7. shared layer issues or updates the local session cookie
|
||||
|
||||
The browser should never be the custodian of provider tokens.
|
||||
|
||||
## App integration contract
|
||||
|
||||
Every hosted experiment should integrate through the same seams:
|
||||
|
||||
- define an `app_slug`
|
||||
- define the app capability profile
|
||||
- mark which routes are public, authenticated, or capability-gated
|
||||
- attach `account_id`, `session_id`, and `access_revision` to owned resources
|
||||
such as jobs, drafts, uploads, or queue entries
|
||||
- require resource-owner checks on private read/update/delete paths
|
||||
|
||||
If an app has a queue or job system:
|
||||
|
||||
- queue entries belong to local account/session ids, not provider ids
|
||||
- entitlement refresh should block new submissions after access loss
|
||||
- active-job cancellation policy is app-specific, but the recommended first cut
|
||||
is still "let the active job finish, block new work"
|
||||
|
||||
## Deployment modes
|
||||
|
||||
### Mode 1: Embedded shared package
|
||||
|
||||
Recommended first cut.
|
||||
|
||||
- one shared `gamecult_access` package/module
|
||||
- each hosted experiment embeds it
|
||||
- each app keeps its own runtime and route integration
|
||||
- sessions may still be app-local
|
||||
- each app keeps its own domain-data store
|
||||
|
||||
Why this is the right first cut:
|
||||
|
||||
- reusable without forcing a separate always-on dependency
|
||||
- small blast radius
|
||||
- easy to evolve while only one or two apps use it
|
||||
|
||||
### Mode 2: Same-host shared access service
|
||||
|
||||
Use this when multiple apps on the same server want one shared access store and
|
||||
session issuer, but full centralization still feels premature.
|
||||
|
||||
### Mode 3: Dedicated GameCult access service
|
||||
|
||||
Only do this when multiple independently deployed apps genuinely need:
|
||||
|
||||
- shared cross-app sessions
|
||||
- central grant administration
|
||||
- one callback origin for all providers
|
||||
- enough auth churn that duplicated embedded deployments become the bigger pain
|
||||
|
||||
Do not build this first because it sounds important.
|
||||
|
||||
Even in this mode, the dedicated service should own auth/control-plane data, not
|
||||
absorb every app's audience or product data.
|
||||
|
||||
## Configuration split
|
||||
|
||||
Suggested generic env surface:
|
||||
|
||||
- `GC_ACCESS_ENABLED=1`
|
||||
- `GC_ACCESS_SESSION_SECRET=...`
|
||||
- `GC_ACCESS_BASE_URL=https://access.gamecult.org` or host-app local equivalent
|
||||
- `GC_ACCESS_DISCORD_CLIENT_ID=...`
|
||||
- `GC_ACCESS_DISCORD_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_DISCORD_BOT_TOKEN=...`
|
||||
- `GC_ACCESS_DISCORD_GUILD_ID=...`
|
||||
- `GC_ACCESS_PATREON_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PATREON_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PATREON_CAMPAIGN_ID=...`
|
||||
- `GC_ACCESS_ENTITLEMENT_CACHE_TTL_SECONDS=900`
|
||||
- `GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600`
|
||||
|
||||
Per-app binding should live in code or profile config, not in cloned provider
|
||||
env namespaces.
|
||||
|
||||
## Audit Result
|
||||
|
||||
The correct reusable seam is:
|
||||
|
||||
- shared provider/session/entitlement/capability machinery
|
||||
- thin per-app access profiles and runtime bindings
|
||||
|
||||
The incorrect reusable seam is:
|
||||
|
||||
- cloning one app’s auth blob into every future experiment
|
||||
- or turning shared auth into shared domain-data soup
|
||||
101
docs/implementation-plan.md
Normal file
101
docs/implementation-plan.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# GameCult Access Implementation Plan
|
||||
|
||||
## What this file is
|
||||
|
||||
This file is the forward plan and active hypothesis ledger for GameCult Access.
|
||||
|
||||
It is not the canonical shared architecture note. That lives in
|
||||
`docs/architecture.md`.
|
||||
|
||||
If the code or the architecture note disagrees with this plan, trust the code
|
||||
and the architecture note first, then fix this file.
|
||||
|
||||
## Current machine
|
||||
|
||||
There is no landed auth machine here yet.
|
||||
|
||||
What exists right now is:
|
||||
|
||||
- the extracted shared architecture note
|
||||
- app-binding notes for Repixelizer and StreamPixels
|
||||
- explicit state/doctrine scaffolding so the plan does not immediately turn into
|
||||
soup
|
||||
|
||||
## Primary objective
|
||||
|
||||
Build a reusable `gamecult_access` layer that can:
|
||||
|
||||
- own provider OAuth and identity linking
|
||||
- issue signed local sessions
|
||||
- refresh Discord and Patreon entitlements
|
||||
- evaluate per-app capability rules
|
||||
- stay separate from app-domain data
|
||||
|
||||
## Current priorities
|
||||
|
||||
### 1. Lock the shared boundary
|
||||
|
||||
Goals:
|
||||
|
||||
- keep the shared layer scoped to auth/control-plane machinery
|
||||
- keep app-domain data in app-owned stores
|
||||
- keep app profiles thin and explicit
|
||||
|
||||
### 2. Prefer embedded-package mode first
|
||||
|
||||
Goals:
|
||||
|
||||
- define the reusable package/module seam
|
||||
- avoid a dedicated shared service until a second real app proves the need
|
||||
- keep initial blast radius small
|
||||
|
||||
### 3. Make Repixelizer the first binding
|
||||
|
||||
Goals:
|
||||
|
||||
- gate hosted access and queue submission through local sessions
|
||||
- attach queue ownership to `account_id` and `session_id`
|
||||
- preserve the existing hosted GUI and queue runtime
|
||||
|
||||
### 4. Keep StreamPixels separate where it matters
|
||||
|
||||
Goals:
|
||||
|
||||
- let StreamPixels reuse auth/control-plane machinery
|
||||
- keep viewer profiles, creator memberships, connector creds, and audience data
|
||||
in StreamPixels
|
||||
|
||||
## Proposed phases
|
||||
|
||||
### Phase 1: Shared package scaffold
|
||||
|
||||
- create package/module boundaries for:
|
||||
- provider adapters
|
||||
- signed session issuance/verification
|
||||
- linked identity persistence interfaces
|
||||
- entitlement refresh/evaluation
|
||||
- capability rule evaluation
|
||||
|
||||
### Phase 2: Repixelizer binding
|
||||
|
||||
- integrate the shared layer into the hosted demo
|
||||
- gate `POST /api/jobs`
|
||||
- gate per-job read/cancel/event routes by local session ownership
|
||||
|
||||
### Phase 3: StreamPixels control-plane fit check
|
||||
|
||||
- map the shared access layer onto StreamPixels creator/operator auth seams
|
||||
- explicitly reject audience-data centralization
|
||||
|
||||
### Phase 4: Shared-store decision
|
||||
|
||||
- only after two real bindings exist, decide whether auth/control-plane state
|
||||
should stay embedded or move into a shared service/store
|
||||
|
||||
## Guardrails
|
||||
|
||||
- do not let auth reuse become data mixing
|
||||
- do not build a central service first just because it sounds grand
|
||||
- do not duplicate the shared architecture note into app profiles
|
||||
- keep one clear hypothesis per pass
|
||||
- commit completed work before the planning notes start breeding
|
||||
69
notes/fresh-workspace-handoff.md
Normal file
69
notes/fresh-workspace-handoff.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Fresh Workspace Handoff
|
||||
|
||||
This is the re-entry packet for `E:\Projects\gamecult-access`.
|
||||
|
||||
It is intentionally short. Historical proof belongs in git history and the
|
||||
distilled `state/evidence.jsonl` ledger; the shared architecture belongs in
|
||||
`docs/architecture.md`; the forward build order belongs in
|
||||
`docs/implementation-plan.md`.
|
||||
|
||||
## Rehydrate
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
# This repo has no dedicated venv yet. Use any working Python 3.11+.
|
||||
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_state.py status
|
||||
Get-Content '.\state\map.yaml'
|
||||
Get-Content '.\notes\fresh-workspace-handoff.md'
|
||||
Get-Content '.\docs\architecture.md'
|
||||
Get-Content '.\docs\implementation-plan.md'
|
||||
git status --short --branch
|
||||
git log --oneline -5
|
||||
Get-Content '.\state\evidence.jsonl' -Tail 8
|
||||
```
|
||||
|
||||
Do not trust this file for the exact live HEAD. Always check git.
|
||||
|
||||
## Current Orientation
|
||||
|
||||
- this repo is planning and extraction work, not a landed auth service yet
|
||||
- the canonical shared architecture lives in `docs/architecture.md`
|
||||
- app profiles live under `docs/app-profiles/`
|
||||
- the key boundary is explicit:
|
||||
- shared auth/control-plane mechanics are reusable
|
||||
- app-domain data stays app-owned by default
|
||||
- Repixelizer is the first binding target
|
||||
- StreamPixels is the boundary case that proves backbone reuse must not imply
|
||||
audience-data centralization
|
||||
- the recommended first implementation mode is an embedded shared package, not a
|
||||
dedicated auth service
|
||||
|
||||
## Critical Doctrine
|
||||
|
||||
- Persistent state is the agent's mind.
|
||||
- Cut stale architecture notes as ruthlessly as stale code.
|
||||
- Do not mistake "shared auth" for "shared database."
|
||||
- If compaction hits before boundary findings are persisted, that work is gone.
|
||||
Re-gather it instead of pretending continuity happened.
|
||||
|
||||
## Next Real Move
|
||||
|
||||
Do not continue implementation automatically from a rehydrate-only request.
|
||||
|
||||
If the user asks to continue, the current next move is to define the embedded
|
||||
`gamecult_access` package seam:
|
||||
|
||||
- storage interfaces
|
||||
- provider adapter interfaces
|
||||
- signed-session claims
|
||||
- capability rule evaluation boundaries
|
||||
|
||||
without central-service theatrics and without smearing app-domain data into the
|
||||
shared layer.
|
||||
|
||||
## Immediate Re-entry Instruction
|
||||
|
||||
After compaction, first rehydrate and reorient from the listed files and git
|
||||
state. Wait for the user's next instruction unless they explicitly say to
|
||||
continue.
|
||||
17
state/branches.json
Normal file
17
state/branches.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"branches": [
|
||||
{
|
||||
"id": "main",
|
||||
"hypothesis": "A shared GameCult access backbone can be reused across hosted experiments if OAuth/session/capability machinery is centralized while app-domain data stays app-owned.",
|
||||
"status": "active",
|
||||
"artifacts": [
|
||||
"state/map.yaml",
|
||||
"state/scratch.md",
|
||||
"state/evidence.jsonl",
|
||||
"docs/architecture.md",
|
||||
"docs/implementation-plan.md"
|
||||
],
|
||||
"notes": "Current mainline. Repixelizer is the first binding target; StreamPixels is the boundary stress test."
|
||||
}
|
||||
]
|
||||
}
|
||||
3
state/evidence.jsonl
Normal file
3
state/evidence.jsonl
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{"ts":"2026-04-26T00:00:00Z","type":"design","status":"accepted","note":"The shared hosted-access plan was extracted from Repixelizer into its own repo so the backbone can evolve separately from app-specific notes."}
|
||||
{"ts":"2026-04-26T00:00:00Z","type":"design","status":"accepted","note":"The recommended first implementation mode is an embedded shared gamecult_access package, not a dedicated auth service."}
|
||||
{"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, but streamer-audience data, creator memberships, connector credentials, and runtime state should remain StreamPixels-owned."}
|
||||
81
state/map.yaml
Normal file
81
state/map.yaml
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
project:
|
||||
name: GameCult Access
|
||||
status: active
|
||||
phase: architecture_extraction
|
||||
premise: >
|
||||
Build one reusable GameCult-hosted access backbone for OAuth, linked
|
||||
identities, local sessions, and capability checks without mixing in
|
||||
app-domain audience or product data.
|
||||
|
||||
objective:
|
||||
primary: >
|
||||
Create a reusable shared access layer that Repixelizer, StreamPixels, and
|
||||
future GameCult-hosted experiments can bind onto.
|
||||
current: >
|
||||
Lock the shared architecture, extraction path, and app-boundary rules before
|
||||
implementation starts breeding myths.
|
||||
|
||||
constraints:
|
||||
- Shared auth/access code is reusable; app-domain data is separate by default.
|
||||
- Prefer an embedded shared package before a dedicated auth service.
|
||||
- App profiles stay thin; the shared architecture note owns the main model.
|
||||
- StreamPixels audience and creator data must not be absorbed into a shared auth store.
|
||||
|
||||
invariants:
|
||||
- The map is canonical and slow-changing.
|
||||
- Scratch is temporary and disposable.
|
||||
- Evidence is durable and distilled; routine activity logs belong in git history and commit messages.
|
||||
- The shared access layer owns OAuth, local sessions, entitlement refresh, capability evaluation, and audit surfaces.
|
||||
- App-domain data stays with the host app unless there is a deliberate, source-grounded reason to share it.
|
||||
- Embedded shared-package mode is the default first implementation strategy.
|
||||
- StreamPixels can reuse control-plane auth mechanics without centralizing streamer-audience data.
|
||||
- Persistent state cruft is the same failure class as architecture cruft.
|
||||
|
||||
data_flow:
|
||||
observe:
|
||||
- task request
|
||||
- shared architecture note
|
||||
- app profile bindings
|
||||
- current app/runtime seams in Repixelizer and StreamPixels
|
||||
process:
|
||||
- rehydrate from canonical state
|
||||
- choose one bounded architecture or implementation slice
|
||||
- keep shared code and app-domain data boundaries explicit
|
||||
- record only belief-changing evidence
|
||||
- commit or backtrack
|
||||
cut:
|
||||
- reject shared-data creep
|
||||
- reject duplicated auth blobs per app
|
||||
- reject central-service theatrics before two real bindings justify them
|
||||
|
||||
active_subgoals:
|
||||
- Keep the shared architecture explicit and separate from app profiles.
|
||||
- Preserve the default boundary that app-domain data remains app-owned.
|
||||
- Make Repixelizer the first binding and StreamPixels the boundary stress test.
|
||||
- Define the extraction seam for an embedded `gamecult_access` package.
|
||||
|
||||
accepted_design:
|
||||
- `docs/architecture.md` is the canonical shared architecture note.
|
||||
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
|
||||
- The reusable backbone owns provider OAuth, identity linking, signed local sessions, entitlement refresh, capability evaluation, and audit events.
|
||||
- Host apps own their domain data, runtime state, and product-specific resources.
|
||||
- A future shared access store, if it exists, should hold auth/control-plane truth only.
|
||||
- Repixelizer is the first hosted-app binding.
|
||||
- StreamPixels is the boundary case that proves backbone reuse must not imply audience-data centralization.
|
||||
|
||||
landed_baseline:
|
||||
- Shared architecture extracted into its own repo.
|
||||
- Repixelizer and StreamPixels app-profile notes created.
|
||||
- Repo-local state, handoff, and compaction scaffolding created.
|
||||
|
||||
rejected_paths:
|
||||
- name: per_app_auth_copy_paste
|
||||
reason: duplicates the same provider/session sludge across every experiment
|
||||
- name: central_auth_service_first
|
||||
reason: adds deployment weight before two real bindings justify it
|
||||
- name: shared_user_data_swamp
|
||||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||
|
||||
current_status:
|
||||
summary: The shared hosted-access planning work has been extracted into its own repo with a canonical architecture note, a forward implementation plan, and thin app profiles for Repixelizer and StreamPixels. The key boundary is explicit: reusable auth/control-plane code is shared, app-domain data remains app-owned by default. The recommended first implementation mode is an embedded shared package rather than a dedicated auth service.
|
||||
next_action: Start the first implementation-oriented pass by defining the embedded `gamecult_access` package seam: storage interfaces, provider adapter interfaces, signed-session claims, and capability rule evaluation boundaries, while keeping Repixelizer as the first binding target.
|
||||
7
state/scratch.md
Normal file
7
state/scratch.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Scratch
|
||||
|
||||
This file is disposable working memory for the current bounded subgoal.
|
||||
|
||||
## Current Subgoal
|
||||
|
||||
No active scratch subgoal.
|
||||
328
tools/gamecult_access_prepare_compaction.py
Normal file
328
tools/gamecult_access_prepare_compaction.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATE_DIR = ROOT / "state"
|
||||
NOTES_DIR = ROOT / "notes"
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
|
||||
MAP_PATH = STATE_DIR / "map.yaml"
|
||||
SCRATCH_PATH = STATE_DIR / "scratch.md"
|
||||
BRANCHES_PATH = STATE_DIR / "branches.json"
|
||||
EVIDENCE_PATH = STATE_DIR / "evidence.jsonl"
|
||||
HANDOFF_PATH = NOTES_DIR / "fresh-workspace-handoff.md"
|
||||
ARCH_PATH = DOCS_DIR / "architecture.md"
|
||||
PLAN_PATH = DOCS_DIR / "implementation-plan.md"
|
||||
AGENTS_PATH = ROOT / "AGENTS.md"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
level: str
|
||||
message: str
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def run_git(args: list[str]) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def extract_map_field(name: str) -> str | None:
|
||||
prefix = f" {name}:"
|
||||
for line in read_text(MAP_PATH).splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_active_subgoals() -> list[str]:
|
||||
lines = read_text(MAP_PATH).splitlines()
|
||||
results: list[str] = []
|
||||
in_section = False
|
||||
for line in lines:
|
||||
if not in_section:
|
||||
if line.strip() == "active_subgoals:":
|
||||
in_section = True
|
||||
continue
|
||||
if line.startswith(" - "):
|
||||
results.append(line[4:].strip())
|
||||
continue
|
||||
if line and not line.startswith(" "):
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def current_scratch_subgoal() -> str | None:
|
||||
lines = read_text(SCRATCH_PATH).splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() != "## Current Subgoal":
|
||||
continue
|
||||
for candidate in lines[index + 1 :]:
|
||||
stripped = candidate.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
def load_evidence() -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for line_number, line in enumerate(read_text(EVIDENCE_PATH).splitlines(), start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"evidence line {line_number} is invalid JSON: {exc}") from exc
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"evidence line {line_number} is not a JSON object")
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def load_branches() -> dict[str, Any]:
|
||||
data = json.loads(read_text(BRANCHES_PATH))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("branches.json must be a JSON object")
|
||||
branches = data.get("branches")
|
||||
if not isinstance(branches, list):
|
||||
raise ValueError("branches.json must contain a branches list")
|
||||
return data
|
||||
|
||||
|
||||
def add_required_file_checks(findings: list[Finding]) -> None:
|
||||
for path in [
|
||||
MAP_PATH,
|
||||
SCRATCH_PATH,
|
||||
BRANCHES_PATH,
|
||||
EVIDENCE_PATH,
|
||||
HANDOFF_PATH,
|
||||
ARCH_PATH,
|
||||
PLAN_PATH,
|
||||
AGENTS_PATH,
|
||||
]:
|
||||
if path.exists():
|
||||
findings.append(Finding("ok", f"found {path.relative_to(ROOT)}"))
|
||||
else:
|
||||
findings.append(Finding("error", f"missing {path.relative_to(ROOT)}"))
|
||||
|
||||
|
||||
def add_content_checks(findings: list[Finding]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
evidence: list[dict[str, Any]] = []
|
||||
branches: dict[str, Any] = {}
|
||||
|
||||
summary = extract_map_field("summary")
|
||||
next_action = extract_map_field("next_action")
|
||||
if summary:
|
||||
findings.append(Finding("ok", "state/map.yaml has current_status.summary"))
|
||||
else:
|
||||
findings.append(Finding("error", "state/map.yaml is missing current_status.summary"))
|
||||
if next_action:
|
||||
findings.append(Finding("ok", "state/map.yaml has current_status.next_action"))
|
||||
else:
|
||||
findings.append(Finding("error", "state/map.yaml is missing current_status.next_action"))
|
||||
|
||||
subgoals = extract_active_subgoals()
|
||||
if subgoals:
|
||||
findings.append(Finding("ok", f"state/map.yaml has {len(subgoals)} active subgoal(s)"))
|
||||
else:
|
||||
findings.append(Finding("warn", "state/map.yaml has no active_subgoals entries"))
|
||||
|
||||
scratch_subgoal = current_scratch_subgoal()
|
||||
if scratch_subgoal == "No active scratch subgoal.":
|
||||
findings.append(Finding("ok", "state/scratch.md has no stale active scratch subgoal"))
|
||||
elif scratch_subgoal:
|
||||
findings.append(Finding("warn", f"state/scratch.md has active scratch subgoal: {scratch_subgoal}"))
|
||||
else:
|
||||
findings.append(Finding("warn", "state/scratch.md has no Current Subgoal value"))
|
||||
|
||||
try:
|
||||
evidence = load_evidence()
|
||||
findings.append(Finding("ok", f"state/evidence.jsonl parses ({len(evidence)} record(s))"))
|
||||
except ValueError as exc:
|
||||
findings.append(Finding("error", str(exc)))
|
||||
|
||||
if EVIDENCE_PATH.exists() and EVIDENCE_PATH.stat().st_size > 25_000:
|
||||
findings.append(Finding("warn", "state/evidence.jsonl is larger than 25 KB; consider distillation"))
|
||||
|
||||
try:
|
||||
branches = load_branches()
|
||||
active = [
|
||||
branch
|
||||
for branch in branches.get("branches", [])
|
||||
if isinstance(branch, dict) and branch.get("status") == "active"
|
||||
]
|
||||
findings.append(Finding("ok", f"state/branches.json parses ({len(active)} active branch(es))"))
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
findings.append(Finding("error", f"branches.json parse failed: {exc}"))
|
||||
|
||||
return evidence, branches
|
||||
|
||||
|
||||
def add_handoff_checks(findings: list[Finding]) -> None:
|
||||
text = read_text(HANDOFF_PATH)
|
||||
stale_branch = re.search(r"Current branch before .*ahead \d+", text)
|
||||
stale_head = re.search(r"Current HEAD before .*\b[0-9a-f]{7,40}\b", text)
|
||||
if stale_branch or stale_head:
|
||||
findings.append(Finding("error", "handoff embeds an exact branch or HEAD snapshot; use git commands instead"))
|
||||
else:
|
||||
findings.append(Finding("ok", "handoff avoids exact branch/HEAD snapshots"))
|
||||
|
||||
required_phrases = [
|
||||
"Do not continue implementation automatically from a rehydrate-only request.",
|
||||
"Do not trust this file for the exact live HEAD.",
|
||||
"Immediate Re-entry Instruction",
|
||||
]
|
||||
for phrase in required_phrases:
|
||||
if phrase in text:
|
||||
findings.append(Finding("ok", f"handoff contains: {phrase}"))
|
||||
else:
|
||||
findings.append(Finding("warn", f"handoff missing: {phrase}"))
|
||||
|
||||
|
||||
def add_agents_checks(findings: list[Finding]) -> None:
|
||||
text = read_text(AGENTS_PATH)
|
||||
if "gamecult_access_prepare_compaction.py" in text:
|
||||
findings.append(Finding("ok", "AGENTS.md tells agents to use the compaction helper"))
|
||||
else:
|
||||
findings.append(Finding("error", "AGENTS.md does not mention tools/gamecult_access_prepare_compaction.py"))
|
||||
if "prepare for imminent compaction" in text.lower():
|
||||
findings.append(Finding("ok", "AGENTS.md names the imminent-compaction trigger"))
|
||||
else:
|
||||
findings.append(Finding("warn", "AGENTS.md does not name the imminent-compaction trigger phrase"))
|
||||
|
||||
|
||||
def add_git_checks(findings: list[Finding]) -> tuple[str, str]:
|
||||
status = run_git(["status", "--short", "--branch"])
|
||||
log = run_git(["log", "--oneline", "-5"])
|
||||
dirty_lines = [line for line in status.splitlines()[1:] if line.strip()]
|
||||
if dirty_lines:
|
||||
findings.append(Finding("warn", "git worktree has uncommitted changes; commit or explain before compaction"))
|
||||
else:
|
||||
findings.append(Finding("ok", "git worktree is clean"))
|
||||
return status, log
|
||||
|
||||
|
||||
def render_report(findings: list[Finding], status: str, log: str, evidence: list[dict[str, Any]]) -> str:
|
||||
summary = extract_map_field("summary") or "(missing)"
|
||||
next_action = extract_map_field("next_action") or "(missing)"
|
||||
subgoals = extract_active_subgoals()
|
||||
|
||||
counts = {
|
||||
"ok": sum(1 for finding in findings if finding.level == "ok"),
|
||||
"warn": sum(1 for finding in findings if finding.level == "warn"),
|
||||
"error": sum(1 for finding in findings if finding.level == "error"),
|
||||
}
|
||||
|
||||
lines = [
|
||||
"GameCult Access pre-compaction persistence check",
|
||||
f"Workspace: {ROOT}",
|
||||
f"Findings: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error",
|
||||
"",
|
||||
"Git status:",
|
||||
status,
|
||||
"",
|
||||
"Recent commits:",
|
||||
log,
|
||||
"",
|
||||
f"Summary: {summary}",
|
||||
f"Next action: {next_action}",
|
||||
]
|
||||
if subgoals:
|
||||
lines.append("Active subgoals:")
|
||||
lines.extend(f"- {item}" for item in subgoals)
|
||||
if evidence:
|
||||
latest = evidence[-1]
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Latest distilled evidence:",
|
||||
f"- {latest.get('ts', '(missing ts)')} {latest.get('type', '(missing type)')}/{latest.get('status', '(missing status)')}: {latest.get('note', '(missing note)')}",
|
||||
]
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Findings:")
|
||||
for finding in findings:
|
||||
lines.append(f"[{finding.level.upper()}] {finding.message}")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Pre-compaction checklist:",
|
||||
"- Update state/map.yaml only if current understanding changed.",
|
||||
"- Refresh notes/fresh-workspace-handoff.md if re-entry instructions changed.",
|
||||
"- Add distilled evidence only for a belief-changing lesson, verification, rejected path, or scar.",
|
||||
"- Keep exact branch/HEAD out of handoff prose; git commands own volatile truth.",
|
||||
"- Commit completed persistence changes, or state why the worktree must stay dirty.",
|
||||
"- Re-run this helper after edits before yielding to compaction.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit GameCult Access persistent state before imminent compaction."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Exit nonzero on warnings as well as errors.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
findings: list[Finding] = []
|
||||
evidence: list[dict[str, Any]] = []
|
||||
|
||||
add_required_file_checks(findings)
|
||||
try:
|
||||
evidence, _branches = add_content_checks(findings)
|
||||
except OSError as exc:
|
||||
findings.append(Finding("error", f"state content check failed: {exc}"))
|
||||
try:
|
||||
add_handoff_checks(findings)
|
||||
except OSError as exc:
|
||||
findings.append(Finding("error", f"handoff check failed: {exc}"))
|
||||
try:
|
||||
add_agents_checks(findings)
|
||||
except OSError as exc:
|
||||
findings.append(Finding("error", f"AGENTS check failed: {exc}"))
|
||||
try:
|
||||
status, log = add_git_checks(findings)
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
findings.append(Finding("error", f"git check failed: {exc}"))
|
||||
status = "(git status unavailable)"
|
||||
log = "(git log unavailable)"
|
||||
|
||||
print(render_report(findings, status, log, evidence))
|
||||
|
||||
has_error = any(finding.level == "error" for finding in findings)
|
||||
has_warning = any(finding.level == "warn" for finding in findings)
|
||||
if has_error or (args.strict and has_warning):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
179
tools/gamecult_access_state.py
Normal file
179
tools/gamecult_access_state.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATE_DIR = ROOT / "state"
|
||||
MAP_PATH = STATE_DIR / "map.yaml"
|
||||
BRANCHES_PATH = STATE_DIR / "branches.json"
|
||||
EVIDENCE_PATH = STATE_DIR / "evidence.jsonl"
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def write_text(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def load_branches() -> dict[str, Any]:
|
||||
return json.loads(read_text(BRANCHES_PATH))
|
||||
|
||||
|
||||
def save_branches(data: dict[str, Any]) -> None:
|
||||
write_text(BRANCHES_PATH, json.dumps(data, indent=2) + "\n")
|
||||
|
||||
|
||||
def append_evidence(record: dict[str, Any]) -> None:
|
||||
with EVIDENCE_PATH.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, ensure_ascii=True) + "\n")
|
||||
|
||||
|
||||
def utc_stamp() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def extract_map_field(name: str) -> str | None:
|
||||
prefix = f" {name}:"
|
||||
for line in read_text(MAP_PATH).splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_active_subgoals() -> list[str]:
|
||||
lines = read_text(MAP_PATH).splitlines()
|
||||
results: list[str] = []
|
||||
in_section = False
|
||||
for line in lines:
|
||||
if not in_section:
|
||||
if line.strip() == "active_subgoals:":
|
||||
in_section = True
|
||||
continue
|
||||
if line.startswith(" - "):
|
||||
results.append(line[4:].strip())
|
||||
continue
|
||||
if line and not line.startswith(" "):
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def cmd_status(_: argparse.Namespace) -> int:
|
||||
branches = load_branches().get("branches", [])
|
||||
active = [branch for branch in branches if branch.get("status") == "active"]
|
||||
summary = extract_map_field("summary") or "(missing)"
|
||||
next_action = extract_map_field("next_action") or "(missing)"
|
||||
subgoals = extract_active_subgoals()
|
||||
|
||||
print(f"Workspace: {ROOT}")
|
||||
print(f"Summary: {summary}")
|
||||
print(f"Next action: {next_action}")
|
||||
print(f"Active branches: {len(active)} / {len(branches)}")
|
||||
if subgoals:
|
||||
print("Active subgoals:")
|
||||
for item in subgoals:
|
||||
print(f"- {item}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add_evidence(args: argparse.Namespace) -> int:
|
||||
record: dict[str, Any] = {
|
||||
"ts": utc_stamp(),
|
||||
"type": args.type,
|
||||
"status": args.status,
|
||||
"note": args.note,
|
||||
}
|
||||
if args.branch:
|
||||
record["branch"] = args.branch
|
||||
append_evidence(record)
|
||||
print("Appended evidence record.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add_branch(args: argparse.Namespace) -> int:
|
||||
data = load_branches()
|
||||
branches = data.setdefault("branches", [])
|
||||
if any(branch.get("id") == args.id for branch in branches):
|
||||
raise SystemExit(f"Branch '{args.id}' already exists.")
|
||||
branches.append(
|
||||
{
|
||||
"id": args.id,
|
||||
"hypothesis": args.hypothesis,
|
||||
"status": "active",
|
||||
"artifacts": args.artifact or [],
|
||||
"notes": args.note or "",
|
||||
}
|
||||
)
|
||||
save_branches(data)
|
||||
print(f"Added branch '{args.id}'.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_close_branch(args: argparse.Namespace) -> int:
|
||||
data = load_branches()
|
||||
for branch in data.get("branches", []):
|
||||
if branch.get("id") != args.id:
|
||||
continue
|
||||
branch["status"] = args.status
|
||||
if args.note:
|
||||
branch["notes"] = args.note
|
||||
save_branches(data)
|
||||
print(f"Updated branch '{args.id}' to status '{args.status}'.")
|
||||
return 0
|
||||
raise SystemExit(f"Branch '{args.id}' was not found.")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect and update GameCult Access persistent state files."
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
status_parser = subparsers.add_parser("status", help="Show a compact state summary.")
|
||||
status_parser.set_defaults(func=cmd_status)
|
||||
|
||||
evidence_parser = subparsers.add_parser(
|
||||
"add-evidence", help="Append one distilled JSONL evidence record."
|
||||
)
|
||||
evidence_parser.add_argument("--type", required=True)
|
||||
evidence_parser.add_argument("--status", required=True)
|
||||
evidence_parser.add_argument("--note", required=True)
|
||||
evidence_parser.add_argument("--branch")
|
||||
evidence_parser.set_defaults(func=cmd_add_evidence)
|
||||
|
||||
add_branch_parser = subparsers.add_parser(
|
||||
"add-branch", help="Create a new active branch entry."
|
||||
)
|
||||
add_branch_parser.add_argument("--id", required=True)
|
||||
add_branch_parser.add_argument("--hypothesis", required=True)
|
||||
add_branch_parser.add_argument("--artifact", action="append")
|
||||
add_branch_parser.add_argument("--note")
|
||||
add_branch_parser.set_defaults(func=cmd_add_branch)
|
||||
|
||||
close_branch_parser = subparsers.add_parser(
|
||||
"close-branch", help="Close an existing branch."
|
||||
)
|
||||
close_branch_parser.add_argument("--id", required=True)
|
||||
close_branch_parser.add_argument(
|
||||
"--status", required=True, choices=["accepted", "rejected", "archived"]
|
||||
)
|
||||
close_branch_parser.add_argument("--note")
|
||||
close_branch_parser.set_defaults(func=cmd_close_branch)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue