mirror of
https://github.com/plebbit/seedit.git
synced 2026-07-31 18:06:23 -04:00
* chore(deps): upgrade bitsocial-react-hooks to 0.1.26 and core deps
- @bitsocial/bitsocial-react-hooks 0.1.2 -> 0.1.26
- add @pkcprotocol/pkc-js 0.0.62 and @bitsocial/bso-resolver 0.0.8 (electron RPC)
- react/react-dom 19.1.2 -> 19.2.7, @types/react 19.2.16, @types/react-dom 19.2.3
- vite 8.0.5 -> 8.0.16 (dep + resolution)
- node-fetch 2 -> 3.3.2, drop @types/node-fetch (v3 ships its own types)
- @types/node 20.8.2 -> 20.19.37, fs-extra 11.2.0 -> 11.3.0
- add @electron/rebuild 3.7.2 + electron:rebuild-native script (better-sqlite3
now ships via pkc-js)
* refactor(electron): port RPC from plebbit-js to pkc-js
- replace start-plebbit-rpc.js with start-pkc-rpc.js + start-pkc-rpc-core.js
(5chan template, same 9138 port and auth-key handling)
- add pkc-rpc-options.js with BSO name resolvers via @bitsocial/bso-resolver
- add pkc-paths.js: data dir moves from EnvPaths('plebbit') / .plebbit to
EnvPaths('pkc') / .pkc, with a one-time rename of the legacy plebbit data
dir so existing users keep their communities and accounts
- main.js, start-ipfs.js, log.js now resolve data/log paths through pkc-paths
- preload.mjs exposes window.defaultPkcOptions (pkcRpcClientsOptions) and
window.pkcRpcAuthKey via pkc-named IPC channels
* chore: rename local data dir references from .plebbit to .pkc
- electron:before:delete-data clears both .pkc and legacy .plebbit
- android icons temp dir renamed to /tmp/seedit-android-icons
- forge.config.js excludes both .pkc and .plebbit from packages
- knip.jsonc drops @plebbit/plebbit-js entries now that the electron RPC
imports @pkcprotocol/pkc-js directly
* fix: align renderer with pkc-named account options from hooks 0.1.26
- window.defaultPlebbitOptions -> window.defaultPkcOptions (p2p. subdomain)
- account.plebbitOptions/plebbitRpcClientsOptions -> pkcOptions/
pkcRpcClientsOptions in settings views and account import utils
- account.plebbit -> account.pkc when stripping the client instance from
account export/editor JSON
- rpc settings data path now read from pkcRpcSettings.pkcOptions.dataPath
- account import keeps a shim that upgrades legacy plebbitOptions exports
- drop unused catch binding in start-pkc-rpc.js (oxlint)
* chore(ai): move Claude Code hooks to .claude/settings.json entry point
Adopt the reworked hook entry points: Claude Code hooks now live in
.claude/settings.json (settings-format with $CLAUDE_PROJECT_DIR paths),
the obsolete .claude/hooks.json is removed, and .cursor/hooks.json is
updated to the compact format. Hook scripts are unchanged.
* chore(ai): sync agent descriptors with 5chan rework, adapted for seedit
Port the reworked agent descriptors across .claude, .cursor, and .codex:
tools: frontmatter for read-only agents, browser-session guidance,
untrusted page-content rules, machine-path removal, and yarn doctor/knip
verification guidance.
seedit adaptations: correct the Android appId to seedit.android (the old
copies wrongly said fivechan.android), use a seedit-test-api35 AVD name,
rewrite test-apk around the direct catbox.moe FileUploaderPlugin (seedit
has no WebView upload automation or instrumentation test suite), replace
5chan board/catalog route examples with /s/<community-address> routes,
and point react-scan wiring at vite.config.js instead of the 5chan-only
src/lib/react-scan.ts.
* fix(agent hooks): port harness payload parsing and stop-gate fixes from 5chan
The shared hook implementations now parse both Cursor ({file_path}) and
Claude/Codex ({tool_input: {file_path}}) stdin shapes, normalize absolute
paths to repo-relative, emit hookSpecificOutput additionalContext on
PostToolUse so reminders reach the model, honor stop_hook_active to avoid
stop loops, skip verification on a clean tree, and exit 2 (blocking) on
verify failure. sync-git-branches keeps bitsocialnet/seedit and gains the
unmerged-PR output check.
* docs(ai): merge 5chan AGENTS.md and playbook improvements, adapted for seedit
AGENTS.md: document the harness-specific hook entry points (.claude/
settings.json, .cursor/hooks.json, .codex/hooks.json), add the
ai-workflow:check router row and command, browser-session confirmation
rules for playwright verification, the translate-skill router row, and
reworked AI Tooling Rules ordering. seedit keeps its own project
overview, changelog-based maintenance rules, and stays without
DESIGN.md/blotter/low-spec/android-usb content.
Playbooks: rewrite hooks-setup.md around the new entry points and
shared script behavior, add the committed skills/subagents index to
skills-and-tools.md, and note the canonical commit/issue-format skills
in commit-issue-format.md (without 5chan's required-scope rule, which
seedit does not use).
* chore(ai): add toolchain drift validator from 5chan
Add scripts/validate-ai-workflow.mjs and the ai-workflow:check script.
Fix the one drift it found: seedit's .claude and .cursor copies of the
profile-browsing skill disagreed on the dev-server probe URL and the
Portless note.
* chore(ai skills): port generic 5chan skill fixes and correct seedit misadaptations
Port generic improvements: stale main->master branch names in deslop and
you-might-not-need-an-effect, invalid rg --type tsx flag in
fix-merge-conflicts, the reworked verified/update-in-place readme skill,
the Chesterton's Fence rule in refactor-pass, the independent-subagent-
verification rule in review-and-merge-pr, harness-neutral wording in
inspect-elements/implement-plan/translate.
Correct seedit misadaptations left over from the original copy: test-apk
now uses appId seedit.android, a seedit AVD, and the direct catbox.moe
FileUploaderPlugin flow instead of 5chan's WebView upload automation and
instrumentation suite; profile-browsing drops 5chan board-code routes for
/s/<community-address>.
Kept seedit-flavored: commit-format (no required scope), release and
release-description (no blotter), doctor-vs-repo-standard verification
wording in implement-plan and review-and-merge-pr.
* docs(llms): regenerate llms indexes after AGENTS.md and playbook updates
* feat(directories): add directory list utils, vendored data and sync script
Port 5chan's directory machinery adapted to seedit terminology: per-code
candidate community lists, ranking/winner selection, directory codes as
subscription entries, legacy default migration transform, and a vendored
mirror of bitsocialnet/lists/seedit-directories with a sync:directories
script.
* feat(directories): resolve subscriptions and defaults from directory lists
Add hooks that fetch per-code candidate lists from the lists repo (1h
localStorage cache, in-flight dedup, vendored fallback), pick the
highest-ranked online winner with offline rotation, expand
account.subscriptions directory codes at read time, and derive the
default communities for /s/all from the union of all directory winners.
Rework the one-time auto-subscribe into the directory migration: drop
the dead legacy default communities and subscribe the 10 directory
codes under a new per-account localStorage guard.
* feat(directories): directory routes, feeds and subscribe UI
/s/<code> resolves like a community page via the directory winner while
/s/<address> keeps working; the home feed expands directory-code
subscriptions into winner addresses without flashing the empty state;
the subscribe button targets the code on directory routes with a
'subscribe to this community only' pin action; subscription lists render
directory entries as s/<code> with a (directory) marker and the resolved
community as subtitle.
* test(directories): cover directory codes, winner ranking and migration
Vitest coverage for isDirectoryCode (reserved words, dots, unknown
codes), candidate ranking and offline rotation, subscription expansion
dedupe, list normalization with defaults, and the legacy migration
transform including idempotence.
* style(directories): apply oxfmt formatting
* chore(directories): vendor real community addresses for the 10 default directories
* chore(profiling): port 5chan dev-only react-scan setup for report API and __PROFILING__ flag
Replace the @react-scan/vite-plugin-react-scan wiring with 5chan's manual
dev-only src/lib/react-scan.ts, imported in the entry file. It exposes
window.__getReactScanReport() and window.__ELEMENT_SOURCE__ (element-source
helpers the inspect-elements skill already referenced) and honors
window.__PROFILING__ to hide the toolbar during automated profiling.
Update profile-browsing SKILL.md and profiler agent copies to match.
* chore(directories): vendor .bso names for default directory communities
* fix(subscriptions): retry directory migration in-session after transient setAccount failure
* fix(electron): migrate legacy project-root .plebbit dev data dir to .pkc
* fix(directories): seed useDirectoryLists synchronously from cache/vendored data
* test(directories): guard directory codes against defaults drift
* chore(scripts): reject path-traversal file names in sync-directories
* fix(profiling): handle react-scan chunk-load failure and dedupe not-ready stubs
* style(directories): replace deprecated word-wrap with overflow-wrap
* docs(agents): align test-apk logcat reference, broaden conflict-marker scan, dedupe committed skills guidance
* chore(directories): vendor pics-posting.bso name
* fix(review): address PR feedback
* fix(review): address migration race feedback
292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* validate-ai-workflow.mjs
|
|
*
|
|
* Checks that the repo-managed AI toolchain directories (.claude, .codex,
|
|
* .cursor) stay aligned, per the AI Tooling Rules in AGENTS.md:
|
|
*
|
|
* - same set of skills, agents, hook scripts, and skill support files in
|
|
* every toolchain
|
|
* - mirrored files are identical after normalizing toolchain-specific
|
|
* tokens (.claude/.codex/.cursor path prefixes, agent model lines)
|
|
* - hook entry points (harness-specific formats: .claude/settings.json,
|
|
* .cursor/hooks.json, .codex/hooks.json) wire the same hook scripts
|
|
* - SKILL.md frontmatter has a name matching its directory and a
|
|
* non-empty description
|
|
* - agent model rules: no composer-* models in .claude agents, no
|
|
* gpt-5.3-codex* models in .codex agents
|
|
*
|
|
* Exemptions live HERE in validator-owned allowlists, not in the exempted
|
|
* files, so a drifted copy cannot silently exempt itself. Every entry needs
|
|
* a documented reason.
|
|
*
|
|
* Exit codes: 0 = aligned, 1 = one or more errors.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const TOOLCHAINS = ['.claude', '.codex', '.cursor'];
|
|
|
|
// Skill files whose .codex copy intentionally diverges because Codex uses a
|
|
// different subagent delegation syntax than the Claude/Cursor Task tool.
|
|
// The .claude and .cursor copies must still match each other.
|
|
const CODEX_BODY_EXEMPT = new Map([
|
|
['skills/implement-plan/SKILL.md', 'Codex delegation-tool invocation syntax'],
|
|
['skills/profile-browsing/SKILL.md', 'Codex delegation-tool invocation syntax and .toml agent reference'],
|
|
['skills/test-apk/SKILL.md', 'Codex delegation-tool invocation syntax'],
|
|
['skills/translate/SKILL.md', 'Codex delegation-tool invocation syntax'],
|
|
]);
|
|
|
|
// Hook scripts that intentionally exist in a single toolchain.
|
|
const SINGLE_TOOLCHAIN_HOOKS = new Map([
|
|
[
|
|
'.claude/hooks/session-start.sh',
|
|
'Claude-only: wired via .claude/settings.json SessionStart; Codex/Cursor have no configured session-start entry point',
|
|
],
|
|
]);
|
|
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
const rel = (p) => path.relative(repoRoot, p);
|
|
const read = (p) => fs.readFileSync(p, 'utf8');
|
|
const exists = (p) => fs.existsSync(p);
|
|
|
|
// Replace toolchain-specific tokens so mirrored copies compare equal.
|
|
function normalize(content) {
|
|
let out = content;
|
|
for (const tc of TOOLCHAINS) out = out.replaceAll(tc, '.<toolchain>');
|
|
return out;
|
|
}
|
|
|
|
// Agents additionally differ by harness-specific model frontmatter.
|
|
function normalizeAgent(content) {
|
|
return normalize(content).replace(/^model:.*$/m, 'model: <toolchain-specific>');
|
|
}
|
|
|
|
function listFilesRecursive(dir) {
|
|
if (!exists(dir)) return [];
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
|
|
if (entry.isFile()) {
|
|
out.push(path.relative(dir, path.join(entry.parentPath, entry.name)));
|
|
}
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
function listDirs(dir) {
|
|
if (!exists(dir)) return [];
|
|
return fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
.sort();
|
|
}
|
|
|
|
function parseFrontmatter(content) {
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!match) return null;
|
|
const fm = {};
|
|
for (const line of match[1].split(/\r?\n/)) {
|
|
const kv = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/);
|
|
if (kv) fm[kv[1]] = kv[2].trim();
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
// ─── Skills ──────────────────────────────────────────────────────────────────
|
|
|
|
const skillSets = new Map(TOOLCHAINS.map((tc) => [tc, listDirs(path.join(repoRoot, tc, 'skills'))]));
|
|
const allSkills = [...new Set([...skillSets.values()].flat())].sort();
|
|
|
|
for (const skill of allSkills) {
|
|
for (const tc of TOOLCHAINS) {
|
|
if (!skillSets.get(tc).includes(skill)) {
|
|
errors.push(`missing skill: ${tc}/skills/${skill} (present in other toolchains)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mirroredFileCount = 0;
|
|
for (const skill of allSkills) {
|
|
const presentIn = TOOLCHAINS.filter((tc) => skillSets.get(tc).includes(skill));
|
|
|
|
// File-set parity inside the skill directory.
|
|
const fileSets = new Map(
|
|
presentIn.map((tc) => [tc, listFilesRecursive(path.join(repoRoot, tc, 'skills', skill))]),
|
|
);
|
|
const allFiles = [...new Set([...fileSets.values()].flat())].sort();
|
|
|
|
for (const file of allFiles) {
|
|
const skillRel = `skills/${skill}/${file}`;
|
|
const holders = presentIn.filter((tc) => fileSets.get(tc).includes(file));
|
|
for (const tc of presentIn) {
|
|
if (!holders.includes(tc)) {
|
|
errors.push(`missing file: ${tc}/${skillRel} (present in ${holders.join(', ')})`);
|
|
}
|
|
}
|
|
if (holders.length < 2) continue;
|
|
|
|
// Content parity, normalized. Codex copies of exempt files may diverge.
|
|
mirroredFileCount += 1;
|
|
const contents = new Map(
|
|
holders.map((tc) => [tc, normalize(read(path.join(repoRoot, tc, 'skills', skill, file)))]),
|
|
);
|
|
const reference = holders.find((tc) => tc !== '.codex') ?? holders[0];
|
|
for (const tc of holders) {
|
|
if (tc === reference) continue;
|
|
if (contents.get(tc) === contents.get(reference)) continue;
|
|
if (tc === '.codex' && CODEX_BODY_EXEMPT.has(skillRel)) continue;
|
|
errors.push(`content drift: ${tc}/${skillRel} differs from ${reference}/${skillRel}`);
|
|
}
|
|
}
|
|
|
|
// Frontmatter sanity per toolchain copy of SKILL.md.
|
|
for (const tc of presentIn) {
|
|
const skillMd = path.join(repoRoot, tc, 'skills', skill, 'SKILL.md');
|
|
if (!exists(skillMd)) {
|
|
errors.push(`missing file: ${tc}/skills/${skill}/SKILL.md`);
|
|
continue;
|
|
}
|
|
const fm = parseFrontmatter(read(skillMd));
|
|
if (!fm) {
|
|
errors.push(`no frontmatter: ${tc}/skills/${skill}/SKILL.md`);
|
|
continue;
|
|
}
|
|
if (fm.name !== skill) {
|
|
errors.push(`frontmatter name "${fm.name}" does not match directory: ${tc}/skills/${skill}/SKILL.md`);
|
|
}
|
|
if (!fm.description) {
|
|
errors.push(`missing frontmatter description: ${tc}/skills/${skill}/SKILL.md`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Agents ──────────────────────────────────────────────────────────────────
|
|
|
|
const agentExt = { '.claude': '.md', '.codex': '.toml', '.cursor': '.md' };
|
|
const agentSets = new Map(
|
|
TOOLCHAINS.map((tc) => [
|
|
tc,
|
|
listFilesRecursive(path.join(repoRoot, tc, 'agents'))
|
|
.filter((f) => f.endsWith(agentExt[tc]))
|
|
.map((f) => f.slice(0, -agentExt[tc].length))
|
|
.sort(),
|
|
]),
|
|
);
|
|
const allAgents = [...new Set([...agentSets.values()].flat())].sort();
|
|
|
|
for (const agent of allAgents) {
|
|
for (const tc of TOOLCHAINS) {
|
|
if (!agentSets.get(tc).includes(agent)) {
|
|
errors.push(`missing agent: ${tc}/agents/${agent}${agentExt[tc]} (present in other toolchains)`);
|
|
}
|
|
}
|
|
|
|
// .claude and .cursor agent bodies must match aside from the model line.
|
|
const claudePath = path.join(repoRoot, '.claude', 'agents', `${agent}.md`);
|
|
const cursorPath = path.join(repoRoot, '.cursor', 'agents', `${agent}.md`);
|
|
if (exists(claudePath) && exists(cursorPath)) {
|
|
if (normalizeAgent(read(claudePath)) !== normalizeAgent(read(cursorPath))) {
|
|
errors.push(`content drift: .cursor/agents/${agent}.md differs from .claude/agents/${agent}.md beyond the model line`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Model rules from AGENTS.md.
|
|
for (const agent of agentSets.get('.claude')) {
|
|
const fm = parseFrontmatter(read(path.join(repoRoot, '.claude', 'agents', `${agent}.md`)));
|
|
if (fm?.model?.startsWith('composer')) {
|
|
errors.push(`banned model "${fm.model}" (Cursor-only) in .claude/agents/${agent}.md`);
|
|
}
|
|
}
|
|
for (const agent of agentSets.get('.codex')) {
|
|
const toml = read(path.join(repoRoot, '.codex', 'agents', `${agent}.toml`));
|
|
const model = toml.match(/^model\s*=\s*"([^"]*)"/m)?.[1];
|
|
if (model === 'gpt-5.3-codex' || model === 'gpt-5.3-codex-spark') {
|
|
errors.push(`banned model "${model}" in .codex/agents/${agent}.toml (standardize on gpt-5.4)`);
|
|
}
|
|
}
|
|
|
|
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
|
|
|
const hookSets = new Map(TOOLCHAINS.map((tc) => [tc, listFilesRecursive(path.join(repoRoot, tc, 'hooks'))]));
|
|
const allHooks = [...new Set([...hookSets.values()].flat())].sort();
|
|
|
|
for (const hook of allHooks) {
|
|
const holders = TOOLCHAINS.filter((tc) => hookSets.get(tc).includes(hook));
|
|
for (const tc of TOOLCHAINS) {
|
|
if (holders.includes(tc)) continue;
|
|
const onlyCopy = holders.length === 1 ? `${holders[0]}/hooks/${hook}` : null;
|
|
if (onlyCopy && SINGLE_TOOLCHAIN_HOOKS.has(onlyCopy)) continue;
|
|
errors.push(`missing hook: ${tc}/hooks/${hook} (present in ${holders.join(', ')})`);
|
|
}
|
|
if (holders.length < 2) continue;
|
|
const reference = holders[0];
|
|
for (const tc of holders.slice(1)) {
|
|
const a = normalize(read(path.join(repoRoot, reference, 'hooks', hook)));
|
|
const b = normalize(read(path.join(repoRoot, tc, 'hooks', hook)));
|
|
if (a !== b) {
|
|
errors.push(`content drift: ${tc}/hooks/${hook} differs from ${reference}/hooks/${hook}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hook entry points are harness-specific formats and are NOT byte-mirrored:
|
|
// .claude/settings.json — Claude Code only reads hooks from settings files,
|
|
// never from a standalone hooks.json
|
|
// .cursor/hooks.json — Cursor schema ({"version": 1, "hooks": {...}} with
|
|
// afterFileEdit/stop event names)
|
|
// .codex/hooks.json — Codex schema (intentionally Claude-compatible:
|
|
// PostToolUse/Stop, matcher, type "command")
|
|
// Instead of mirroring content, require every entry point to wire the same set
|
|
// of hooks/<name>.sh scripts (modulo SINGLE_TOOLCHAIN_HOOKS exemptions).
|
|
{
|
|
const entryPoints = new Map([
|
|
['.claude', 'settings.json'],
|
|
['.codex', 'hooks.json'],
|
|
['.cursor', 'hooks.json'],
|
|
]);
|
|
const wired = new Map();
|
|
for (const [tc, file] of entryPoints) {
|
|
const p = path.join(repoRoot, tc, file);
|
|
if (!exists(p)) {
|
|
errors.push(`missing hook entry point: ${tc}/${file}`);
|
|
continue;
|
|
}
|
|
const names = [...read(p).matchAll(/hooks\/([A-Za-z0-9._-]+\.sh)/g)].map((m) => m[1]);
|
|
wired.set(tc, new Set(names));
|
|
}
|
|
const allWired = [...new Set([...wired.values()].flatMap((s) => [...s]))].sort();
|
|
for (const hook of allWired) {
|
|
const holders = [...wired].filter(([, names]) => names.has(hook)).map(([tc]) => tc);
|
|
for (const tc of wired.keys()) {
|
|
if (holders.includes(tc)) continue;
|
|
const onlyCopy = holders.length === 1 ? `${holders[0]}/hooks/${hook}` : null;
|
|
if (onlyCopy && SINGLE_TOOLCHAIN_HOOKS.has(onlyCopy)) continue;
|
|
errors.push(
|
|
`hook not wired: ${tc}/${entryPoints.get(tc)} does not reference hooks/${hook} (wired in ${holders.join(', ')})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Report ──────────────────────────────────────────────────────────────────
|
|
|
|
console.log(
|
|
`validate-ai-workflow: checked ${allSkills.length} skills (${mirroredFileCount} mirrored files), ` +
|
|
`${allAgents.length} agents, ${allHooks.length} hook scripts across ${TOOLCHAINS.join(', ')}`,
|
|
);
|
|
|
|
for (const warning of warnings) console.warn(`warning: ${warning}`);
|
|
if (errors.length > 0) {
|
|
for (const error of errors) console.error(`error: ${error}`);
|
|
console.error(`\n${errors.length} error(s). Align the toolchain copies or add a documented exemption in scripts/validate-ai-workflow.mjs.`);
|
|
process.exit(1);
|
|
}
|
|
console.log('OK: .claude, .codex, and .cursor are aligned.');
|