Skip to content

feat(web/moderation): Ada-as-tagger + retire off_topic — POLICY_PROMPT_V=2#11

Merged
xiaolai merged 1 commit into
mainfrom
ada-tagging
May 6, 2026
Merged

feat(web/moderation): Ada-as-tagger + retire off_topic — POLICY_PROMPT_V=2#11
xiaolai merged 1 commit into
mainfrom
ada-tagging

Conversation

@xiaolai

@xiaolai xiaolai commented May 6, 2026

Copy link
Copy Markdown
Owner

Summary

Two structural changes that ride together because both bump the
prompt version stamp on policy_decisions.

  1. Ada gains a tagging job. The moderator emits up to 2 topical tags for accepted submissions in the same call. Hybrid vocab: pick from existing tags (is_new=false) or propose new tags with pending_review=true for staff approval at /admin/flags. Migration 0022 adds tags.pending_review and submission_tags.source ('ai'|'user') with a CHECK constraint. Pending tags hide from /c and from public submission rows; staff approve/reject in the new pending-review section. tagSlugSchema in lib/tags/slug.ts is the one canonical slug shape — moderator parsing, admin actions, and the public API now share it.
  2. POLICY_CATEGORIES drops off_topic. The gate now handles universal trust-and-safety only (spam/abuse/illegal/doxxing). Topical fit is the editorial layer's concern (rubric scoring + voting), not the moderator's. Removes a perpetual drift point for autonomous operation and fixes the misread where gpt-4o-mini was rejecting AI-on-topic content as off-topic-for-AI-audience due to an ambiguous parenthetical in the fallback prompt.

POLICY_PROMPT_V bumped 1→2 so policy_decisions.prompt_version splits analytics cleanly across the change. The category column is plain text not enum, so legacy rows with category='off_topic' stay queryable; display surfaces (/office/policy, /appeal/[id]) keep their off_topic labels for historical rendering.

Migration

Migration 0022_ai_tagging.sql adds two columns, a CHECK constraint, and one partial index. Apply manually via psql per .claude/rules/db-migrations.mddrizzle-kit push against prod would drop the search_vec generated column.

ALTER TABLE tags ADD COLUMN IF NOT EXISTS pending_review boolean NOT NULL DEFAULT false;
ALTER TABLE submission_tags ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'user';
-- + guarded ADD CONSTRAINT (DO $$ ... pg_constraint check)
-- + CREATE INDEX IF NOT EXISTS idx_tags_pending_review (pending_review, slug) WHERE pending_review = true

Audit + verify

  • Codex mini audit (5-dim: correctness, security, error-handling, types, perf) returned 7 findings (2 HIGH, 2 MED, 3 LOW). All 7 fixed in this branch and re-verified by Codex (independent fresh-thread call).
  • tsc --noEmit → 0 errors
  • 11/11 unit-test suites pass

Test plan

  • Apply migration 0022 to prod via psql
  • Submit a new post in prod, confirm Ada attaches up to 2 tags
  • Hit /admin/flags, confirm pending-review section renders any new Ada-proposed tags with sample submission titles
  • Approve a pending tag, confirm it appears at /c/<slug> and the moderator vocab cache is cleared (Ada picks it up on next submission)
  • Confirm /office/policy shows 4 categories, lede explicitly disclaims topical-fit gating
  • Confirm policy_decisions.prompt_version rows with new submissions are stamped "2"

…T_V=2

Two structural changes that ride together because both bump the
prompt version stamp on policy_decisions.

1. Ada gains a tagging job. The moderator emits up to 2 topical
   tags for accepted submissions in the same call. Hybrid vocab:
   pick from existing tags (is_new=false) or propose new tags with
   pending_review=true for staff approval at /admin/flags. Migration
   0022 adds tags.pending_review and submission_tags.source
   ('ai'|'user') with a CHECK constraint. Pending tags hide from /c
   and from public submission rows; staff approve/reject in the new
   pending-review section. tagSlugSchema in lib/tags/slug.ts is the
   one canonical slug shape — moderator parsing, admin actions, and
   the public API now share it.

