Compare commits

...

9 Commits

Author SHA1 Message Date
bff187da20 fix: use current selected model
All checks were successful
port-to-omp / port (push) Successful in 10s
publish / publish (push) Successful in 14s
2026-08-21 21:18:15 -04:00
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
82888f3137 docs: port is updated only by CI on push — drop manual regeneration usage note
Some checks failed
port-to-omp / port (push) Failing after 5s
2026-08-11 12:16:24 -04:00
d8be026a2b fix: todos scan task ballooned to 2.5MB and analysis produced no output
The todos pre-scan walked .output/ (Nitro) and .vercel/ (Vercel) build
dirs, flagging 119 of 124 candidates inside minified bundles (single
lines up to 162KB). buildTodosScanTask embedded full candidate lines in
the task prompt, producing a 2.5MB prompt on freno-dev; the analysis
agent settled with ok:true + empty text + no findings.md, verify failed,
and resume re-ran the same oversized prompt and failed identically.

- scope: exclude .output/.vercel/.netlify (shared by all checks)
- todos: truncate candidate code at 160 chars in the prompt + fallback
- agent-runner: a session settling with no text and no observed message/
  tool events now fails the run loudly instead of reporting ok:true
- agent prompts: add the three dirs to each skip list
- tests: excluded-dir scan, prompt truncation, emptySessionError cases
2026-08-11 12:12:15 -04:00
24 changed files with 645 additions and 76 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

@@ -71,8 +71,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)

View File

@@ -87,8 +87,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)

View File

@@ -36,8 +36,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)

View File

@@ -100,8 +100,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)

View File

@@ -1,6 +1,6 @@
{
"name": "pygienium",
"version": "0.1.0",
"name": "@mikefreno/pygienium",
"version": "0.1.2",
"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

@@ -7,12 +7,12 @@
* --out) is a generated artifact. Every op asserts its target and fails
* loudly on base drift — never silently producing a stale port.
*
* Usage:
* bun port-to-omp.mjs # write ~/.omp/agent/extensions/pygenium
* bun port-to-omp.mjs --out <dir> # write elsewhere (CI: the omp repo clone)
* The port is updated ONLY by CI on push (see below) — never by hand; local
* edits to ~/.omp/agent/extensions/pygienium are overwritten by the next run.
*
* CI: .gitea/workflows/port-to-omp.yml clones the omp-pygienium repo and
* runs this script into it, then commits + pushes when the port changed.
* runs this script into it (`bun port-to-omp.mjs --out <dir>`), then
* commits + pushes when the port changed.
*/
import {
@@ -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." },
@@ -250,9 +256,9 @@ const FILE_RULES = {
{ from: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" },
{
from:
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});",
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\t// Pin the sub-agent to the model the user has selected in the invoking\n\t\t// session rather than the settings default. Omitted when the caller\n\t\t// has no live session model (print/RPC modes), which keeps the\n\t\t// settings-default fallback.\n\t\t...(opts.model ? { model: opts.model } : {}),\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});",
to:
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});",
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\t// Pin the sub-agent to the model the user has selected in the invoking\n\t\t// session rather than the settings default.\n\t\t...(opts.model ? { model: opts.model } : {}),\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});",
label: "createAgentSession",
},
],
@@ -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

@@ -20,6 +20,7 @@ import { dirname, isAbsolute, join } from "node:path";
import type {
AgentSession,
AgentSessionEvent,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
@@ -52,6 +53,11 @@ export interface AgentTaskOptions {
task: string;
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
allowedTools?: string[];
/**
* The currently selected model from the invoking session. When omitted,
* `createAgentSession` falls back to the settings default model.
*/
model?: ExtensionCommandContext["model"];
/** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef;
/**
@@ -208,6 +214,11 @@ export async function defaultAgentRunner(
const { session } = await createAgentSession({
cwd: opts.cwd,
// Pin the sub-agent to the model the user has selected in the invoking
// session rather than the settings default. Omitted when the caller
// has no live session model (print/RPC modes), which keeps the
// settings-default fallback.
...(opts.model ? { model: opts.model } : {}),
tools,
sessionManager: SessionManager.inMemory(opts.cwd),
resourceLoader: loader,
@@ -243,7 +254,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);
@@ -256,16 +267,15 @@ async function runSessionToCompletion(
unsubscribe();
// Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true.
if (acc.errorMessage) {
return { ok: false, text: acc.text, error: acc.errorMessage };
}
if (!acc.text.trim() && acc.stopReason === "error") {
return {
ok: false,
text: acc.text,
error: "sub-agent session ended in error with no output.",
};
// call failed silently — treat that as a failed run, not ok:true. A
// session that settled with NO text and NO observed events means the
// model never produced anything at all (dead provider stream, failed
// start) — reporting that as a successful analysis would let an empty
// scan masquerade as a clean one, and the check's verify hook would
// fail only later with a confusing "artifact missing" error.
const sessionError = emptySessionError(acc);
if (sessionError) {
return { ok: false, text: acc.text, error: sessionError };
}
return { ok: true, text: acc.text };
} catch (err) {
@@ -287,12 +297,34 @@ async function runSessionToCompletion(
export interface SessionEventAccumulator {
/** Joined assistant text seen so far (text_delta stream). */
text: string;
/** Whether any assistant-message or tool event was observed at all. */
sawMessage: boolean;
/** stopReason of the final assistant message, when reported. */
stopReason?: string;
/** errorMessage of the final assistant message, when reported. */
errorMessage?: string;
}
/**
* Classify a settled session's capture: `undefined` when the result is a
* legitimate (possibly empty-text) outcome, else the error that should fail
* the run. Kept pure so the decision is unit-testable without a session.
*/
export function emptySessionError(
acc: SessionEventAccumulator,
): string | undefined {
if (acc.errorMessage) return acc.errorMessage;
if (!acc.text.trim()) {
if (acc.stopReason === "error") {
return "sub-agent session ended in error with no output.";
}
if (!acc.sawMessage) {
return "sub-agent session settled with no output — no assistant message or tool activity was observed. Check model/provider connectivity, then resume.";
}
}
return undefined;
}
/**
* Interpret one session event into the running accumulator and forward the
* stream-driving events to the chat.
@@ -313,6 +345,16 @@ export function applySessionEvent(
): void {
if (!event) return;
try {
// Any message or tool event means the session actually ran — a settled
// capture with none of these is a dead session, not an empty scan.
if (
event.type === "message_update" ||
event.type === "message_end" ||
event.type === "tool_execution_start" ||
event.type === "tool_execution_end"
) {
acc.sawMessage = true;
}
if (
event.type === "message_update" &&
event.assistantMessageEvent?.type === "text_delta"

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.
*
@@ -56,6 +60,13 @@ export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
".nuxt",
".turbo",
".svelte-kit",
// Framework/deploy build output: Nitro, Vercel, Netlify artifact dirs.
// Generated bundles dominate a naive marker/stub scan (freno-dev alone
// flagged 119 of 124 candidates inside `.output/`/`.vercel/` minified
// bundles) and must never feed the pre-scan or the agent's file walk.
".output",
".vercel",
".netlify",
"__pycache__",
".venv",
"venv",
@@ -99,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,
@@ -124,6 +124,21 @@ const MAX_CANDIDATES = 500;
const FALLBACK_CAP = 16;
/** Candidate list embedded in the live prompt is truncated at this many. */
const PROMPT_CAP = 40;
/**
* Max characters of a candidate's code line embedded in the task prompt.
* Generated/bundled single lines can be hundreds of KB (e.g. minified assets
* sneaking past scope); embedding them wholesale balloons the task to
* megabytes and chokes the sub-agent. `path:line` plus a truncated prefix is
* enough to classify — the agent can read the file for full context.
*/
const CODE_DISPLAY_CAP = 160;
/** Truncate a candidate's code line for prompt embedding. */
function displayCode(code: string): string {
return code.length > CODE_DISPLAY_CAP
? `${code.slice(0, CODE_DISPLAY_CAP)}`
: code;
}
/** Extract the declared function name from a header line, when present. */
function headerName(line: string): string | undefined {
@@ -369,7 +384,7 @@ function renderFindings(
parts.push(`## ${title}`);
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
parts.push(
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line}${c.code} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line}${displayCode(c.code)} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
);
});
if (list.length > FALLBACK_CAP) {
@@ -390,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";
}
/**
@@ -434,7 +439,7 @@ export async function buildTodosScanTask(
const candidateList = candidates
.slice(0, PROMPT_CAP)
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${c.code}`);
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${displayCode(c.code)}`);
if (candidates.length > PROMPT_CAP) {
candidateList.push(
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,

View File

@@ -48,7 +48,7 @@ import type { SendChatMessage } from "./phases.js";
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
export type PygieniumCtx = Pick<
ExtensionCommandContext,
"cwd" | "mode" | "hasUI" | "ui"
"cwd" | "mode" | "hasUI" | "ui" | "model"
> & {
/** Optional callback to post messages to the chat window. */
sendChatMessage?: SendChatMessage;
@@ -176,6 +176,7 @@ export async function handleCheckCommand(
existingState: existing,
ui: ctx.ui,
hasUI: ctx.hasUI,
model: ctx.model,
sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
@@ -215,6 +216,7 @@ export async function handleAllCommand(
only: parsed.only,
ui: ctx.ui,
hasUI: ctx.hasUI,
model: ctx.model,
sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
@@ -303,6 +305,7 @@ export async function handleResumeCommand(
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
ui: ctx.ui,
hasUI: ctx.hasUI,
model: ctx.model,
existingState: state,
sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,

View File

@@ -410,6 +410,7 @@ export default async function pygieniumExtension(
mode: ctx.mode,
hasUI: ctx.hasUI,
ui: ctx.ui,
model: ctx.model,
sendChatMessage,
onAgentEvent,
sendPhaseLine,

View File

@@ -22,10 +22,13 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import type {
AgentSessionEvent,
ExtensionCommandContext,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
import { runCheck } from "./check-runner.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
import { createPipelineFooter, type ItemStatus } from "../footer.js";
import { runRecon } from "../recon.js";
@@ -74,6 +77,12 @@ export interface AllRunOptions {
ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
/**
* The currently selected model from the invoking session; forwarded to
* each check's sub-agents so scans run on the model the user picked, not
* the settings default.
*/
model?: ExtensionCommandContext["model"];
/** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage;
/** Optional callback forwarding raw sub-agent events to the chat stream. */
@@ -369,6 +378,7 @@ export async function runAllChecks(
scope: { cwd, target, fix, rest: [] },
ui: opts.ui,
hasUI,
model: opts.model,
existingState: state,
sendChatMessage: opts.sendChatMessage,
onAgentEvent: opts.onAgentEvent,

View File

@@ -17,7 +17,10 @@
import { rm } from "node:fs/promises";
import { resolve, join } from "node:path";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import type {
ExtensionCommandContext,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
import { runAgentTask } from "../agent-runner.js";
import { runRecon } from "../recon.js";
@@ -84,6 +87,12 @@ export interface RunCheckOptions {
ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
/**
* The currently selected model from the invoking session; forwarded to
* each sub-agent so scans run on the model the user picked, not the
* settings default.
*/
model?: ExtensionCommandContext["model"];
/** Pre-existing run state to update (for `/pygienium-all` and resume). */
existingState?: RunState;
/** Optional callback to post completion messages into the chat. */
@@ -278,6 +287,7 @@ async function runCheckImplInner(
cwd: scope.target,
agentName: check.agentName,
task: scanTask,
model: opts.model,
onEvent: forward(PHASE_ANALYSIS),
});
findings = scanResult.text;
@@ -310,6 +320,7 @@ async function runCheckImplInner(
cwd: scope.target,
agentName: check.fixAgentName ?? "fixer",
task: fixTask,
model: opts.model,
onEvent: forward(PHASE_FIX),
});
changes = fixResult.text;

View File

@@ -10,12 +10,13 @@
import { describe, expect, it } from "bun:test";
import {
applySessionEvent,
emptySessionError,
type SessionEventAccumulator,
} from "../src/agent-runner.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
function fresh(): SessionEventAccumulator {
return { text: "" };
return { text: "", sawMessage: false };
}
/** Build a typed-as-unknown event so malformed shapes compile in tests. */
@@ -134,4 +135,54 @@ describe("applySessionEvent", () => {
expect(forwarded).toEqual([]);
expect(acc.text).toBe("x");
});
it("marks sawMessage when any message or tool event is observed", () => {
const fromUpdate = fresh();
applySessionEvent(fromUpdate, event({ type: "message_update" }));
expect(fromUpdate.sawMessage).toBe(true);
const fromEnd = fresh();
applySessionEvent(fromEnd, event({ type: "message_end", message: null }));
expect(fromEnd.sawMessage).toBe(true);
const fromTool = fresh();
applySessionEvent(fromTool, event({ type: "tool_execution_start" }));
expect(fromTool.sawMessage).toBe(true);
});
});
describe("emptySessionError", () => {
it("fails a session that settled with no text and no observed events", () => {
expect(emptySessionError({ text: "", sawMessage: false })).toContain(
"no output",
);
});
it("fails an empty session whose final message reported stopReason error", () => {
expect(
emptySessionError({ text: "", sawMessage: true, stopReason: "error" }),
).toBe("sub-agent session ended in error with no output.");
});
it("surfaces a recorded errorMessage regardless of text", () => {
expect(
emptySessionError({
text: "partial output",
sawMessage: true,
errorMessage: "upstream 529",
}),
).toBe("upstream 529");
});
it("accepts an empty-text session that demonstrably ran (tool activity)", () => {
expect(
emptySessionError({ text: "", sawMessage: true, stopReason: "end_turn" }),
).toBeUndefined();
});
it("accepts any session with text", () => {
expect(
emptySessionError({ text: "report", sawMessage: false }),
).toBeUndefined();
});
});

View File

@@ -110,6 +110,28 @@ describe("check-runner integration", () => {
);
});
it("forwards the selected model to every sub-agent", async () => {
const check = smokeCheck();
const selectedModel = {
provider: "test-provider",
id: "test-model",
} as unknown as PygieniumCtx["model"];
const seen: unknown[] = [];
setAgentRunner(async (opts) => {
seen.push(opts.model);
return fakeAgentRunner(opts);
});
await handleCheckCommand(
check,
"--fix",
{ ...stubCtx(cwd), model: selectedModel } as PygieniumCtx,
);
// Analysis + fix phases each dispatch one sub-agent.
expect(seen.length).toBe(2);
for (const m of seen) expect(m).toBe(selectedModel);
});
it("persists run-state.json at the expected path", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));

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

@@ -205,6 +205,30 @@ describe("detectTodoStubs", () => {
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
).toBe(true);
});
it("never descends into build/deploy output directories", async () => {
// Generated bundles under framework build dirs must not feed the
// pre-scan: they dominate candidate counts with minified noise (the
// freno-dev failure flagged 119 of 124 candidates inside
// `.output`/`.vercel` bundles, ballooning the scan task to 2.5 MB).
for (const rel of [
join(".output", "public", "bundle.js"),
join(".vercel", "output", "static", "app.js"),
join(".netlify", "functions", "bundle.js"),
]) {
const full = join(dir, rel);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(
full,
"// TODO: bundle placeholder\nfunction f(){ return 0; }\nthrow new Error('not implemented');\n",
"utf8",
);
}
const hits = await detectTodoStubs(dir);
expect(
hits.filter((h) => /(?:\.output|\.vercel|\.netlify)[/\\]/.test(h.path)),
).toHaveLength(0);
});
});
describe("todos check", () => {
@@ -311,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")!;
@@ -334,4 +371,25 @@ describe("todos check", () => {
);
expect(task).toContain("| new: 0 | resolved: 4 |");
});
it("truncates giant single-line candidates so the task prompt stays bounded", async () => {
// A minified/generated single line can be hundreds of KB; embedding it
// wholesale ballooned the freno-dev task to 2.5 MB and choked the
// analysis agent. The task must carry a truncated prefix, never the
// full line.
const long = `// TODO: ${"x".repeat(400)}`;
await writeFile(
join(cwd, "huge.ts"),
`${long}\nexport function f() { return 0; }\n`,
"utf8",
);
const task = await buildTodosScanTask(cwd, {
cwd,
target: cwd,
fix: false,
rest: [],
});
expect(task).not.toContain("x".repeat(400));
expect(task).toContain("…");
});
});