Compare commits

...

6 Commits

Author SHA1 Message Date
bb2e89c83f chore: bump version to v0.1.1
All checks were successful
port-to-omp / port (push) Successful in 5s
publish / publish (push) Successful in 8s
2026-08-12 22:03:04 -04:00
63c2f73a5e better recon info in certain (nested) situations
All checks were successful
port-to-omp / port (push) Successful in 6s
2026-08-12 21:59:49 -04:00
3fd386a637 port: emit publish workflow into omp repos (independent npm releases)
All checks were successful
port-to-omp / port (push) Successful in 4s
2026-08-12 19:37:07 -04:00
c9d384a4fa add scripts/release-tag.sh (bump package.json, tag, push; triggers port + npm publish CI)
All checks were successful
port-to-omp / port (push) Successful in 4s
2026-08-12 19:31:39 -04:00
dedcf994df add npm publish workflow (v* tags): publishes pi + omp packages via NPM_TOKEN
All checks were successful
port-to-omp / port (push) Successful in 4s
2026-08-12 19:27:11 -04:00
4e56b46dc9 fix: missing sawMessage init in accumulator literal; add pre-commit port typecheck hook
All checks were successful
port-to-omp / port (push) Successful in 5s
The CI port job's tsc --noEmit caught SessionEventAccumulator literals not
initializing the new sawMessage field (tests don't typecheck, so bun test
was green). Add a committed .githooks/pre-commit that mirrors the CI port
job — regenerate the port into a temp dir and tsc --noEmit it — so this
class of error fails at commit time, not in CI.
2026-08-11 12:26:00 -04:00
14 changed files with 403 additions and 45 deletions

View File

@@ -0,0 +1,51 @@
name: publish
# Publish this package and its omp port to the npm registry on version tags.
#
# Prerequisites on npmjs.com / git.freno.me:
# - an npm automation token (publish scope) stored as the repo secret
# NPM_TOKEN (consumed via actions/setup-node registry auth)
# - the scoped package names claimed on npm (@mikefreno/<name> and
# @mikefreno/omp-<name>)
#
# Both packages ship TypeScript sources — nothing is built — so lifecycle
# scripts (prepublishOnly tsc) are skipped: the port workflow already
# typechecks the generated port against the pinned @oh-my-pi SDK, and the pi
# package typechecks against the global pi SDK that CI does not install.
#
# Manual publish: Actions tab → Run workflow (workflow_dispatch).
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install bun
uses: oven-sh/setup-bun@v2
- name: Setup node (npm registry auth)
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Publish pi package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --ignore-scripts
- name: Port to omp
run: bun port-to-omp.mjs --out "$RUNNER_TEMP/omp-port"
- name: Publish omp package
working-directory: ${{ runner.temp }}/omp-port
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --ignore-scripts

37
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/sh
# pre-commit — typecheck the generated omp port before committing.
#
# Mirrors the CI port job (.gitea/workflows/port-to-omp.yml). The source
# repo's own tsconfig extends the host harness tsconfig and is not
# self-contained, so the reliable typecheck target is the regenerated port:
# it ships a self-contained tsconfig and the pinned @oh-my-pi SDK as a real
# devDependency. Regenerating into a temp dir and running `tsc --noEmit`
# there catches exactly what CI will fail on (e.g. an interface field added
# without updating its object literals).
#
# Enable (per clone): git config core.hooksPath .githooks
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
# Fast path: no TypeScript-adjacent change staged -> nothing to typecheck.
if git diff --cached --quiet -- src/ agents/ package.json tsconfig.json port-to-omp.mjs; then
exit 0
fi
TMP="$(mktemp -d)"
LOG="$(mktemp)"
trap 'rm -rf "$TMP" "$LOG"' EXIT
if ! bun port-to-omp.mjs --out "$TMP" >"$LOG" 2>&1; then
echo "pre-commit: port regeneration failed (CI would fail too) — output:" >&2
tail -20 "$LOG" >&2
exit 1
fi
if ! (cd "$TMP" && bun run typecheck) >"$LOG" 2>&1; then
echo "pre-commit: port typecheck failed (this is what CI runs) — output:" >&2
tail -30 "$LOG" >&2
exit 1
fi

View File