2. POLICY_CATEGORIES drops off_topic. The gate now handles universal
   trust-and-safety only (spam/abuse/illegal/doxxing). Topical fit is
   the editorial layer's concern (rubric scoring + voting), not the
   moderator's. Removes a perpetual drift point for autonomous
   operation and fixes the misread where gpt-4o-mini was rejecting
   AI-on-topic content as off-topic-for-AI-audience due to an
   ambiguous parenthetical in the fallback prompt.

POLICY_PROMPT_V bumped 1→2 so policy_decisions.prompt_version
splits analytics cleanly across the change. The category column is
plain text not enum, so legacy rows with category='off_topic' stay
queryable; display surfaces (/office/policy, /appeal/[id]) keep
their off_topic labels for historical rendering.

Migration 0022 is NOT auto-applied — apply via psql per
.claude/rules/db-migrations.md. drizzle-kit push against prod would
drop the search_vec generated column.
@vercel

vercel Bot commented May 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
claudepot-com Error Error May 6, 2026 11:21pm

Request Review

@xiaolai xiaolai merged commit 3a591bf into main May 6, 2026
7 of 8 checks passed
xiaolai added a commit that referenced this pull request May 8, 2026
HIGH fixes:
- register_from_browser: rollback private blob + cleanup temp dir
  on store.insert() failure (#4)
- Orphan detection: roundtrip check prevents lossy unsanitize_path
  from causing false-positive deletions (#1, #10)
- --from-token reads from stdin when value is "-" to avoid shell
  history exposure (#9)

MEDIUM fixes:
- swap switch(): rollback CC credentials on set_active_cli failure (#11)
- register_account_from_profile: delete orphaned private blob on
  insert failure (#17)
- remove_account: reorder to clear pointers + remove DB row before
  irreversible file deletions (#18)
- desktop switch(): propagate DB update failures instead of ignoring (#22)
- account.rs: propagate DB permission-setting failures (#20)
- project.rs: post-move failures become warnings instead of errors
  when directory already moved (#16)
- account remove --json: emit structured JSON response (#28)
- doctor summary: count expired accounts as warnings (#26)

LOW fixes:
- doctor: surface store.list() failures via db_error field (#29)
- keychain status: distinguish "no credential" from "access error" (#30)
xiaolai added a commit that referenced this pull request May 8, 2026
#15 REGRESSED — metrics transition-only broke active_series
carry-forward:
  * Runtime now writes on transition OR heartbeat (every 60 ticks
    at 500ms cadence = 30s). Bounded volume (~1/30s/session for
    idle-static sessions, more for transitioning ones) but
    preserves per-bucket density so the Trends active_series query
    still sees each live session in every bucket longer than the
    heartbeat interval.
  * SessionState.ticks_since_metrics counter drives the heartbeat.
    Reset to 0 on every write; increments every tick otherwise.
  * New runtime test: metrics_writes_transition_plus_heartbeat
    against a MetricsStore tempdir confirms two distinct writes
    produce two non-zero buckets.

#11 PARTIAL — LiveStatusHeader ignored detail-channel deltas:
  * status_changed / overlay_changed / model_changed now update
    local override state (setLiveStatus / setLiveOverlay /
    setLiveModel / setLiveWaitingFor). Rendered values prefer the
    detail override when present and fall back to the aggregate
    summary.
  * seq-guarded: lastSeqRef keeps late out-of-order deliveries
    from overwriting a newer state.
  * StatusChipRow refactored to accept broken-out props so it can
    consume either source without a synthetic summary object.

#17 PARTIAL — j/k still used a global window listener:
  * Removed the useEffect + window.addEventListener pair.
  * Replaced with a local  handler attached directly to
    the role=listbox div. No global listener at all;  /
    pressed anywhere outside the strip simply don't reach our
    handler. useEffect import dropped.

#20 PARTIAL — lifecycle tests missing:
  * excluded_paths_are_skipped_by_tick — proves the runtime
    filters excluded projects before attach.
  * unsubscribe_releases_detail_slot_for_resubscribe — proves the
    single-subscriber contract, the AlreadySubscribed error, and
    that detail_end_session clears the slot.
  * metrics_writes_transition_plus_heartbeat — proves the post-
    audit write model doesn't double-count and does record both
    transitions.

Full tests: 689 core + 3 E2E + 36 tauri-lib = 728 Rust; 161 React.
tsc --noEmit clean. cargo check --workspace clean.
xiaolai added a commit that referenced this pull request May 8, 2026
Independent Codex audit (5-dim mini, threadId 019de662) flagged
2 High + 8 Medium + 1 Low across the templates feature surface.
Independent verification (threadId 019de792) confirmed 10 fixes
landed; #11 is deferred with rationale.

### High

#1 prerun probe ignored route auth — `probe_sync` in
`automations/prerun.rs` now attaches `Authorization: Bearer
<key>` (or `Basic <key>` when `auth_scheme` is set) from the
route's `GatewayConfig.api_key` when non-empty. LiteLLM /
Cloudflare AI Gateway endpoints behind auth no longer false-
fail with 401.

#2 sidecar swap in `templates_apply_pending` —
`commands_templates.rs` now rejects early when
`pending.automation_id != automation_id`. Without this, an
attacker (or a buggy caller) could pair automation A's pending-
changes.json with automation B's broader apply policy.

### Medium

#3 Ollama probe detection too broad — was `cfg.base_url.contains
("/api/")` which false-positives on `/api/v1` (OpenRouter,
LiteLLM). Now narrowed to `:11434` port OR explicit `/api/tags`
path.

#4 hardcoded `~/.claudepot` — both `templates_pending_changes`
and `templates_read_report` now use `claudepot_data_dir()` from
claudepot-core, honoring `CLAUDEPOT_DATA_DIR` overrides
correctly.

#5 `supported_platforms` was only enforced in `templates_list`
— `templates_install` now ALSO rejects when
`!bp.supports(HostPlatform::current())`. The contract is
symmetric: a direct IPC bypass of the gallery filter no longer
lets a macOS-only template instantiate on Linux/Windows.

#6 Windows ACL trusted `USERNAME` — `tighten_consent_acl` now
resolves the current user SID via `whoami /user /fo csv /nh`,
grants `*<SID>:F` in icacls (well-known SID notation), grants
SYSTEM via the literal `S-1-5-18` SID, and surfaces every
failure path through `tracing::warn` instead of swallowing
silently.

#7 cron test wrote `CLAUDE_OAUTH_TOKEN` to disk via
`automation.extra_env` (which `install_shim` renders into
`run.sh`). Token now lives in a 0600 sibling file
(`<tmp>/.oauth-token`); a 0700 wrapper script
(`<tmp>/claude-with-token.sh`) reads the file at fire time and
exec's the real claude. The shim sees only the wrapper path,
never the token.

#8 cron test scheduled `now + 1 minute`, which races the case
where `install_shim + scheduler.register` span past the next
minute boundary — launchd would defer to the same time
tomorrow and the test would hang. Now schedules `now + 2
minutes`; deadline extended to 180s.

#9 cron test passed on a `"result":` substring match, which is
too permissive — error rows mention `result` too. Now parses
`stdout.log` as a JSON event array, finds the terminal
`result` event, asserts `is_error == false`. Verified locally:
the cron run produced `is_error=false result_first_80=
CRON_TEMPLATE_OK`.

#10 `CronCleanupGuard` did not restore the previous
`CLAUDEPOT_DATA_DIR` — risk of polluting subsequent tests in
the same process. Now captures the prior value as
`Option<OsString>` at construction and restores it on Drop.
Also explicitly zero-fills + unlinks the token file so a
tempdir-removal race doesn't leave the token on disk.

### Low (deferred)

#11 validator rebuilds globset matcher per call. Apply is
interactive (~5-50 items × 1-3 globs each, user reviews + clicks
per run); not a hot loop. Caching would require an
`ApplyConfig`-level matcher cache and is not worth the
complexity. Documented in the audit-fix verification report.

### Verification

Independent Codex re-audit on a fresh thread:
| Status | Count |
|---|---:|
| FIXED | 10 |
| NOT FIXED | 0 |
| PARTIAL | 0 |
| REGRESSED | 0 |
| DEFERRED | 1 |

Local validation:
- `cargo test --workspace` — all green
- `cargo test -p claudepot-core --test templates_real_llm
   cron_schedule_fires -- --ignored` — passes with new
  is_error=false assertion (110s wall, ~$0.30)

Audit threadId: 019de662-808c-7b62-80b3-e85d373f92b5
Verify threadId: 019de792-3515-7533-aac0-0203a96388be
xiaolai added a commit that referenced this pull request May 8, 2026
feat(web/moderation): Ada-as-tagger + retire off_topic — POLICY_PROMPT_V=2
xiaolai added a commit that referenced this pull request May 8, 2026
Independent Codex audit (5-dim mini, threadId 019de662) flagged
2 High + 8 Medium + 1 Low across the templates feature surface.
Independent verification (threadId 019de792) confirmed 10 fixes
landed; #11 is deferred with rationale.

### High

#1 prerun probe ignored route auth — `probe_sync` in
`automations/prerun.rs` now attaches `Authorization: Bearer
<key>` (or `Basic <key>` when `auth_scheme` is set) from the
route's `GatewayConfig.api_key` when non-empty. LiteLLM /
Cloudflare AI Gateway endpoints behind auth no longer false-
fail with 401.

#2 sidecar swap in `templates_apply_pending` —
`commands_templates.rs` now rejects early when
`pending.automation_id != automation_id`. Without this, an
attacker (or a buggy caller) could pair automation A's pending-
changes.json with automation B's broader apply policy.

### Medium

#3 Ollama probe detection too broad — was `cfg.base_url.contains
("/api/")` which false-positives on `/api/v1` (OpenRouter,
LiteLLM). Now narrowed to `:11434` port OR explicit `/api/tags`
path.

#4 hardcoded `~/.claudepot` — both `templates_pending_changes`
and `templates_read_report` now use `claudepot_data_dir()` from
claudepot-core, honoring `CLAUDEPOT_DATA_DIR` overrides
correctly.

#5 `supported_platforms` was only enforced in `templates_list`
— `templates_install` now ALSO rejects when
`!bp.supports(HostPlatform::current())`. The contract is
symmetric: a direct IPC bypass of the gallery filter no longer
lets a macOS-only template instantiate on Linux/Windows.

#6 Windows ACL trusted `USERNAME` — `tighten_consent_acl` now
resolves the current user SID via `whoami /user /fo csv /nh`,
grants `*<SID>:F` in icacls (well-known SID notation), grants
SYSTEM via the literal `S-1-5-18` SID, and surfaces every
failure path through `tracing::warn` instead of swallowing
silently.

#7 cron test wrote `CLAUDE_OAUTH_TOKEN` to disk via
`automation.extra_env` (which `install_shim` renders into
`run.sh`). Token now lives in a 0600 sibling file
(`<tmp>/.oauth-token`); a 0700 wrapper script
(`<tmp>/claude-with-token.sh`) reads the file at fire time and
exec's the real claude. The shim sees only the wrapper path,
never the token.

#8 cron test scheduled `now + 1 minute`, which races the case
where `install_shim + scheduler.register` span past the next
minute boundary — launchd would defer to the same time
tomorrow and the test would hang. Now schedules `now + 2
minutes`; deadline extended to 180s.

#9 cron test passed on a `"result":` substring match, which is
too permissive — error rows mention `result` too. Now parses
`stdout.log` as a JSON event array, finds the terminal
`result` event, asserts `is_error == false`. Verified locally:
the cron run produced `is_error=false result_first_80=
CRON_TEMPLATE_OK`.

#10 `CronCleanupGuard` did not restore the previous
`CLAUDEPOT_DATA_DIR` — risk of polluting subsequent tests in
the same process. Now captures the prior value as
`Option<OsString>` at construction and restores it on Drop.
Also explicitly zero-fills + unlinks the token file so a
tempdir-removal race doesn't leave the token on disk.

### Low (deferred)

#11 validator rebuilds globset matcher per call. Apply is
interactive (~5-50 items × 1-3 globs each, user reviews + clicks
per run); not a hot loop. Caching would require an
`ApplyConfig`-level matcher cache and is not worth the
complexity. Documented in the audit-fix verification report.

### Verification

Independent Codex re-audit on a fresh thread:
| Status | Count |
|---|---:|
| FIXED | 10 |
| NOT FIXED | 0 |
| PARTIAL | 0 |
| REGRESSED | 0 |
| DEFERRED | 1 |

Local validation:
- `cargo test --workspace` — all green
- `cargo test -p claudepot-core --test templates_real_llm
   cron_schedule_fires -- --ignored` — passes with new
  is_error=false assertion (110s wall, ~$0.30)

Audit threadId: 019de662-808c-7b62-80b3-e85d373f92b5
Verify threadId: 019de792-3515-7533-aac0-0203a96388be
xiaolai added a commit that referenced this pull request May 8, 2026
feat(web/moderation): Ada-as-tagger + retire off_topic — POLICY_PROMPT_V=2
xiaolai added a commit that referenced this pull request May 8, 2026
HIGH fixes:
- register_from_browser: rollback private blob + cleanup temp dir
  on store.insert() failure (#4)
- Orphan detection: roundtrip check prevents lossy unsanitize_path
  from causing false-positive deletions (#1, #10)
- --from-token reads from stdin when value is "-" to avoid shell
  history exposure (#9)

MEDIUM fixes:
- swap switch(): rollback CC credentials on set_active_cli failure (#11)
- register_account_from_profile: delete orphaned private blob on
  insert failure (#17)
- remove_account: reorder to clear pointers + remove DB row before
  irreversible file deletions (#18)
- desktop switch(): propagate DB update failures instead of ignoring (#22)
- account.rs: propagate DB permission-setting failures (#20)
- project.rs: post-move failures become warnings instead of errors
  when directory already moved (#16)
- account remove --json: emit structured JSON response (#28)
- doctor summary: count expired accounts as warnings (#26)

LOW fixes:
- doctor: surface store.list() failures via db_error field (#29)
- keychain status: distinguish "no credential" from "access error" (#30)
xiaolai added a commit that referenced this pull request May 8, 2026
#15 REGRESSED — metrics transition-only broke active_series
carry-forward:
  * Runtime now writes on transition OR heartbeat (every 60 ticks
    at 500ms cadence = 30s). Bounded volume (~1/30s/session for
    idle-static sessions, more for transitioning ones) but
    preserves per-bucket density so the Trends active_series query
    still sees each live session in every bucket longer than the
    heartbeat interval.
  * SessionState.ticks_since_metrics counter drives the heartbeat.
    Reset to 0 on every write; increments every tick otherwise.
  * New runtime test: metrics_writes_transition_plus_heartbeat
    against a MetricsStore tempdir confirms two distinct writes
    produce two non-zero buckets.

#11 PARTIAL — LiveStatusHeader ignored detail-channel deltas:
  * status_changed / overlay_changed / model_changed now update
    local override state (setLiveStatus / setLiveOverlay /
    setLiveModel / setLiveWaitingFor). Rendered values prefer the
    detail override when present and fall back to the aggregate
    summary.
  * seq-guarded: lastSeqRef keeps late out-of-order deliveries
    from overwriting a newer state.
  * StatusChipRow refactored to accept broken-out props so it can
    consume either source without a synthetic summary object.

#17 PARTIAL — j/k still used a global window listener:
  * Removed the useEffect + window.addEventListener pair.
  * Replaced with a local  handler attached directly to
    the role=listbox div. No global listener at all;  /
    pressed anywhere outside the strip simply don't reach our
    handler. useEffect import dropped.

#20 PARTIAL — lifecycle tests missing:
  * excluded_paths_are_skipped_by_tick — proves the runtime
    filters excluded projects before attach.
  * unsubscribe_releases_detail_slot_for_resubscribe — proves the
    single-subscriber contract, the AlreadySubscribed error, and
    that detail_end_session clears the slot.
  * metrics_writes_transition_plus_heartbeat — proves the post-
    audit write model doesn't double-count and does record both
    transitions.

Full tests: 689 core + 3 E2E + 36 tauri-lib = 728 Rust; 161 React.
tsc --noEmit clean. cargo check --workspace clean.
xiaolai added a commit that referenced this pull request May 8, 2026
Independent Codex audit (5-dim mini, threadId 019de662) flagged
2 High + 8 Medium + 1 Low across the templates feature surface.
Independent verification (threadId 019de792) confirmed 10 fixes
landed; #11 is deferred with rationale.

### High

#1 prerun probe ignored route auth — `probe_sync` in
`automations/prerun.rs` now attaches `Authorization: Bearer
<key>` (or `Basic <key>` when `auth_scheme` is set) from the
route's `GatewayConfig.api_key` when non-empty. LiteLLM /
Cloudflare AI Gateway endpoints behind auth no longer false-
fail with 401.

#2 sidecar swap in `templates_apply_pending` —
`commands_templates.rs` now rejects early when
`pending.automation_id != automation_id`. Without this, an
attacker (or a buggy caller) could pair automation A's pending-
changes.json with automation B's broader apply policy.

### Medium

#3 Ollama probe detection too broad — was `cfg.base_url.contains
("/api/")` which false-positives on `/api/v1` (OpenRouter,
LiteLLM). Now narrowed to `:11434` port OR explicit `/api/tags`
path.

#4 hardcoded `~/.claudepot` — both `templates_pending_changes`
and `templates_read_report` now use `claudepot_data_dir()` from
claudepot-core, honoring `CLAUDEPOT_DATA_DIR` overrides
correctly.

#5 `supported_platforms` was only enforced in `templates_list`
— `templates_install` now ALSO rejects when
`!bp.supports(HostPlatform::current())`. The contract is
symmetric: a direct IPC bypass of the gallery filter no longer
lets a macOS-only template instantiate on Linux/Windows.

#6 Windows ACL trusted `USERNAME` — `tighten_consent_acl` now
resolves the current user SID via `whoami /user /fo csv /nh`,
grants `*<SID>:F` in icacls (well-known SID notation), grants
SYSTEM via the literal `S-1-5-18` SID, and surfaces every
failure path through `tracing::warn` instead of swallowing
silently.

#7 cron test wrote `CLAUDE_OAUTH_TOKEN` to disk via
`automation.extra_env` (which `install_shim` renders into
`run.sh`). Token now lives in a 0600 sibling file
(`<tmp>/.oauth-token`); a 0700 wrapper script
(`<tmp>/claude-with-token.sh`) reads the file at fire time and
exec's the real claude. The shim sees only the wrapper path,
never the token.

#8 cron test scheduled `now + 1 minute`, which races the case
where `install_shim + scheduler.register` span past the next
minute boundary — launchd would defer to the same time
tomorrow and the test would hang. Now schedules `now + 2
minutes`; deadline extended to 180s.

#9 cron test passed on a `"result":` substring match, which is
too permissive — error rows mention `result` too. Now parses
`stdout.log` as a JSON event array, finds the terminal
`result` event, asserts `is_error == false`. Verified locally:
the cron run produced `is_error=false result_first_80=
CRON_TEMPLATE_OK`.

#10 `CronCleanupGuard` did not restore the previous
`CLAUDEPOT_DATA_DIR` — risk of polluting subsequent tests in
the same process. Now captures the prior value as
`Option<OsString>` at construction and restores it on Drop.
Also explicitly zero-fills + unlinks the token file so a
tempdir-removal race doesn't leave the token on disk.

### Low (deferred)

#11 validator rebuilds globset matcher per call. Apply is
interactive (~5-50 items × 1-3 globs each, user reviews + clicks
per run); not a hot loop. Caching would require an
`ApplyConfig`-level matcher cache and is not worth the
complexity. Documented in the audit-fix verification report.

### Verification

Independent Codex re-audit on a fresh thread:
| Status | Count |
|---|---:|
| FIXED | 10 |
| NOT FIXED | 0 |
| PARTIAL | 0 |
| REGRESSED | 0 |
| DEFERRED | 1 |

Local validation:
- `cargo test --workspace` — all green
- `cargo test -p claudepot-core --test templates_real_llm
   cron_schedule_fires -- --ignored` — passes with new
  is_error=false assertion (110s wall, ~$0.30)

Audit threadId: 019de662-808c-7b62-80b3-e85d373f92b5
Verify threadId: 019de792-3515-7533-aac0-0203a96388be
xiaolai added a commit that referenced this pull request May 8, 2026
feat(web/moderation): Ada-as-tagger + retire off_topic — POLICY_PROMPT_V=2
xiaolai added a commit that referenced this pull request May 18, 2026
Eight findings collapsed into one refactor cycle plus two small
cleanups. The grill report (in conversation; not on disk) flagged
0 critical / 0 high / 3 medium / 8 low — this addresses every
actionable item.

Core reshape (#1 + #6 + #7):
  - detect_pr returns DetectOutcome { branch, pr } so the
    orchestrator keys the cache directly. Halves the per-tick git
    invocation count (no more standalone current_branch call).
  - PrCache keyed by repo_root only; insert() overwrites
    unconditionally so a branch flip is absorbed by the next tick
    without explicit detection. Drops get_any_for (was O(n)),
    drops the dead Entry.branch field, drops refresh_one's
    early-return guard.

Orchestrator parallelism + gh latching (#2 + #9):
  - tick_all fans out via tokio Semaphore + JoinSet with
    MAX_PARALLEL=4 cap on simultaneous gh invocations. 30-project
    tick now completes in ~8 batches instead of 30 sequential
    calls.
  - First MissingCli("gh") flips an AtomicBool that
    short-circuits every subsequent tick. gh-less users pay one
    detect attempt per process lifetime, then nothing.

Test coverage (#8):
  - New PrDetector trait + RealDetector wrapper so the orchestrator
    is testable against a FakeDetector. Four new tokio tests cover
    cache round-trip, negative caching, gh-absent latch, and the
    bounded-parallel fan-out (12 roots vs MAX_PARALLEL=4).
  - DTO tests: PrInfoDto serializes PrState lowercase;
    ProjectInfoDto omits the pr field when None.
  - PrCache test for explicit overwrite semantics.

UI hygiene (#3, #4, #5, #11):
  - Extract liveDotTitle to src/components/primitives so
    ProjectDetail and ProjectsTable share one implementation.
  - LiveStatusDot.title is now required in the prop type — the
    "mandatory by comment" contract was decay-prone.
  - BrandGithubMark license comment now correctly says the
    silhouette is a GitHub Inc. trademark used under the
    design.md brand-mark exception (was claiming CC-BY 4.0 on
    GitHub's mark, which doesn't apply).
  - cwdMatchesProject now normalizes both inputs to forward slash
    before prefix-checking, so a mixed-separator Windows project
    path matches its live cwd. Three new regression tests cover
    the cases.

Tests: 81 + 613 green. Clippy --all-targets clean. Fmt clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant