Compare commits
9 Commits
595ce48f3c
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| bff187da20 | |||
| bb2e89c83f | |||
| 63c2f73a5e | |||
| 3fd386a637 | |||
| c9d384a4fa | |||
| dedcf994df | |||
| 4e56b46dc9 | |||
| 82888f3137 | |||
| d8be026a2b |
51
.gitea/workflows/publish.yml
Normal file
51
.gitea/workflows/publish.yml
Normal 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
37
.githooks/pre-commit
Executable 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
|
||||||
15
README.md
15
README.md
@@ -202,6 +202,21 @@ pygienium/
|
|||||||
└─ agents/ ← scanner.md fixer.md deep-modules.md defensive-guards.md
|
└─ 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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ noise the user cannot act on.
|
|||||||
|
|
||||||
## Skip (directory names — never descend into)
|
## Skip (directory names — never descend into)
|
||||||
|
|
||||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||||
|
`__pycache__`, `build`, `coverage`,
|
||||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||||
|
|
||||||
## Skip (file patterns)
|
## Skip (file patterns)
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ noise the user cannot act on.
|
|||||||
|
|
||||||
## Skip (directory names — never descend into)
|
## Skip (directory names — never descend into)
|
||||||
|
|
||||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||||
|
`__pycache__`, `build`, `coverage`,
|
||||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||||
|
|
||||||
## Skip (file patterns)
|
## Skip (file patterns)
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ noise the user cannot act on.
|
|||||||
|
|
||||||
## Skip (directory names — never descend into)
|
## Skip (directory names — never descend into)
|
||||||
|
|
||||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||||
|
`__pycache__`, `build`, `coverage`,
|
||||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||||
|
|
||||||
## Skip (file patterns)
|
## Skip (file patterns)
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ noise the user cannot act on.
|
|||||||
|
|
||||||
## Skip (directory names — never descend into)
|
## Skip (directory names — never descend into)
|
||||||
|
|
||||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||||
|
`__pycache__`, `build`, `coverage`,
|
||||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||||
|
|
||||||
## Skip (file patterns)
|
## Skip (file patterns)
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pygienium",
|
"name": "@mikefreno/pygienium",
|
||||||
"version": "0.1.0",
|
"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.",
|
"description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
@@ -51,5 +51,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"src/",
|
||||||
|
"agents/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
* --out) is a generated artifact. Every op asserts its target and fails
|
* --out) is a generated artifact. Every op asserts its target and fails
|
||||||
* loudly on base drift — never silently producing a stale port.
|
* loudly on base drift — never silently producing a stale port.
|
||||||
*
|
*
|
||||||
* Usage:
|
* The port is updated ONLY by CI on push (see below) — never by hand; local
|
||||||
* bun port-to-omp.mjs # write ~/.omp/agent/extensions/pygenium
|
* edits to ~/.omp/agent/extensions/pygienium are overwritten by the next run.
|
||||||
* bun port-to-omp.mjs --out <dir> # write elsewhere (CI: the omp repo clone)
|
|
||||||
*
|
*
|
||||||
* CI: .gitea/workflows/port-to-omp.yml clones the omp-pygienium repo and
|
* 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 {
|
import {
|
||||||
@@ -35,6 +35,7 @@ const OMP = join(HOME, ".omp", "agent", "extensions");
|
|||||||
|
|
||||||
const SKIP = new Set([
|
const SKIP = new Set([
|
||||||
"port-to-omp.mjs",
|
"port-to-omp.mjs",
|
||||||
|
"release-tag.sh",
|
||||||
".gitea",
|
".gitea",
|
||||||
".github",
|
".github",
|
||||||
"node_modules",
|
"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.
|
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 = {
|
const FILE_RULES = {
|
||||||
"src/index.ts": [
|
"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." },
|
{ 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: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" },
|
||||||
{
|
{
|
||||||
from:
|
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:
|
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",
|
label: "createAgentSession",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -321,6 +327,10 @@ function portExtension() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeFileSync(join(dstDir, "tsconfig.json"), PYGENIUM_TSCONFIG);
|
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)");
|
console.log("== bun install (regenerates bun.lock + node_modules)");
|
||||||
execSync("bun install", { cwd: dstDir, stdio: "inherit" });
|
execSync("bun install", { cwd: dstDir, stdio: "inherit" });
|
||||||
console.log("== done");
|
console.log("== done");
|
||||||
|
|||||||
196
scripts/release-tag.sh
Executable file
196
scripts/release-tag.sh
Executable 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."
|
||||||
@@ -20,6 +20,7 @@ import { dirname, isAbsolute, join } from "node:path";
|
|||||||
import type {
|
import type {
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionEvent,
|
AgentSessionEvent,
|
||||||
|
ExtensionCommandContext,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
||||||
|
|
||||||
@@ -52,6 +53,11 @@ export interface AgentTaskOptions {
|
|||||||
task: string;
|
task: string;
|
||||||
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
|
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
|
||||||
allowedTools?: string[];
|
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`). */
|
/** Optional explicit agent definition (skips `loadAgents`). */
|
||||||
agent?: AgentDef;
|
agent?: AgentDef;
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +214,11 @@ export async function defaultAgentRunner(
|
|||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
cwd: opts.cwd,
|
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,
|
tools,
|
||||||
sessionManager: SessionManager.inMemory(opts.cwd),
|
sessionManager: SessionManager.inMemory(opts.cwd),
|
||||||
resourceLoader: loader,
|
resourceLoader: loader,
|
||||||
@@ -243,7 +254,7 @@ async function runSessionToCompletion(
|
|||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
opts: AgentTaskOptions,
|
opts: AgentTaskOptions,
|
||||||
): Promise<AgentRunResult> {
|
): Promise<AgentRunResult> {
|
||||||
const acc: SessionEventAccumulator = { text: "" };
|
const acc: SessionEventAccumulator = { text: "", sawMessage: false };
|
||||||
try {
|
try {
|
||||||
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||||
applySessionEvent(acc, event, opts.onEvent);
|
applySessionEvent(acc, event, opts.onEvent);
|
||||||
@@ -256,16 +267,15 @@ async function runSessionToCompletion(
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
// Surface session errors that didn't throw but left no useful output.
|
// Surface session errors that didn't throw but left no useful output.
|
||||||
// A session ending with stopReason "error" and no text means the model
|
// A session ending with stopReason "error" and no text means the model
|
||||||
// call failed silently — treat that as a failed run, not ok:true.
|
// call failed silently — treat that as a failed run, not ok:true. A
|
||||||
if (acc.errorMessage) {
|
// session that settled with NO text and NO observed events means the
|
||||||
return { ok: false, text: acc.text, error: acc.errorMessage };
|
// model never produced anything at all (dead provider stream, failed
|
||||||
}
|
// start) — reporting that as a successful analysis would let an empty
|
||||||
if (!acc.text.trim() && acc.stopReason === "error") {
|
// scan masquerade as a clean one, and the check's verify hook would
|
||||||
return {
|
// fail only later with a confusing "artifact missing" error.
|
||||||
ok: false,
|
const sessionError = emptySessionError(acc);
|
||||||
text: acc.text,
|
if (sessionError) {
|
||||||
error: "sub-agent session ended in error with no output.",
|
return { ok: false, text: acc.text, error: sessionError };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { ok: true, text: acc.text };
|
return { ok: true, text: acc.text };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -287,12 +297,34 @@ async function runSessionToCompletion(
|
|||||||
export interface SessionEventAccumulator {
|
export interface SessionEventAccumulator {
|
||||||
/** Joined assistant text seen so far (text_delta stream). */
|
/** Joined assistant text seen so far (text_delta stream). */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Whether any assistant-message or tool event was observed at all. */
|
||||||
|
sawMessage: boolean;
|
||||||
/** stopReason of the final assistant message, when reported. */
|
/** stopReason of the final assistant message, when reported. */
|
||||||
stopReason?: string;
|
stopReason?: string;
|
||||||
/** errorMessage of the final assistant message, when reported. */
|
/** errorMessage of the final assistant message, when reported. */
|
||||||
errorMessage?: string;
|
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
|
* Interpret one session event into the running accumulator and forward the
|
||||||
* stream-driving events to the chat.
|
* stream-driving events to the chat.
|
||||||
@@ -313,6 +345,16 @@ export function applySessionEvent(
|
|||||||
): void {
|
): void {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
try {
|
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 (
|
if (
|
||||||
event.type === "message_update" &&
|
event.type === "message_update" &&
|
||||||
event.assistantMessageEvent?.type === "text_delta"
|
event.assistantMessageEvent?.type === "text_delta"
|
||||||
|
|||||||
@@ -21,14 +21,13 @@
|
|||||||
* @module pygienium/checks/deep-modules
|
* @module pygienium/checks/deep-modules
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
registerCheck,
|
registerCheck,
|
||||||
type CheckDefinition,
|
type CheckDefinition,
|
||||||
type CheckScope,
|
type CheckScope,
|
||||||
} from "./registry.js";
|
} from "./registry.js";
|
||||||
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
|
import { hasScopeSources, scopeRulesMarkdown } from "./scope.js";
|
||||||
|
|
||||||
/** Output directory for this check's persistent reports. */
|
/** Output directory for this check's persistent reports. */
|
||||||
export function deepModulesOutputDir(cwd: string): string {
|
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.
|
* with zero source files gives the scanner nothing to classify.
|
||||||
*/
|
*/
|
||||||
function deepModulesGate(cwd: string): string | undefined {
|
function deepModulesGate(cwd: string): string | undefined {
|
||||||
let found = false;
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(cwd);
|
if (hasScopeSources(cwd)) return undefined;
|
||||||
for (const entry of entries) {
|
|
||||||
if (isScopeSource(entry)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// unreadable cwd → let the agent decide; don't block.
|
// unreadable cwd → let the agent decide; don't block.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!found) {
|
return "no source files found to inspect";
|
||||||
return "no source files found to inspect";
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,14 +41,13 @@
|
|||||||
* @module pygienium/checks/defensive-guards
|
* @module pygienium/checks/defensive-guards
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
registerCheck,
|
registerCheck,
|
||||||
type CheckDefinition,
|
type CheckDefinition,
|
||||||
type CheckScope,
|
type CheckScope,
|
||||||
} from "./registry.js";
|
} from "./registry.js";
|
||||||
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
|
import { hasScopeSources, scopeRulesMarkdown } from "./scope.js";
|
||||||
|
|
||||||
/** Output directory for this check's persistent reports. */
|
/** Output directory for this check's persistent reports. */
|
||||||
export function defensiveGuardsOutputDir(cwd: string): string {
|
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.
|
* with zero source files gives the scanner nothing to analyse.
|
||||||
*/
|
*/
|
||||||
function defensiveGuardsGate(cwd: string): string | undefined {
|
function defensiveGuardsGate(cwd: string): string | undefined {
|
||||||
let found = false;
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(cwd);
|
if (hasScopeSources(cwd)) return undefined;
|
||||||
for (const entry of entries) {
|
|
||||||
if (isScopeSource(entry)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// unreadable cwd → let the agent decide; don't block.
|
// unreadable cwd → let the agent decide; don't block.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!found) {
|
return "no source files found to inspect";
|
||||||
return "no source files found to inspect";
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
* @module pygienium/checks/scope
|
* @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.
|
* Implementation-code file extensions pygienium inspects.
|
||||||
*
|
*
|
||||||
@@ -56,6 +60,13 @@ export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
|
|||||||
".nuxt",
|
".nuxt",
|
||||||
".turbo",
|
".turbo",
|
||||||
".svelte-kit",
|
".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__",
|
"__pycache__",
|
||||||
".venv",
|
".venv",
|
||||||
"venv",
|
"venv",
|
||||||
@@ -99,6 +110,38 @@ export function isScopeSource(path: string): boolean {
|
|||||||
return SCOPE_EXTENSIONS.has(lower.slice(dot));
|
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
|
* 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
|
* exactly what to inspect and what to skip — stated once here, not copy-pasted
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
* @module pygienium/checks/todos
|
* @module pygienium/checks/todos
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdirSync } from "node:fs";
|
|
||||||
import { readFile, readdir, stat } from "node:fs/promises";
|
import { readFile, readdir, stat } from "node:fs/promises";
|
||||||
import { join, relative } from "node:path";
|
import { join, relative } from "node:path";
|
||||||
import { loadRunState } from "../run-state.js";
|
import { loadRunState } from "../run-state.js";
|
||||||
@@ -46,6 +45,7 @@ import {
|
|||||||
type CheckScope,
|
type CheckScope,
|
||||||
} from "./registry.js";
|
} from "./registry.js";
|
||||||
import {
|
import {
|
||||||
|
hasScopeSources,
|
||||||
isScopeSource,
|
isScopeSource,
|
||||||
SCOPE_EXCLUDE_DIRS,
|
SCOPE_EXCLUDE_DIRS,
|
||||||
scopeRulesMarkdown,
|
scopeRulesMarkdown,
|
||||||
@@ -124,6 +124,21 @@ const MAX_CANDIDATES = 500;
|
|||||||
const FALLBACK_CAP = 16;
|
const FALLBACK_CAP = 16;
|
||||||
/** Candidate list embedded in the live prompt is truncated at this many. */
|
/** Candidate list embedded in the live prompt is truncated at this many. */
|
||||||
const PROMPT_CAP = 40;
|
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. */
|
/** Extract the declared function name from a header line, when present. */
|
||||||
function headerName(line: string): string | undefined {
|
function headerName(line: string): string | undefined {
|
||||||
@@ -369,7 +384,7 @@ function renderFindings(
|
|||||||
parts.push(`## ${title}`);
|
parts.push(`## ${title}`);
|
||||||
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
|
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
|
||||||
parts.push(
|
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) {
|
if (list.length > FALLBACK_CAP) {
|
||||||
@@ -390,23 +405,13 @@ function renderFindings(
|
|||||||
* with zero source files gives the scanner nothing to analyse.
|
* with zero source files gives the scanner nothing to analyse.
|
||||||
*/
|
*/
|
||||||
function todosGate(cwd: string): string | undefined {
|
function todosGate(cwd: string): string | undefined {
|
||||||
let found = false;
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(cwd);
|
if (hasScopeSources(cwd)) return undefined;
|
||||||
for (const entry of entries) {
|
|
||||||
if (isScopeSource(entry)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// unreadable cwd → let the agent decide; don't block.
|
// unreadable cwd → let the agent decide; don't block.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!found) {
|
return "no source files found to inspect";
|
||||||
return "no source files found to inspect";
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -434,7 +439,7 @@ export async function buildTodosScanTask(
|
|||||||
|
|
||||||
const candidateList = candidates
|
const candidateList = candidates
|
||||||
.slice(0, PROMPT_CAP)
|
.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) {
|
if (candidates.length > PROMPT_CAP) {
|
||||||
candidateList.push(
|
candidateList.push(
|
||||||
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,
|
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import type { SendChatMessage } from "./phases.js";
|
|||||||
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
|
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
|
||||||
export type PygieniumCtx = Pick<
|
export type PygieniumCtx = Pick<
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
"cwd" | "mode" | "hasUI" | "ui"
|
"cwd" | "mode" | "hasUI" | "ui" | "model"
|
||||||
> & {
|
> & {
|
||||||
/** Optional callback to post messages to the chat window. */
|
/** Optional callback to post messages to the chat window. */
|
||||||
sendChatMessage?: SendChatMessage;
|
sendChatMessage?: SendChatMessage;
|
||||||
@@ -176,6 +176,7 @@ export async function handleCheckCommand(
|
|||||||
existingState: existing,
|
existingState: existing,
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
|
model: ctx.model,
|
||||||
sendChatMessage: ctx.sendChatMessage,
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
onAgentEvent: ctx.onAgentEvent,
|
onAgentEvent: ctx.onAgentEvent,
|
||||||
sendPhaseLine: ctx.sendPhaseLine,
|
sendPhaseLine: ctx.sendPhaseLine,
|
||||||
@@ -215,6 +216,7 @@ export async function handleAllCommand(
|
|||||||
only: parsed.only,
|
only: parsed.only,
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
|
model: ctx.model,
|
||||||
sendChatMessage: ctx.sendChatMessage,
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
onAgentEvent: ctx.onAgentEvent,
|
onAgentEvent: ctx.onAgentEvent,
|
||||||
sendPhaseLine: ctx.sendPhaseLine,
|
sendPhaseLine: ctx.sendPhaseLine,
|
||||||
@@ -303,6 +305,7 @@ export async function handleResumeCommand(
|
|||||||
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
|
model: ctx.model,
|
||||||
existingState: state,
|
existingState: state,
|
||||||
sendChatMessage: ctx.sendChatMessage,
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
onAgentEvent: ctx.onAgentEvent,
|
onAgentEvent: ctx.onAgentEvent,
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ export default async function pygieniumExtension(
|
|||||||
mode: ctx.mode,
|
mode: ctx.mode,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
|
model: ctx.model,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
onAgentEvent,
|
onAgentEvent,
|
||||||
sendPhaseLine,
|
sendPhaseLine,
|
||||||
|
|||||||
@@ -22,10 +22,13 @@
|
|||||||
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join, resolve } from "node:path";
|
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 { getAllChecks, type CheckDefinition } from "../checks/registry.js";
|
||||||
import { runCheck } from "./check-runner.js";
|
import { runCheck } from "./check-runner.js";
|
||||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
||||||
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
||||||
import { runRecon } from "../recon.js";
|
import { runRecon } from "../recon.js";
|
||||||
@@ -74,6 +77,12 @@ export interface AllRunOptions {
|
|||||||
ui?: ExtensionUIContext;
|
ui?: ExtensionUIContext;
|
||||||
/** Whether dialog-capable UI is available. */
|
/** Whether dialog-capable UI is available. */
|
||||||
hasUI?: boolean;
|
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. */
|
/** Optional callback to post completion messages into the chat. */
|
||||||
sendChatMessage?: SendChatMessage;
|
sendChatMessage?: SendChatMessage;
|
||||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||||
@@ -369,6 +378,7 @@ export async function runAllChecks(
|
|||||||
scope: { cwd, target, fix, rest: [] },
|
scope: { cwd, target, fix, rest: [] },
|
||||||
ui: opts.ui,
|
ui: opts.ui,
|
||||||
hasUI,
|
hasUI,
|
||||||
|
model: opts.model,
|
||||||
existingState: state,
|
existingState: state,
|
||||||
sendChatMessage: opts.sendChatMessage,
|
sendChatMessage: opts.sendChatMessage,
|
||||||
onAgentEvent: opts.onAgentEvent,
|
onAgentEvent: opts.onAgentEvent,
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
|
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { resolve, join } from "node:path";
|
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 type { CheckDefinition, CheckScope } from "../checks/registry.js";
|
||||||
import { runAgentTask } from "../agent-runner.js";
|
import { runAgentTask } from "../agent-runner.js";
|
||||||
import { runRecon } from "../recon.js";
|
import { runRecon } from "../recon.js";
|
||||||
@@ -84,6 +87,12 @@ export interface RunCheckOptions {
|
|||||||
ui?: ExtensionUIContext;
|
ui?: ExtensionUIContext;
|
||||||
/** Whether dialog-capable UI is available. */
|
/** Whether dialog-capable UI is available. */
|
||||||
hasUI?: boolean;
|
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). */
|
/** Pre-existing run state to update (for `/pygienium-all` and resume). */
|
||||||
existingState?: RunState;
|
existingState?: RunState;
|
||||||
/** Optional callback to post completion messages into the chat. */
|
/** Optional callback to post completion messages into the chat. */
|
||||||
@@ -278,6 +287,7 @@ async function runCheckImplInner(
|
|||||||
cwd: scope.target,
|
cwd: scope.target,
|
||||||
agentName: check.agentName,
|
agentName: check.agentName,
|
||||||
task: scanTask,
|
task: scanTask,
|
||||||
|
model: opts.model,
|
||||||
onEvent: forward(PHASE_ANALYSIS),
|
onEvent: forward(PHASE_ANALYSIS),
|
||||||
});
|
});
|
||||||
findings = scanResult.text;
|
findings = scanResult.text;
|
||||||
@@ -310,6 +320,7 @@ async function runCheckImplInner(
|
|||||||
cwd: scope.target,
|
cwd: scope.target,
|
||||||
agentName: check.fixAgentName ?? "fixer",
|
agentName: check.fixAgentName ?? "fixer",
|
||||||
task: fixTask,
|
task: fixTask,
|
||||||
|
model: opts.model,
|
||||||
onEvent: forward(PHASE_FIX),
|
onEvent: forward(PHASE_FIX),
|
||||||
});
|
});
|
||||||
changes = fixResult.text;
|
changes = fixResult.text;
|
||||||
|
|||||||
@@ -10,12 +10,13 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import {
|
import {
|
||||||
applySessionEvent,
|
applySessionEvent,
|
||||||
|
emptySessionError,
|
||||||
type SessionEventAccumulator,
|
type SessionEventAccumulator,
|
||||||
} from "../src/agent-runner.js";
|
} from "../src/agent-runner.js";
|
||||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
function fresh(): SessionEventAccumulator {
|
function fresh(): SessionEventAccumulator {
|
||||||
return { text: "" };
|
return { text: "", sawMessage: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a typed-as-unknown event so malformed shapes compile in tests. */
|
/** Build a typed-as-unknown event so malformed shapes compile in tests. */
|
||||||
@@ -134,4 +135,54 @@ describe("applySessionEvent", () => {
|
|||||||
expect(forwarded).toEqual([]);
|
expect(forwarded).toEqual([]);
|
||||||
expect(acc.text).toBe("x");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
it("persists run-state.json at the expected path", async () => {
|
||||||
const check = smokeCheck();
|
const check = smokeCheck();
|
||||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||||
|
|||||||
@@ -168,4 +168,17 @@ describe("deep-modules check", () => {
|
|||||||
await rm(empty, { recursive: true, force: true }).catch(() => {});
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -229,4 +229,17 @@ describe("defensive-guards check", () => {
|
|||||||
const state = await loadRunState(cwd);
|
const state = await loadRunState(cwd);
|
||||||
expect(state?.checks["defensive-guards"]?.status).toBe("skipped");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -205,6 +205,30 @@ describe("detectTodoStubs", () => {
|
|||||||
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
|
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
|
||||||
).toBe(true);
|
).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", () => {
|
describe("todos check", () => {
|
||||||
@@ -311,6 +335,19 @@ describe("todos check", () => {
|
|||||||
expect(state?.checks["todos"]?.status).toBe("skipped");
|
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 () => {
|
it("reports a new/resolved delta against the previous run's counts", async () => {
|
||||||
await seedStubs(cwd);
|
await seedStubs(cwd);
|
||||||
const check = getCheck("todos")!;
|
const check = getCheck("todos")!;
|
||||||
@@ -334,4 +371,25 @@ describe("todos check", () => {
|
|||||||
);
|
);
|
||||||
expect(task).toContain("| new: 0 | resolved: 4 |");
|
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("…");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user