@@ -202,6 +202,21 @@ pygienium/
└─ agents/ ← scanner.md fixer.md deep-modules.md defensive-guards.md
```
## Development
The omp port at `~/.omp/agent/extensions/pygienium` is regenerated **only by CI
on push** (`.gitea/workflows/port-to-omp.yml`) — never by hand.
The source repo's own `tsconfig.json` extends the host harness tsconfig, so the
reliable typecheck target is the regenerated port (self-contained tsconfig +
pinned `@oh-my-pi` SDK devDependency). A committed pre-commit hook runs exactly
what CI runs — regenerate the port into a temp dir and `tsc --noEmit` it — and
fails the commit on any error:
```sh
git config core.hooksPath .githooks
```
## License
MIT

View File

@@ -1,6 +1,6 @@
{
"name": "pygienium",
"version": "0.1.0",
"name": "@mikefreno/pygienium",
"version": "0.1.1",
"description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
"keywords": [
"pi-package",
@@ -51,5 +51,11 @@
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
}
},
"files": [
"src/",
"agents/",
"README.md",
"LICENSE"
]
}

View File

@@ -35,6 +35,7 @@ const OMP = join(HOME, ".omp", "agent", "extensions");
const SKIP = new Set([
"port-to-omp.mjs",
"release-tag.sh",
".gitea",
".github",
"node_modules",
@@ -217,6 +218,11 @@ omp install @mikefreno/omp-pygienium
This is the omp port of [Mike/pygienium](https://git.freno.me/Mike/pygienium), regenerated automatically from the source repo. See the source repo for full documentation.
`;
// publish workflow emitted into the port repo so the omp package can be
// released independently (tag push or manual dispatch on the omp repo).
const PORT_PUBLISH_WORKFLOW = "name: publish\n\n# Publish this omp port package to the npm registry on version tags.\n#\n# Prerequisites:\n# - npm automation token (publish scope) stored as the repo secret NPM_TOKEN\n# - package name claimed on npm (@mikefreno/omp-<name>)\n#\n# Manual publish: Actions tab → Run workflow (workflow_dispatch).\n\non:\n push:\n tags: ['v*']\n workflow_dispatch:\n\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup node (npm registry auth)\n uses: actions/setup-node@v4\n with:\n node-version: '22'\n registry-url: 'https://registry.npmjs.org'\n\n - name: Publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n run: npm publish --access public --ignore-scripts\n";
const FILE_RULES = {
"src/index.ts": [
{ from: " * Read the pygienium chat style from pi's settings.json.", to: " * Read the pygienium chat style from omp's settings.json." },
@@ -321,6 +327,10 @@ function portExtension() {
}
}
writeFileSync(join(dstDir, "tsconfig.json"), PYGENIUM_TSCONFIG);
const wfDir = join(dstDir, ".gitea", "workflows");
mkdirSync(wfDir, { recursive: true });
writeFileSync(join(wfDir, "publish.yml"), PORT_PUBLISH_WORKFLOW);
console.log("== bun install (regenerates bun.lock + node_modules)");
execSync("bun install", { cwd: dstDir, stdio: "inherit" });
console.log("== done");

196
scripts/release-tag.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/bin/bash
# release-tag.sh — version bump, commit, tag, and push for this pi extension.
#
# Mirrors the release flow from PodTui's scripts/release-tag.sh, adapted for
# the pi extension repos: the version lives in package.json, and the omp port
# derives its version from it during regeneration.
#
# Usage:
# scripts/release-tag.sh interactive release
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
#
# After this script runs:
# - pushing master triggers the port-to-omp workflow, which regenerates and
# pushes the omp port (picking up the new version)
# - pushing the v* tag triggers the publish workflow, which npm-publishes
# this package and its omp port together
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
-h | --help)
echo "usage: $0 [--dry-run]"
exit 0
;;
esac
done
if [ ! -d .git ] && [ ! -f .git ]; then
echo -e "${RED}✗ not a git repository: $PROJECT_ROOT${NC}"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo -e "${RED}✗ working tree is dirty — commit or stash before releasing${NC}"
git status --short
exit 1
fi
NAME=$(python3 -c "import json;print(json.load(open('package.json'))['name'])")
OMP_NAME=$(python3 -c "import json,re;n=json.load(open('package.json'))['name'];print(re.sub(r'^@mikefreno/(.+)$', r'@mikefreno/omp-\1', n))")
CURRENT_VERSION=$(python3 -c "import json;print(json.load(open('package.json'))['version'])")
echo -e "${CYAN}Package:${NC} ${GREEN}${NAME}${NC}"
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
echo ""
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
echo -e "${CYAN}Select version bump type:${NC}"
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH}$((MAJOR + 1)).0.0"
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.$((MINOR + 1)).0"
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.${MINOR}.$((PATCH + 1))"
echo " 4) Custom version"
echo " 5) Cancel"
echo ""
read -p "Enter choice (1-5): " -n 1 -r CHOICE
echo ""
echo ""
case $CHOICE in
1) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
2) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
4)
read -p "Enter new version (e.g. 0.6.1): " -r NEW_VERSION
echo ""
;;
5)
echo -e "${YELLOW}Cancelled.${NC}"
exit 0
;;
*)
echo -e "${RED}✗ invalid choice${NC}"
exit 1
;;
esac
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo -e "${RED}✗ version must be MAJOR.MINOR.PATCH (got: ${NEW_VERSION})${NC}"
exit 1
fi
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
echo -e "${RED}✗ tag v${NEW_VERSION} already exists${NC}"
exit 1
fi
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
echo ""
echo -e "${YELLOW}This will:${NC}"
echo " 1. Set package.json → \"version\": \"${NEW_VERSION}\""
echo " 2. Commit the bump"
echo " 3. Create annotated tag v${NEW_VERSION}"
echo " 4. Push master and the tag to every remote"
REMOTES=$(git remote)
for r in $REMOTES; do
echo "$r"
done
echo ""
echo -e "${YELLOW}Note: master push triggers the port-to-omp workflow; the v* tag${NC}"
echo -e "push triggers the npm publish workflow (this package + omp port).${NC}"
echo ""
read -p "Proceed? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Aborted.${NC}"
exit 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo -e "${BLUE}── dry run: no changes made ──${NC}"
exit 0
fi
# ── Apply the bump ───────────────────────────────────────────────────────────
echo ""
echo -e "${CYAN}[1/4]${NC} Updating package.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"${NEW_VERSION}\"/" package.json
rm -f package.json.bak
echo -e "${GREEN}✓ package.json updated${NC}"
if git diff --quiet -- package.json; then
echo -e "${RED}✗ package.json did not change${NC}"
exit 1
fi
echo -e "${CYAN}[2/4]${NC} Committing..."
git add package.json
git commit -m "chore: bump version to v${NEW_VERSION}"
echo -e "${GREEN}✓ committed${NC}"
echo -e "${CYAN}[3/4]${NC} Tagging..."
git tag -a "v${NEW_VERSION}" -m "${NAME} v${NEW_VERSION}"
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
echo ""
echo -e "${CYAN}[4/4]${NC} Pushing..."
FAILED=""
for r in $REMOTES; do
echo -e "${BLUE}${r}${NC} master..."
if git push "$r" master >/dev/null 2>&1; then
echo -e " ${GREEN}✓ master pushed${NC}"
else
echo -e " ${RED}✗ master push failed${NC}"
FAILED="$FAILED $r"
continue
fi
echo -e "${BLUE}${r}${NC} v${NEW_VERSION}..."
if git push "$r" "v${NEW_VERSION}" >/dev/null 2>&1; then
echo -e " ${GREEN}✓ tag pushed${NC}"
else
echo -e " ${RED}✗ tag push failed${NC}"
FAILED="$FAILED $r"
fi
done
echo ""
if [ -n "$FAILED" ]; then
echo -e "${RED}✗ push failed for remote(s):${FAILED}${NC}"
echo -e "${YELLOW}Re-run: git push <remote> master && git push <remote> v${NEW_VERSION}${NC}"
exit 1
fi
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo -e "${GREEN}${NAME} v${NEW_VERSION} released${NC}"
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION}${GREEN}${NEW_VERSION}${NC}"
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
echo ""
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
echo " 1. Gitea Actions port-to-omp regenerates and pushes the omp port"
echo " (which now carries version ${NEW_VERSION})"
echo " 2. Gitea Actions publish npm-publishes ${NAME} and"
echo " ${OMP_NAME} to the npm registry"
echo ""
echo -e "${CYAN}Local port (optional):${NC} run 'bun port-to-omp.mjs' in this repo, or"
echo "pull in ~/.omp once CI has pushed."

View File

@@ -243,7 +243,7 @@ async function runSessionToCompletion(
session: AgentSession,
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
const acc: SessionEventAccumulator = { text: "" };
const acc: SessionEventAccumulator = { text: "", sawMessage: false };
try {
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
applySessionEvent(acc, event, opts.onEvent);

View File

@@ -21,14 +21,13 @@
* @module pygienium/checks/deep-modules
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import {
registerCheck,
type CheckDefinition,
type CheckScope,
} from "./registry.js";
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
import { hasScopeSources, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */
export function deepModulesOutputDir(cwd: string): string {
@@ -50,23 +49,13 @@ export function changesPath(cwd: string): string {
* with zero source files gives the scanner nothing to classify.
*/
function deepModulesGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
if (isScopeSource(entry)) {
found = true;
break;
}
}
if (hasScopeSources(cwd)) return undefined;
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
return undefined;
return "no source files found to inspect";
}
/**

View File

@@ -41,14 +41,13 @@
* @module pygienium/checks/defensive-guards
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import {
registerCheck,
type CheckDefinition,
type CheckScope,
} from "./registry.js";
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
import { hasScopeSources, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */
export function defensiveGuardsOutputDir(cwd: string): string {
@@ -70,23 +69,13 @@ export function changesPath(cwd: string): string {
* with zero source files gives the scanner nothing to analyse.
*/
function defensiveGuardsGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
if (isScopeSource(entry)) {
found = true;
break;
}
}
if (hasScopeSources(cwd)) return undefined;
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
return undefined;
return "no source files found to inspect";
}
/**

View File

@@ -9,6 +9,10 @@
* @module pygienium/checks/scope
*/
import { readdirSync, statSync } from "node:fs";
import type { Dirent } from "node:fs";
import { join } from "node:path";
/**
* Implementation-code file extensions pygienium inspects.
*
@@ -106,6 +110,38 @@ export function isScopeSource(path: string): boolean {
return SCOPE_EXTENSIONS.has(lower.slice(dot));
}
/**
* True when the tree rooted at `root` contains at least one in-scope source
* file. Walks recursively (honoring {@link SCOPE_EXCLUDE_DIRS}) — a top-level
* entry scan alone would skip any repo whose source lives in subdirectories,
* e.g. `game/` or `src/`, even though recon's git inventory finds hundreds of
* files. A file root is judged by {@link isScopeSource} directly.
*/
export function hasScopeSources(root: string): boolean {
const st = statSync(root, { throwIfNoEntry: false });
if (!st) return false;
if (st.isFile()) return isScopeSource(root);
const stack = [root];
while (stack.length > 0) {
const dir = stack.pop() as string;
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SCOPE_EXCLUDE_DIRS.has(entry.name)) continue;
stack.push(join(dir, entry.name));
} else if (entry.isFile() && isScopeSource(entry.name)) {
return true;
}
}
}
return false;
}
/**
* Markdown section injected into every scan task string so the sub-agent knows
* exactly what to inspect and what to skip — stated once here, not copy-pasted

View File

@@ -36,7 +36,6 @@
* @module pygienium/checks/todos
*/
import { readdirSync } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import { loadRunState } from "../run-state.js";
@@ -46,6 +45,7 @@ import {
type CheckScope,
} from "./registry.js";
import {
hasScopeSources,
isScopeSource,
SCOPE_EXCLUDE_DIRS,
scopeRulesMarkdown,
@@ -405,23 +405,13 @@ function renderFindings(
* with zero source files gives the scanner nothing to analyse.
*/
function todosGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
if (isScopeSource(entry)) {
found = true;
break;
}
}
if (hasScopeSources(cwd)) return undefined;
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
return undefined;
return "no source files found to inspect";
}
/**

View File

@@ -168,4 +168,17 @@ describe("deep-modules check", () => {
await rm(empty, { recursive: true, force: true }).catch(() => {});
}
});
it("does not skip when sources live in subdirectories (gate scans recursively)", async () => {
// Top level holds only a directory — the old shallow gate skipped any
// repo whose source lives under `game/`/`src/`-style subdirs.
await mkdir(join(cwd, "game"), { recursive: true });
await writeFile(join(cwd, "game", "main.lua"), "return {}\n", "utf8");
const check = getCheck("deep-modules")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["deep-modules"]?.status).toBe("complete");
});
});

View File

@@ -229,4 +229,17 @@ describe("defensive-guards check", () => {
const state = await loadRunState(cwd);
expect(state?.checks["defensive-guards"]?.status).toBe("skipped");
});
it("does not skip when sources live in subdirectories (gate scans recursively)", async () => {
// Top level holds only a directory — the old shallow gate skipped any
// repo whose source lives under `game/`/`src/`-style subdirs.
await mkdir(join(cwd, "game"), { recursive: true });
await writeFile(join(cwd, "game", "main.lua"), "return {}\n", "utf8");
const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
});
});

View File

@@ -335,6 +335,19 @@ describe("todos check", () => {
expect(state?.checks["todos"]?.status).toBe("skipped");
});
it("does not skip when sources live in subdirectories (gate scans recursively)", async () => {
// Top level holds only a directory — the old shallow gate skipped any
// repo whose source lives under `game/`/`src/`-style subdirs.
await mkdir(join(cwd, "game"), { recursive: true });
await writeFile(join(cwd, "game", "main.lua"), "return {}\n", "utf8");
const check = getCheck("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.status).toBe("complete");
});
it("reports a new/resolved delta against the previous run's counts", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;